feat: add model configuration management and logging enhancements
Some checks failed
CI / Unit tests (push) Has been cancelled
CI / commit_lint (push) Has been cancelled

- Introduced a new ModelConfigEditor component for managing model configurations.
- Added API endpoints for model configuration management, including fetching, saving, and importing/exporting configurations.
- Enhanced logging for request bodies, particularly for multimodal requests containing image URLs.
- Updated the .gitignore to include new configuration files.
- Refactored model handling in the controller to support custom model configurations.
This commit is contained in:
hjjjj 2026-04-08 16:33:27 +08:00
parent 8b87c3d404
commit a9a224e7dd
9 changed files with 1548 additions and 29 deletions

4
.gitignore vendored
View File

@ -13,4 +13,6 @@ cmd.md
/one-api
temp
.DS_Store
.claude
.claude
.claw/
.claw-todos.json

View File

@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/ctxkey"
"github.com/songquanpeng/one-api/common/logger"
)
func GetRequestBody(c *gin.Context) ([]byte, error) {
@ -29,6 +30,18 @@ func UnmarshalBodyReusable(c *gin.Context, v any) error {
if err != nil {
return err
}
// 添加诊断日志检查原始请求体是否包含image_url
bodyStr := string(requestBody)
if strings.Contains(bodyStr, "image_url") {
logger.Infof(c.Request.Context(), "=== RAW REQUEST BODY contains image_url ===")
if len(bodyStr) < 500 {
logger.Infof(c.Request.Context(), "=== RAW BODY: %s ===", bodyStr)
} else {
logger.Infof(c.Request.Context(), "=== RAW BODY (first 500 chars): %s ===", bodyStr[:500])
}
}
contentType := c.Request.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "application/json") {
err = json.Unmarshal(requestBody, &v)

View File

@ -11,12 +11,14 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/config"
"github.com/songquanpeng/one-api/common/ctxkey"
"github.com/songquanpeng/one-api/common/logger"
"github.com/songquanpeng/one-api/model"
billingratio "github.com/songquanpeng/one-api/relay/billing/ratio"
relay "github.com/songquanpeng/one-api/relay"
"github.com/songquanpeng/one-api/relay/adaptor/openai"
"github.com/songquanpeng/one-api/relay/apitype"
billingratio "github.com/songquanpeng/one-api/relay/billing/ratio"
"github.com/songquanpeng/one-api/relay/channeltype"
"github.com/songquanpeng/one-api/relay/meta"
relaymodel "github.com/songquanpeng/one-api/relay/model"
@ -234,11 +236,26 @@ type ModelCatalogItem struct {
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
// PricingDiscount: e.g. "0.8" means 20% off; applied to both input and output prices
PricingDiscount string `json:"pricing_discount"`
Tags []string `json:"tags"`
PricingDiscount string `json:"pricing_discount"`
Tags []string `json:"tags"`
// SupportsReasoning: 0=none, 1=always-on, 2=toggleable
SupportsReasoning int `json:"supports_reasoning"`
SuitableApi string `json:"suitable_api"`
SupportsReasoning int `json:"supports_reasoning"`
SuitableApi string `json:"suitable_api"`
CacheWritePrice float64 `json:"cache_write_price,omitempty"`
CacheReadPrice float64 `json:"cache_read_price,omitempty"`
ReasoningConfig map[string]interface{} `json:"reasoning_config,omitempty"`
CacheType string `json:"cache_type,omitempty"`
ModelType string `json:"model_type,omitempty"`
SupportsFunctionCalling bool `json:"supports_function_calling,omitempty"`
SupportsComputerUse bool `json:"supports_computer_use,omitempty"`
Icon string `json:"icon,omitempty"`
// IsCustom: true if this model config is manually configured
IsCustom bool `json:"is_custom"`
}
// ModelConfigRequest is the request format for saving model config
type ModelConfigRequest struct {
Models []ModelCatalogItem `json:"models"`
}
// zenmuxModel is the raw response shape from Zenmux listByFilter API.
@ -278,8 +295,280 @@ var (
}
return "2uF44yawHUs41edv2fRcV_eE"
}()
// Custom model config storage
customModelConfig map[string]ModelCatalogItem
customModelConfigMu sync.RWMutex
)
// loadCustomModelConfig loads the custom model config from database
func loadCustomModelConfig() {
customModelConfigMu.Lock()
defer customModelConfigMu.Unlock()
customModelConfig = make(map[string]ModelCatalogItem)
rawValue, err := readModelConfigOptionValue()
if err == nil && rawValue != "" && rawValue != "{}" && rawValue != "null" {
items, parseErr := decodeLegacyModelConfig(rawValue)
if parseErr == nil {
for _, item := range items {
normalized, ok := normalizeCustomModelConfigItem(item)
if !ok {
continue
}
customModelConfig[normalized.Id] = normalized
}
} else {
logger.SysError(fmt.Sprintf("failed to parse ModelConfig from options table: %v", parseErr))
}
}
}
func readModelConfigOptionValue() (string, error) {
keys := []string{"ModelConfig", "model_config", "modelconfig"}
for _, key := range keys {
option := model.Option{Key: key}
err := model.DB.First(&option, "key = ?", key).Error
if err == nil {
return strings.TrimSpace(option.Value), nil
}
}
option := model.Option{}
if err := model.DB.
Where("LOWER(`key`) IN ?", []string{"modelconfig", "model_config"}).
Order("id DESC").
First(&option).Error; err == nil {
return strings.TrimSpace(option.Value), nil
}
// Fallback: OptionMap is hydrated from DB at startup. If DB exact-key lookup
// misses due unexpected key shape/collation issues, keep service usable.
config.OptionMapRWMutex.RLock()
defer config.OptionMapRWMutex.RUnlock()
if v, ok := config.OptionMap["ModelConfig"]; ok {
return strings.TrimSpace(v), nil
}
for k, v := range config.OptionMap {
nk := strings.ToLower(strings.TrimSpace(k))
if nk == "modelconfig" || nk == "model_config" {
return strings.TrimSpace(v), nil
}
}
return "", fmt.Errorf("ModelConfig option not found")
}
// GetCustomModelConfig returns all custom model configurations
func GetCustomModelConfig() map[string]ModelCatalogItem {
customModelConfigMu.RLock()
defer customModelConfigMu.RUnlock()
result := make(map[string]ModelCatalogItem)
for k, v := range customModelConfig {
result[k] = v
}
return result
}
// SaveCustomModelConfig saves the custom model config to database and memory
func SaveCustomModelConfig(models []ModelCatalogItem) error {
customModelConfigMu.Lock()
defer customModelConfigMu.Unlock()
if models == nil {
models = []ModelCatalogItem{}
}
configMap := make(map[string]ModelCatalogItem)
normalizedModels := make([]ModelCatalogItem, 0, len(models))
for _, item := range models {
normalized, ok := normalizeCustomModelConfigItem(item)
if !ok {
continue
}
configMap[normalized.Id] = normalized
normalizedModels = append(normalizedModels, normalized)
}
jsonBytes, err := json.Marshal(normalizedModels)
if err != nil {
return fmt.Errorf("marshal model config failed: %w", err)
}
if err := model.UpdateOption("ModelConfig", string(jsonBytes)); err != nil {
return fmt.Errorf("save model config failed: %w", err)
}
customModelConfig = configMap
return nil
}
func normalizeModalities(modalities []string, defaultValue string) []string {
out := make([]string, 0, len(modalities))
for _, modality := range modalities {
m := strings.ToLower(strings.TrimSpace(modality))
if m == "" {
continue
}
out = append(out, m)
}
if len(out) == 0 && defaultValue != "" {
return []string{defaultValue}
}
return out
}
func normalizeCustomModelConfigItem(item ModelCatalogItem) (ModelCatalogItem, bool) {
item.Id = strings.TrimSpace(item.Id)
if item.Id == "" {
return ModelCatalogItem{}, false
}
item.Name = strings.TrimSpace(item.Name)
if item.Name == "" {
item.Name = item.Id
}
item.Provider = strings.TrimSpace(item.Provider)
if item.Provider == "" {
item.Provider = "其他"
}
item.Description = strings.TrimSpace(item.Description)
item.InputModalities = normalizeModalities(item.InputModalities, "text")
item.OutputModalities = normalizeModalities(item.OutputModalities, "text")
item.SuitableApi = strings.TrimSpace(item.SuitableApi)
item.PricingDiscount = strings.TrimSpace(item.PricingDiscount)
if item.PricingDiscount == "" {
item.PricingDiscount = "1"
}
item.CacheType = strings.TrimSpace(item.CacheType)
if item.CacheType == "" {
item.CacheType = "none"
}
item.ModelType = strings.TrimSpace(item.ModelType)
if item.ModelType == "" {
item.ModelType = "chat"
}
item.Icon = strings.TrimSpace(item.Icon)
if item.Icon == "" {
item.Icon = "auto"
}
if item.ContextLength < 0 {
item.ContextLength = 0
}
if item.MaxOutputTokens < 0 {
item.MaxOutputTokens = 0
}
item.IsCustom = true
return item, true
}
func decodeLegacyModelConfig(raw string) ([]ModelCatalogItem, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" || trimmed == "null" || trimmed == "{}" {
return []ModelCatalogItem{}, nil
}
// Legacy format C: JSON string that wraps another JSON payload, e.g. "\"[{...}]\""
var nested string
if err := json.Unmarshal([]byte(trimmed), &nested); err == nil && strings.TrimSpace(nested) != "" {
nestedTrimmed := strings.TrimSpace(nested)
// Avoid infinite recursion on plain non-JSON strings.
if nestedTrimmed != trimmed && (strings.HasPrefix(nestedTrimmed, "[") || strings.HasPrefix(nestedTrimmed, "{")) {
return decodeLegacyModelConfig(nestedTrimmed)
}
}
// Current format: JSON array
var items []ModelCatalogItem
if err := json.Unmarshal([]byte(trimmed), &items); err == nil {
return items, nil
}
// Legacy format A: {"models":[...]}
var wrapped struct {
Models []ModelCatalogItem `json:"models"`
}
if err := json.Unmarshal([]byte(trimmed), &wrapped); err == nil && wrapped.Models != nil {
return wrapped.Models, nil
}
// Legacy format B: {"model-id": {...}}
var keyed map[string]json.RawMessage
if err := json.Unmarshal([]byte(trimmed), &keyed); err == nil {
out := make([]ModelCatalogItem, 0, len(keyed))
for key, val := range keyed {
var item ModelCatalogItem
if err := json.Unmarshal(val, &item); err != nil {
continue
}
if strings.TrimSpace(item.Id) == "" {
item.Id = key
}
out = append(out, item)
}
if len(out) > 0 {
return out, nil
}
}
return nil, fmt.Errorf("unsupported ModelConfig JSON format")
}
func previewText(s string, limit int) string {
if limit <= 0 {
return ""
}
if len(s) <= limit {
return s
}
return s[:limit]
}
// GetModelConfigDebug returns raw option value and parsed result summary.
// GET /api/model/config/debug - admin only
func GetModelConfigDebug(c *gin.Context) {
rawValue, err := readModelConfigOptionValue()
optionFound := err == nil
config.OptionMapRWMutex.RLock()
_, optionMapHasModelConfig := config.OptionMap["ModelConfig"]
config.OptionMapRWMutex.RUnlock()
decodedCount := 0
parseError := ""
sampleIds := make([]string, 0, 5)
if optionFound {
if items, parseErr := decodeLegacyModelConfig(rawValue); parseErr == nil {
decodedCount = len(items)
for i, item := range items {
if i >= 5 {
break
}
sampleIds = append(sampleIds, item.Id)
}
} else {
parseError = parseErr.Error()
}
}
loadCustomModelConfig()
custom := GetCustomModelConfig()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"option_found": optionFound,
"option_error": func() string {
if err != nil {
return err.Error()
}
return ""
}(),
"option_map_has_model_config": optionMapHasModelConfig,
"raw_length": len(rawValue),
"raw_preview": previewText(rawValue, 400),
"decoded_count": decodedCount,
"parse_error": parseError,
"sample_ids": sampleIds,
"loaded_count": len(custom),
},
})
}
func splitCSV(s string) []string {
if s == "" {
return nil
@ -342,6 +631,7 @@ func zenmuxToItem(z zenmuxModel) ModelCatalogItem {
Tags: tags,
SupportsReasoning: z.SupportsReasoning,
SuitableApi: z.SuitableApi,
IsCustom: false,
}
}
@ -433,8 +723,27 @@ func GetModelCatalog(c *gin.Context) {
available[m] = true
}
// Load custom config and merge with zenmux catalog
loadCustomModelConfig()
customConfig := GetCustomModelConfig()
catalog, err := fetchZenmuxCatalog()
if err != nil {
// If zenmux fails, try to use only custom config
if len(customConfig) > 0 {
result := make([]ModelCatalogItem, 0)
for _, item := range customConfig {
if available[item.Id] {
result = append(result, item)
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "using custom config only: " + err.Error(),
"data": result,
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "model catalog unavailable: " + err.Error(),
@ -443,8 +752,31 @@ func GetModelCatalog(c *gin.Context) {
return
}
result := make([]ModelCatalogItem, 0, len(catalog))
// Merge custom config with zenmux catalog
mergedCatalog := make([]ModelCatalogItem, 0, len(catalog))
zenmuxMap := make(map[string]ModelCatalogItem)
for _, item := range catalog {
zenmuxMap[item.Id] = item
}
// Create merged list with custom overrides
for _, item := range catalog {
if custom, hasCustom := customConfig[item.Id]; hasCustom {
mergedCatalog = append(mergedCatalog, custom)
} else {
mergedCatalog = append(mergedCatalog, item)
}
}
// Add custom-only models (not in zenmux)
for _, item := range customConfig {
if _, exists := zenmuxMap[item.Id]; !exists {
mergedCatalog = append(mergedCatalog, item)
}
}
result := make([]ModelCatalogItem, 0, len(mergedCatalog))
for _, item := range mergedCatalog {
if available[item.Id] {
result = append(result, item)
}
@ -565,13 +897,13 @@ func SetupZenmuxChannels(c *gin.Context) {
priority := int64(0)
weight := uint(1)
ch := model.Channel{
Type: def.Type,
Name: def.Name,
Key: req.Key,
BaseURL: &baseURL,
Models: modelList,
Status: 1,
Group: "default",
Type: def.Type,
Name: def.Name,
Key: req.Key,
BaseURL: &baseURL,
Models: modelList,
Status: 1,
Group: "default",
Priority: &priority,
Weight: &weight,
}
@ -592,8 +924,9 @@ func SetupZenmuxChannels(c *gin.Context) {
// SyncZenmuxModels fetches all models from Zenmux and updates the channel's model list.
// POST /api/channel/:id/sync-zenmux
// Optional query param: ?protocol=chat.completions
// Accepts comma-separated values to match multiple protocols, e.g.
// ?protocol=google.gemini,google.imagen,google.video
//
// Accepts comma-separated values to match multiple protocols, e.g.
// ?protocol=google.gemini,google.imagen,google.video
func SyncZenmuxModels(c *gin.Context) {
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
@ -732,3 +1065,149 @@ func SyncZenmuxRatios(c *gin.Context) {
})
}
// --- Model Config Management (Custom Manual Configuration) ---
// GetModelConfigManagement returns all model configs (both custom and zenmux)
// GET /api/model/config - admin only
func GetModelConfigManagement(c *gin.Context) {
// Load custom config
loadCustomModelConfig()
customConfig := GetCustomModelConfig()
// Get zenmux catalog as base
catalog, err := fetchZenmuxCatalog()
if err != nil {
// If zenmux fails, just return custom config
result := make([]ModelCatalogItem, 0, len(customConfig))
for _, item := range customConfig {
result = append(result, item)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "zenmux unavailable: " + err.Error(),
"data": result,
})
return
}
// Merge: custom config overrides zenmux config
result := make([]ModelCatalogItem, 0, len(catalog))
zenmuxMap := make(map[string]ModelCatalogItem)
for _, item := range catalog {
zenmuxMap[item.Id] = item
}
// Add all zenmux models first, with custom overrides
for _, item := range catalog {
if custom, hasCustom := customConfig[item.Id]; hasCustom {
result = append(result, custom)
} else {
result = append(result, item)
}
}
// Add custom-only models (not in zenmux)
for _, item := range customConfig {
if _, exists := zenmuxMap[item.Id]; !exists {
result = append(result, item)
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": result,
})
}
// SaveModelConfig saves custom model configurations
// POST /api/model/config - admin only
func SaveModelConfig(c *gin.Context) {
var req ModelConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
if req.Models == nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "models is required and must be an array"})
return
}
if err := SaveCustomModelConfig(req.Models); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("saved %d model configs", len(req.Models)),
})
}
// GetModelConfigCustom returns only custom model configs
// GET /api/model/config/custom - admin only
func GetModelConfigCustom(c *gin.Context) {
loadCustomModelConfig()
customConfig := GetCustomModelConfig()
result := make([]ModelCatalogItem, 0, len(customConfig))
for _, item := range customConfig {
result = append(result, item)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": result,
})
}
// ImportModelConfig imports model configs from JSON
// POST /api/model/config/import - admin only
func ImportModelConfig(c *gin.Context) {
var req ModelConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid JSON format"})
return
}
loadCustomModelConfig()
existingConfig := GetCustomModelConfig()
// Merge with existing config
for _, model := range req.Models {
model.IsCustom = true
existingConfig[model.Id] = model
}
// Convert back to slice
result := make([]ModelCatalogItem, 0, len(existingConfig))
for _, item := range existingConfig {
result = append(result, item)
}
if err := SaveCustomModelConfig(result); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("imported %d model configs", len(req.Models)),
})
}
// ExportModelConfig exports model configs as JSON
// GET /api/model/config/export - admin only
func ExportModelConfig(c *gin.Context) {
loadCustomModelConfig()
customConfig := GetCustomModelConfig()
result := make([]ModelCatalogItem, 0, len(customConfig))
for _, item := range customConfig {
result = append(result, item)
}
c.Header("Content-Disposition", "attachment; filename=model-config.json")
c.JSON(http.StatusOK, result)
}

View File

@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common"
@ -49,6 +50,17 @@ func Relay(c *gin.Context) {
requestBody, _ := common.GetRequestBody(c)
logger.Debugf(ctx, "request body: %s", string(requestBody))
}
// Debug logging for multimodal requests - always log to help diagnose image issues
requestBody, _ := common.GetRequestBody(c)
logger.Infof(ctx, "=== Request received: Path=%s, BodyLength=%d ===", c.Request.URL.Path, len(requestBody))
if len(requestBody) > 0 && len(requestBody) < 5000 {
bodyStr := string(requestBody)
logger.Debugf(ctx, "Request body: %s", bodyStr)
// Detect multimodal content
if strings.Contains(bodyStr, "image_url") {
logger.Infof(ctx, "=== MULTIMODAL REQUEST DETECTED: Request contains image_url ===")
}
}
channelId := c.GetInt(ctxkey.ChannelId)
userId := c.GetInt(ctxkey.Id)
bizErr := relayHelper(c, relayMode)

View File

@ -1,6 +1,7 @@
package model
import (
"fmt"
"github.com/songquanpeng/one-api/common/config"
"github.com/songquanpeng/one-api/common/logger"
billingratio "github.com/songquanpeng/one-api/relay/billing/ratio"
@ -106,12 +107,16 @@ func UpdateOption(key string, value string) error {
Key: key,
}
// https://gorm.io/docs/update.html#Save-All-Fields
DB.FirstOrCreate(&option, Option{Key: key})
if err := DB.FirstOrCreate(&option, Option{Key: key}).Error; err != nil {
return fmt.Errorf("create option %s failed: %w", key, err)
}
option.Value = value
// Save is a combination function.
// If save value does not contain primary key, it will execute Create,
// otherwise it will execute Update (with all fields).
DB.Save(&option)
if err := DB.Save(&option).Error; err != nil {
return fmt.Errorf("save option %s failed: %w", key, err)
}
// Update OptionMap
return updateOptionMap(key, value)
}

View File

@ -1,5 +1,9 @@
package model
import (
"github.com/songquanpeng/one-api/common/logger"
)
type Message struct {
Role string `json:"role,omitempty"`
Content any `json:"content,omitempty"`
@ -51,11 +55,14 @@ func (m Message) ParseContent() []MessageContent {
}
anyList, ok := m.Content.([]any)
if ok {
for _, contentItem := range anyList {
logger.Debugf(nil, "=== ParseContent: Found array content with %d items ===", len(anyList))
for i, contentItem := range anyList {
contentMap, ok := contentItem.(map[string]any)
if !ok {
logger.Debugf(nil, "=== ParseContent: Item %d is not a map, type=%T ===", i, contentItem)
continue
}
logger.Debugf(nil, "=== ParseContent: Item %d has type=%v ===", i, contentMap["type"])
switch contentMap["type"] {
case ContentTypeText:
if subStr, ok := contentMap["text"].(string); ok {
@ -65,13 +72,21 @@ func (m Message) ParseContent() []MessageContent {
})
}
case ContentTypeImageURL:
logger.Debugf(nil, "=== ParseContent: Processing image_url item ===")
if subObj, ok := contentMap["image_url"].(map[string]any); ok {
contentList = append(contentList, MessageContent{
Type: ContentTypeImageURL,
ImageURL: &ImageURL{
Url: subObj["url"].(string),
},
})
if url, ok := subObj["url"].(string); ok {
logger.Debugf(nil, "=== ParseContent: Found image URL: %s ===", truncateURL(url))
contentList = append(contentList, MessageContent{
Type: ContentTypeImageURL,
ImageURL: &ImageURL{
Url: url,
},
})
} else {
logger.Errorf(nil, "=== ParseContent: image_url.url is not a string, type=%T ===", subObj["url"])
}
} else {
logger.Errorf(nil, "=== ParseContent: image_url is not a map, type=%T ===", contentMap["image_url"])
}
case ContentTypeVideoURL:
if subObj, ok := contentMap["video_url"].(map[string]any); ok {
@ -95,11 +110,26 @@ func (m Message) ParseContent() []MessageContent {
}
}
}
imageCount := 0
for _, c := range contentList {
if c.Type == ContentTypeImageURL {
imageCount++
}
}
logger.Debugf(nil, "=== ParseContent: Parsed %d content items, image_count=%d ===", len(contentList), imageCount)
return contentList
}
logger.Debugf(nil, "=== ParseContent: Content is neither string nor array, type=%T ===", m.Content)
return nil
}
func truncateURL(url string) string {
if len(url) > 100 {
return url[:100] + "..."
}
return url
}
type ImageURL struct {
Url string `json:"url,omitempty"`
Detail string `json:"detail,omitempty"`

View File

@ -18,6 +18,15 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
apiRouter.GET("/model-catalog", middleware.TokenAuth(), controller.GetModelCatalog)
apiRouter.GET("/token-quota", middleware.TokenAuth(), controller.GetTokenQuota)
// Model Config Management (admin only)
apiRouter.GET("/model/config", middleware.AdminAuth(), controller.GetModelConfigManagement)
apiRouter.POST("/model/config", middleware.AdminAuth(), controller.SaveModelConfig)
apiRouter.GET("/model/config/custom", middleware.AdminAuth(), controller.GetModelConfigCustom)
apiRouter.GET("/model/config/debug", middleware.AdminAuth(), controller.GetModelConfigDebug)
apiRouter.POST("/model/config/import", middleware.AdminAuth(), controller.ImportModelConfig)
apiRouter.GET("/model/config/export", middleware.AdminAuth(), controller.ExportModelConfig)
apiRouter.POST("/channel/:id/sync-zenmux", middleware.AdminAuth(), controller.SyncZenmuxModels)
apiRouter.POST("/zenmux/setup", middleware.AdminAuth(), controller.SetupZenmuxChannels)
apiRouter.GET("/zenmux/protocols", middleware.AdminAuth(), controller.GetZenmuxProtocols)

View File

@ -0,0 +1,955 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
IconButton,
Chip,
Box,
Typography,
InputAdornment,
Select,
MenuItem,
FormControl,
InputLabel,
FormHelperText,
Grid,
LinearProgress,
Autocomplete,
Tab,
Tabs,
Tooltip,
Stack,
Switch,
Accordion,
AccordionSummary,
AccordionDetails
} from '@mui/material';
import {
Search as SearchIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Save as SaveIcon,
Add as AddIcon,
Close as CloseIcon,
Settings as SettingsIcon,
Info as InfoIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon
} from '@mui/icons-material';
import { API } from 'utils/api';
import { showError, showSuccess, showInfo } from 'utils/common';
// 提供商列表
const PROVIDERS = [
'xiaomi', 'bailian', 'aiscri-xiong', 'OpenAI', 'Anthropic', 'Google', 'DeepSeek',
'Moonshot', 'MiniMax', '智谱 AI', '阿里云', '腾讯', '百度', '字节', '其他'
];
// API类型对应 suitable_api 字段)
const API_OPTIONS = [
{ value: 'openai-chat', label: 'OpenAI Chat Completions' },
{ value: 'openai-completions', label: 'OpenAI Completions' },
{ value: 'anthropic-messages', label: 'Anthropic Messages' },
{ value: 'anthropic-responses', label: 'Anthropic Responses' },
{ value: 'google-generate', label: 'Google Generate' },
{ value: 'embeddings', label: 'Embeddings' },
{ value: 'auto', label: '自动检测 (Auto)' }
];
// 标签选项
const TAG_OPTIONS = [
'vision', 'function-call', 'reasoning', 'code', 'agent', 'multimodal',
'fast', 'cheap', 'context-long', 'audio', 'video', 'file', 'tool-use',
'computer-use', 'streaming', 'json-mode'
];
// 模态选项
const MODALITY_OPTIONS = ['text', 'image', 'audio', 'video', 'file'];
// 推理能力(对应 supports_reasoning 字段)
const REASONING_OPTIONS = [
{ value: 0, label: '不支持推理' },
{ value: 1, label: '始终开启 (Always-on)' },
{ value: 2, label: '可切换 (Toggleable)' }
];
// 缓存类型选项
const CACHE_TYPE_OPTIONS = [
{ value: 'none', label: '不启用' },
{ value: 'responses', label: 'Prompt 缓存Responses' },
{ value: 'anthropic', label: 'System 缓存Anthropic' }
];
// 模型类型选项(用于区分不同用途的模型)
const MODEL_TYPE_OPTIONS = [
{ value: 'chat', label: '对话 (Chat)' },
{ value: 'completion', label: '文本补全 (Completion)' },
{ value: 'embeddings', label: '向量嵌入 (Embeddings)' },
{ value: 'image', label: '图像生成 (Image)' },
{ value: 'audio', label: '语音 (Audio)' },
{ value: 'multimodal', label: '多模态 (Multimodal)' }
];
const ModelConfigEditor = ({ open, onClose }) => {
const [tabValue, setTabValue] = useState(0);
const [models, setModels] = useState([]);
const [customModels, setCustomModels] = useState([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [searchKeyword, setSearchKeyword] = useState('');
const [editingModel, setEditingModel] = useState(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [formValues, setFormValues] = useState({});
const [expandedSection, setExpandedSection] = useState('basic');
useEffect(() => {
if (open) {
setSearchKeyword('');
fetchData();
}
}, [open]);
const fetchData = async () => {
setLoading(true);
try {
const allRes = await API.get('/api/model/config');
const customRes = await API.get('/api/model/config/custom');
if (allRes.data.success) {
setModels(allRes.data.data || []);
} else {
setModels([]);
showError(allRes.data.message || '加载模型配置失败');
}
if (customRes.data.success) {
setCustomModels(customRes.data.data || []);
} else {
setCustomModels([]);
showError(customRes.data.message || '加载自定义模型失败');
}
} catch (error) {
showError('加载模型配置失败: ' + error.message);
} finally {
setLoading(false);
}
};
const saveModelConfigs = async () => {
const payload = customModels
.map((m) => ({
...m,
id: (m.id || '').trim(),
name: (m.name || '').trim() || (m.id || '').trim(),
provider: (m.provider || '其他').trim(),
input_modalities: Array.isArray(m.input_modalities) && m.input_modalities.length > 0
? m.input_modalities
: ['text'],
output_modalities: Array.isArray(m.output_modalities) && m.output_modalities.length > 0
? m.output_modalities
: ['text'],
pricing_discount: String(m.pricing_discount || '1'),
suitable_api: (m.suitable_api || 'openai-chat').trim(),
is_custom: true,
}))
.filter((m) => m.id);
setSaving(true);
try {
const res = await API.post('/api/model/config', { models: payload });
if (res.data.success) {
showSuccess('保存成功');
await fetchData();
} else {
showError(res.data.message || '保存失败');
}
} catch (error) {
showError('保存失败: ' + error.message);
} finally {
setSaving(false);
}
};
const openEditModal = (model = null) => {
setEditingModel(model);
if (model) {
setFormValues({
id: model.id || '',
name: model.name || '',
provider: model.provider || '',
description: model.description || '',
input_price: model.input_price || 0,
output_price: model.output_price || 0,
cache_write_price: model.cache_write_price || 0,
cache_read_price: model.cache_read_price || 0,
context_length: model.context_length || 4096,
max_output_tokens: model.max_output_tokens || 4096,
suitable_api: model.suitable_api || 'openai-chat',
input_modalities: model.input_modalities || ['text'],
output_modalities: model.output_modalities || ['text'],
pricing_discount: model.pricing_discount || '1',
tags: model.tags || [],
supports_reasoning: model.supports_reasoning ?? 0,
reasoning_config: model.reasoning_config || {},
cache_type: model.cache_type || 'none',
model_type: model.model_type || 'chat',
supports_function_calling: model.supports_function_calling || false,
supports_computer_use: model.supports_computer_use || false,
icon: model.icon || 'auto',
});
} else {
setFormValues({
id: '', name: '', provider: '', description: '',
input_price: 0, output_price: 0, cache_write_price: 0, cache_read_price: 0,
context_length: 4096, max_output_tokens: 4096,
suitable_api: 'openai-chat',
input_modalities: ['text'],
output_modalities: ['text'],
pricing_discount: '1',
tags: [],
supports_reasoning: 0,
reasoning_config: {},
cache_type: 'none',
model_type: 'chat',
supports_function_calling: false,
supports_computer_use: false,
icon: 'auto',
});
}
setIsEditModalOpen(true);
setExpandedSection('basic');
};
const closeEditModal = () => {
setEditingModel(null);
setIsEditModalOpen(false);
setFormValues({});
};
const saveEdit = () => {
if (!formValues.id || !formValues.name) {
showError('请填写模型ID和名称');
return;
}
const newModel = {
id: formValues.id.trim(),
name: formValues.name.trim(),
provider: formValues.provider || '其他',
description: formValues.description || '',
input_price: parseFloat(formValues.input_price) || 0,
output_price: parseFloat(formValues.output_price) || 0,
cache_write_price: parseFloat(formValues.cache_write_price) || 0,
cache_read_price: parseFloat(formValues.cache_read_price) || 0,
context_length: parseInt(formValues.context_length) || 4096,
max_output_tokens: parseInt(formValues.max_output_tokens) || 4096,
suitable_api: formValues.suitable_api || 'openai-chat',
input_modalities: Array.isArray(formValues.input_modalities) ? formValues.input_modalities : ['text'],
output_modalities: Array.isArray(formValues.output_modalities) ? formValues.output_modalities : ['text'],
pricing_discount: String(formValues.pricing_discount || '1'),
tags: Array.isArray(formValues.tags) ? formValues.tags : [],
supports_reasoning: parseInt(formValues.supports_reasoning) || 0,
reasoning_config: formValues.reasoning_config || {},
cache_type: formValues.cache_type || 'none',
model_type: formValues.model_type || 'chat',
supports_function_calling: formValues.supports_function_calling || false,
supports_computer_use: formValues.supports_computer_use || false,
icon: formValues.icon || 'auto',
is_custom: true,
};
let updated;
if (editingModel) {
const nextId = (newModel.id || '').trim();
const oldId = (editingModel.id || '').trim();
if (nextId !== oldId && customModels.some(m => m.id === nextId)) {
showError('模型ID已存在');
return;
}
updated = customModels.map(m => m.id === editingModel.id ? newModel : m);
} else {
if (customModels.some(m => m.id === newModel.id)) {
showError('模型ID已存在');
return;
}
updated = [...customModels, newModel];
}
setCustomModels(updated);
closeEditModal();
showInfo('配置已更新,请点击"保存配置"按钮保存到服务器');
};
const deleteModel = (modelId) => {
const updated = customModels.filter(m => m.id !== modelId);
setCustomModels(updated);
showInfo('已删除,请点击"保存配置"按钮');
};
const filteredModels = models.filter(m => {
const k = searchKeyword.toLowerCase();
return !searchKeyword ||
m.id?.toLowerCase().includes(k) ||
m.name?.toLowerCase().includes(k) ||
m.provider?.toLowerCase().includes(k);
});
const filteredCustom = customModels.filter(m => {
const k = searchKeyword.toLowerCase();
return !searchKeyword ||
m.id?.toLowerCase().includes(k) ||
m.name?.toLowerCase().includes(k);
});
const handleExpandSection = (section) => (event, isExpanded) => {
setExpandedSection(isExpanded ? section : false);
};
return (
<>
<Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth PaperProps={{ sx: { height: '85vh' } }}>
<DialogTitle>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Stack direction="row" alignItems="center" spacing={1}>
<SettingsIcon />
<Typography variant="h6">模型配置管理</Typography>
</Stack>
<Stack direction="row" alignItems="center" spacing={1}>
<Tooltip title="修改后需要点击保存按钮才能持久化到数据库">
<InfoIcon color="info" fontSize="small" />
</Tooltip>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Stack>
</Stack>
</DialogTitle>
<DialogContent sx={{ pb: 2 }}>
<Tabs value={tabValue} onChange={(e, v) => setTabValue(v)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tab label={`所有模型 (${models.length})`} />
<Tab label={`自定义配置 (${customModels.length})`} />
</Tabs>
<Stack direction="row" spacing={1} alignItems="center" py={2}>
<TextField
placeholder="搜索模型ID、名称、提供商..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
InputProps={{
startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment>,
}}
size="small"
sx={{ flexGrow: 1, maxWidth: 300 }}
/>
{tabValue === 1 && (
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => openEditModal()}
size="small"
>
添加模型
</Button>
)}
</Stack>
{/* Tab 1: 所有模型 */}
<Box sx={{ display: tabValue === 0 ? 'block' : 'none' }}>
{loading ? (
<LinearProgress />
) : (
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 450 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>模型ID</TableCell>
<TableCell>名称</TableCell>
<TableCell>提供商</TableCell>
<TableCell align="right">输入价</TableCell>
<TableCell align="right">输出价</TableCell>
<TableCell align="right">上下文</TableCell>
<TableCell>模态</TableCell>
<TableCell>标签</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredModels.map((model) => (
<TableRow key={model.id} hover>
<TableCell>
<Typography variant="caption" fontFamily="monospace">
{model.id}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">{model.name || '-'}</Typography>
</TableCell>
<TableCell>
<Chip label={model.provider || '-'} size="small" variant="outlined" />
</TableCell>
<TableCell align="right">{model.input_price || '-'}</TableCell>
<TableCell align="right">{model.output_price || '-'}</TableCell>
<TableCell align="right">
{model.context_length ? (
<Typography variant="caption">
{model.context_length >= 1000
? `${(model.context_length / 1000).toFixed(0)}k`
: model.context_length}
</Typography>
) : '-'}
</TableCell>
<TableCell>
<Box display="flex" gap={0.5} flexWrap="wrap">
{(model.input_modalities || []).map(m => (
<Chip key={m} label={m} size="small" sx={{ height: 18, fontSize: '0.65rem' }} />
))}
</Box>
</TableCell>
<TableCell>
<Box display="flex" gap={0.5} flexWrap="wrap">
{(model.tags || []).map(tag => (
<Chip key={tag} label={tag} size="small" variant="outlined" sx={{ height: 18, fontSize: '0.65rem' }} />
))}
{model.supports_reasoning > 0 && (
<Chip label="推理" size="small" color="secondary" sx={{ height: 18, fontSize: '0.65rem' }} />
)}
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
{/* Tab 2: 自定义配置 */}
<Box sx={{ display: tabValue === 1 ? 'block' : 'none' }}>
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 450 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>模型ID</TableCell>
<TableCell>名称</TableCell>
<TableCell>提供商</TableCell>
<TableCell align="right">价格(/)</TableCell>
<TableCell align="right">上下文</TableCell>
<TableCell>模态</TableCell>
<TableCell>标签</TableCell>
<TableCell align="center">操作</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredCustom.map((model) => (
<TableRow key={model.id} hover sx={{ backgroundColor: 'rgba(25, 118, 210, 0.04)' }}>
<TableCell>
<Typography variant="caption" fontFamily="monospace">
{model.id}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">{model.name}</Typography>
</TableCell>
<TableCell>
<Chip label={model.provider || '其他'} size="small" />
</TableCell>
<TableCell align="right">
<Typography variant="caption">
{model.input_price}/{model.output_price}
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="caption">
{model.context_length
? (model.context_length >= 1000
? `${(model.context_length / 1000).toFixed(0)}k`
: model.context_length)
: '-'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="caption" noWrap sx={{ maxWidth: 100 }}>
{(model.input_modalities || []).join(', ')}
</Typography>
</TableCell>
<TableCell>
<Box display="flex" gap={0.5} flexWrap="wrap">
{(model.tags || []).slice(0, 3).map(tag => (
<Chip key={tag} label={tag} size="small" variant="filled" color="primary" sx={{ height: 18, fontSize: '0.65rem' }} />
))}
{(model.tags || []).length > 3 && (
<Chip label={`+${model.tags.length - 3}`} size="small" sx={{ height: 18, fontSize: '0.65rem' }} />
)}
</Box>
</TableCell>
<TableCell align="center">
<IconButton size="small" onClick={() => openEditModal(model)}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={() => deleteModel(model.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
{filteredCustom.length === 0 && (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary">
暂无自定义模型配置点击"添加模型"创建新配置
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, justifyContent: 'space-between' }}>
<Box>
{tabValue === 1 && customModels.length > 0 && (
<Typography variant="body2" color="text.secondary">
已配置 {customModels.length} 个自定义模型修改后点击"保存配置"持久化到数据库
</Typography>
)}
</Box>
<Stack direction="row" spacing={1}>
<Button onClick={onClose}>关闭</Button>
<Button
variant="contained"
startIcon={<SaveIcon />}
disabled={saving || customModels.length === 0}
onClick={saveModelConfigs}
>
{saving ? '保存中...' : `保存配置 (${customModels.length})`}
</Button>
</Stack>
</DialogActions>
</Dialog>
{/* 编辑/添加模型对话框 */}
<Dialog open={isEditModalOpen} onClose={closeEditModal} maxWidth="md" fullWidth scroll="paper">
<DialogTitle>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Typography variant="h6">
{editingModel ? '编辑模型配置' : '添加模型配置'}
</Typography>
<IconButton onClick={closeEditModal} size="small"><CloseIcon /></IconButton>
</Stack>
</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
{/* 基本信息 */}
<Accordion expanded={expandedSection === 'basic'} onChange={handleExpandSection('basic')}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight="bold">基本信息</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
fullWidth label="模型 ID *"
value={formValues.id || ''}
onChange={(e) => setFormValues({ ...formValues, id: e.target.value })}
size="small"
placeholder="例如: MiniMax/MiniMax-M2.7"
helperText={editingModel ? "可修改修改后会按新ID保存并覆盖当前条目" : "唯一标识符,格式: provider/model-name"}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth label="显示名称 *"
value={formValues.name || ''}
onChange={(e) => setFormValues({ ...formValues, name: e.target.value })}
size="small"
placeholder="例如: MiniMax-M2.7"
/>
</Grid>
<Grid item xs={12} sm={6}>
<Autocomplete
freeSolo options={PROVIDERS}
value={formValues.provider || ''}
onChange={(e, v) => setFormValues({ ...formValues, provider: v })}
renderInput={(params) => (
<TextField {...params} fullWidth label="提供商" size="small" />
)}
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth size="small">
<InputLabel>模型类型</InputLabel>
<Select
value={formValues.model_type || 'chat'}
label="模型类型"
onChange={(e) => setFormValues({ ...formValues, model_type: e.target.value })}
>
{MODEL_TYPE_OPTIONS.map(t => (
<MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>
))}
</Select>
<FormHelperText>用于区分对话/语音/嵌入/图像模型</FormHelperText>
</FormControl>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth multiline rows={2}
label="模型描述"
value={formValues.description || ''}
onChange={(e) => setFormValues({ ...formValues, description: e.target.value })}
size="small"
placeholder="模型功能和特点描述"
/>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* 请求协议 */}
<Accordion expanded={expandedSection === 'protocol'} onChange={handleExpandSection('protocol')}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight="bold">请求协议</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12}>
<FormControl fullWidth size="small">
<InputLabel>协议覆盖</InputLabel>
<Select
value={formValues.suitable_api || 'openai-chat'}
label="协议覆盖"
onChange={(e) => setFormValues({ ...formValues, suitable_api: e.target.value })}
>
<MenuItem value="">不选则使用提供商默认协议</MenuItem>
{API_OPTIONS.map(t => (
<MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>
))}
</Select>
<FormHelperText>
当前: {formValues.suitable_api || 'openai-chat'}
</FormHelperText>
</FormControl>
</Grid>
<Grid item xs={12}>
<Stack direction="row" alignItems="center" spacing={1} justifyContent="space-between">
<Stack direction="row" alignItems="center" spacing={1}>
<Switch
checked={formValues.use_default_model_type !== false}
onChange={(e) => setFormValues({ ...formValues, use_default_model_type: e.target.checked })}
size="small"
/>
<Typography variant="body2">使用提供商默认模型类型</Typography>
</Stack>
<Typography variant="caption" color="text.secondary">
用于区分对话/语音/嵌入/图像模型
</Typography>
</Stack>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* 上下文与输出 */}
<Accordion expanded={expandedSection === 'context'} onChange={handleExpandSection('context')}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight="bold">上下文与输出</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField
fullWidth type="number"
label="对话上下文长度"
value={formValues.context_length || 4096}
onChange={(e) => setFormValues({ ...formValues, context_length: e.target.value })}
size="small"
helperText="tokens如: 200000"
inputProps={{ step: 1, min: 0 }}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth type="number"
label="最大输出 Token"
value={formValues.max_output_tokens || 4096}
onChange={(e) => setFormValues({ ...formValues, max_output_tokens: e.target.value })}
size="small"
helperText="tokens如: 128000"
inputProps={{ step: 1, min: 0 }}
/>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* 定价配置 */}
<Accordion expanded={expandedSection === 'pricing'} onChange={handleExpandSection('pricing')}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight="bold">定价 (USD / 百万 Token)</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={6} sm={3}>
<TextField
fullWidth type="number"
label="输入价格"
value={formValues.input_price || 0}
onChange={(e) => setFormValues({ ...formValues, input_price: e.target.value })}
size="small"
helperText="$/1M tokens"
inputProps={{ step: 0.01, min: 0 }}
/>
</Grid>
<Grid item xs={6} sm={3}>
<TextField
fullWidth type="number"
label="输出价格"
value={formValues.output_price || 0}
onChange={(e) => setFormValues({ ...formValues, output_price: e.target.value })}
size="small"
helperText="$/1M tokens"
inputProps={{ step: 0.01, min: 0 }}
/>
</Grid>
<Grid item xs={6} sm={3}>
<TextField
fullWidth type="number"
label="缓存写入价格"
value={formValues.cache_write_price || 0}
onChange={(e) => setFormValues({ ...formValues, cache_write_price: e.target.value })}
size="small"
helperText="$/1M tokens"
inputProps={{ step: 0.01, min: 0 }}
/>
</Grid>
<Grid item xs={6} sm={3}>
<TextField
fullWidth type="number"
label="缓存命中价格"
value={formValues.cache_read_price || 0}
onChange={(e) => setFormValues({ ...formValues, cache_read_price: e.target.value })}
size="small"
helperText="$/1M tokens"
inputProps={{ step: 0.01, min: 0 }}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth type="number"
label="折扣"
value={formValues.pricing_discount || '1'}
onChange={(e) => setFormValues({ ...formValues, pricing_discount: e.target.value })}
size="small"
helperText="如: 0.8 表示8折"
inputProps={{ step: 0.01, min: 0, max: 1 }}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="图标"
value={formValues.icon || 'auto'}
onChange={(e) => setFormValues({ ...formValues, icon: e.target.value })}
size="small"
helperText="auto 或图标 URL"
/>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* Responses 配置 */}
<Accordion expanded={expandedSection === 'responses'} onChange={handleExpandSection('responses')}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight="bold">Responses 配置</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12}>
<FormControl fullWidth size="small">
<InputLabel>推理摘要</InputLabel>
<Select
value={formValues.reasoning_mode || 'disabled'}
label="推理摘要"
onChange={(e) => setFormValues({ ...formValues, reasoning_mode: e.target.value })}
>
<MenuItem value="disabled">不启用</MenuItem>
<MenuItem value="auto">自动</MenuItem>
<MenuItem value="enabled">始终启用</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth size="small">
<InputLabel>Prompt 缓存</InputLabel>
<Select
value={formValues.cache_type || 'none'}
label="Prompt 缓存"
onChange={(e) => setFormValues({ ...formValues, cache_type: e.target.value })}
>
{CACHE_TYPE_OPTIONS.map(t => (
<MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* 能力配置 */}
<Accordion expanded={expandedSection === 'capabilities'} onChange={handleExpandSection('capabilities')}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight="bold">能力</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom>Input modalities</Typography>
<Autocomplete
multiple
options={MODALITY_OPTIONS}
value={formValues.input_modalities || ['text']}
onChange={(e, v) => setFormValues({ ...formValues, input_modalities: v })}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip variant="outlined" label={option} size="small" {...getTagProps({ index })} />
))
}
renderInput={(params) => (
<TextField
{...params}
fullWidth
placeholder="选择支持的输入类型"
size="small"
/>
)}
/>
</Grid>
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom>Output modalities</Typography>
<Autocomplete
multiple
options={MODALITY_OPTIONS}
value={formValues.output_modalities || ['text']}
onChange={(e, v) => setFormValues({ ...formValues, output_modalities: v })}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip variant="outlined" label={option} size="small" {...getTagProps({ index })} />
))
}
renderInput={(params) => (
<TextField
{...params}
fullWidth
placeholder="选择支持的输出类型"
size="small"
/>
)}
/>
</Grid>
<Grid item xs={12}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Typography variant="body2">支持函数调用</Typography>
<Switch
checked={formValues.supports_function_calling || false}
onChange={(e) => setFormValues({ ...formValues, supports_function_calling: e.target.checked })}
/>
</Stack>
</Grid>
<Grid item xs={12}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Stack direction="row" alignItems="center" spacing={1}>
<Typography variant="body2">支持 Computer Use</Typography>
<Tooltip title="启用后模型可以执行计算机操作">
<InfoIcon fontSize="small" color="info" />
</Tooltip>
</Stack>
<Switch
checked={formValues.supports_computer_use || false}
onChange={(e) => setFormValues({ ...formValues, supports_computer_use: e.target.checked })}
/>
</Stack>
</Grid>
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom>推理能力 (supports_reasoning)</Typography>
<Select
fullWidth
value={formValues.supports_reasoning ?? 0}
onChange={(e) => setFormValues({ ...formValues, supports_reasoning: parseInt(e.target.value) })}
size="small"
>
{REASONING_OPTIONS.map(r => (
<MenuItem key={r.value} value={r.value}>{r.label}</MenuItem>
))}
</Select>
</Grid>
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom>功能标签</Typography>
<Autocomplete
multiple
freeSolo
options={TAG_OPTIONS}
value={formValues.tags || []}
onChange={(e, v) => setFormValues({ ...formValues, tags: v })}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip variant="outlined" label={option} size="small" {...getTagProps({ index })} />
))
}
renderInput={(params) => (
<TextField
{...params}
fullWidth
placeholder="选择或输入标签"
size="small"
helperText="如: vision, function-call, reasoning, agent"
/>
)}
/>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={closeEditModal}>取消</Button>
<Button variant="contained" onClick={saveEdit}>保存</Button>
</DialogActions>
</Dialog>
</>
);
};
export default ModelConfigEditor;

View File

@ -18,8 +18,9 @@ import ChannelTableHead from './component/TableHead';
import TableToolBar from 'ui-component/TableToolBar';
import { API } from 'utils/api';
import { ITEMS_PER_PAGE } from 'constants';
import { IconRefresh, IconHttpDelete, IconPlus, IconBrandSpeedtest, IconCoinYuan } from '@tabler/icons-react';
import { IconRefresh, IconHttpDelete, IconPlus, IconBrandSpeedtest, IconCoinYuan, IconSettings } from '@tabler/icons-react';
import EditeModal from './component/EditModal';
import ModelConfigEditor from 'components/ModelConfigEditor';
// ----------------------------------------------------------------------
// CHANNEL_OPTIONS,
@ -32,6 +33,7 @@ export default function ChannelPage() {
const matchUpMd = useMediaQuery(theme.breakpoints.up('sm'));
const [openModal, setOpenModal] = useState(false);
const [editChannelId, setEditChannelId] = useState(0);
const [openConfigEditor, setOpenConfigEditor] = useState(false);
const loadChannels = async (startIdx) => {
setSearching(true);
@ -197,9 +199,18 @@ export default function ChannelPage() {
<>
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={2.5}>
<Typography variant="h4">渠道</Typography>
<Button variant="contained" color="primary" startIcon={<IconPlus />} onClick={() => handleOpenModal(0)}>
新建渠道
</Button>
<Stack direction="row" spacing={1}>
<Button
variant="outlined"
startIcon={<IconSettings />}
onClick={() => setOpenConfigEditor(true)}
>
模型配置
</Button>
<Button variant="contained" color="primary" startIcon={<IconPlus />} onClick={() => handleOpenModal(0)}>
新建渠道
</Button>
</Stack>
</Stack>
<Card>
<Box component="form" onSubmit={searchChannels} noValidate sx={{ marginTop: 2 }}>
@ -283,6 +294,9 @@ export default function ChannelPage() {
/>
</Card>
<EditeModal open={openModal} onCancel={handleCloseModal} onOk={handleOkModal} channelId={editChannelId} />
{/* Model Configuration Editor */}
<ModelConfigEditor open={openConfigEditor} onClose={() => setOpenConfigEditor(false)} />
</>
);
}