diff --git a/.gitignore b/.gitignore index 2b159a8..fea137d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,6 @@ cmd.md /one-api temp .DS_Store -.claude \ No newline at end of file +.claude +.claw/ +.claw-todos.json \ No newline at end of file diff --git a/common/gin.go b/common/gin.go index e3281fe..a05075e 100644 --- a/common/gin.go +++ b/common/gin.go @@ -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) diff --git a/controller/model.go b/controller/model.go index 6a0c274..3a86bce 100644 --- a/controller/model.go +++ b/controller/model.go @@ -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) +} diff --git a/controller/relay.go b/controller/relay.go index a0448f7..a151442 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -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) diff --git a/model/option.go b/model/option.go index 8fd30ae..36119c0 100644 --- a/model/option.go +++ b/model/option.go @@ -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) } diff --git a/relay/model/message.go b/relay/model/message.go index 33c20ba..6d1577c 100644 --- a/relay/model/message.go +++ b/relay/model/message.go @@ -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"` diff --git a/router/api.go b/router/api.go index 9ec45c2..9bdb665 100644 --- a/router/api.go +++ b/router/api.go @@ -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) diff --git a/web/berry/src/components/ModelConfigEditor.js b/web/berry/src/components/ModelConfigEditor.js new file mode 100644 index 0000000..03c5567 --- /dev/null +++ b/web/berry/src/components/ModelConfigEditor.js @@ -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 ( + <> + + + + + + 模型配置管理 + + + + + + + + + + + + + + setTabValue(v)} sx={{ borderBottom: 1, borderColor: 'divider' }}> + + + + + + setSearchKeyword(e.target.value)} + InputProps={{ + startAdornment: , + }} + size="small" + sx={{ flexGrow: 1, maxWidth: 300 }} + /> + {tabValue === 1 && ( + + )} + + + {/* Tab 1: 所有模型 */} + + {loading ? ( + + ) : ( + + + + + 模型ID + 名称 + 提供商 + 输入价 + 输出价 + 上下文 + 模态 + 标签 + + + + {filteredModels.map((model) => ( + + + + {model.id} + + + + {model.name || '-'} + + + + + {model.input_price || '-'} + {model.output_price || '-'} + + {model.context_length ? ( + + {model.context_length >= 1000 + ? `${(model.context_length / 1000).toFixed(0)}k` + : model.context_length} + + ) : '-'} + + + + {(model.input_modalities || []).map(m => ( + + ))} + + + + + {(model.tags || []).map(tag => ( + + ))} + {model.supports_reasoning > 0 && ( + + )} + + + + ))} + +
+
+ )} +
+ + {/* Tab 2: 自定义配置 */} + + + + + + 模型ID + 名称 + 提供商 + 价格(入/出) + 上下文 + 模态 + 标签 + 操作 + + + + {filteredCustom.map((model) => ( + + + + {model.id} + + + + {model.name} + + + + + + + {model.input_price}/{model.output_price} + + + + + {model.context_length + ? (model.context_length >= 1000 + ? `${(model.context_length / 1000).toFixed(0)}k` + : model.context_length) + : '-'} + + + + + {(model.input_modalities || []).join(', ')} + + + + + {(model.tags || []).slice(0, 3).map(tag => ( + + ))} + {(model.tags || []).length > 3 && ( + + )} + + + + openEditModal(model)}> + + + deleteModel(model.id)}> + + + + + ))} + {filteredCustom.length === 0 && ( + + + + 暂无自定义模型配置,点击"添加模型"创建新配置 + + + + )} + +
+
+
+
+ + + + {tabValue === 1 && customModels.length > 0 && ( + + 已配置 {customModels.length} 个自定义模型,修改后点击"保存配置"持久化到数据库 + + )} + + + + + + +
+ + {/* 编辑/添加模型对话框 */} + + + + + {editingModel ? '编辑模型配置' : '添加模型配置'} + + + + + + + + {/* 基本信息 */} + + }> + 基本信息 + + + + + setFormValues({ ...formValues, id: e.target.value })} + size="small" + placeholder="例如: MiniMax/MiniMax-M2.7" + helperText={editingModel ? "可修改;修改后会按新ID保存并覆盖当前条目" : "唯一标识符,格式: provider/model-name"} + /> + + + + setFormValues({ ...formValues, name: e.target.value })} + size="small" + placeholder="例如: MiniMax-M2.7" + /> + + + + setFormValues({ ...formValues, provider: v })} + renderInput={(params) => ( + + )} + /> + + + + + 模型类型 + + 用于区分对话/语音/嵌入/图像模型 + + + + + setFormValues({ ...formValues, description: e.target.value })} + size="small" + placeholder="模型功能和特点描述" + /> + + + + + + {/* 请求协议 */} + + }> + 请求协议 + + + + + + 协议覆盖 + + + 当前: {formValues.suitable_api || 'openai-chat'} + + + + + + + + setFormValues({ ...formValues, use_default_model_type: e.target.checked })} + size="small" + /> + 使用提供商默认模型类型 + + + 用于区分对话/语音/嵌入/图像模型 + + + + + + + + {/* 上下文与输出 */} + + }> + 上下文与输出 + + + + + setFormValues({ ...formValues, context_length: e.target.value })} + size="small" + helperText="tokens,如: 200000" + inputProps={{ step: 1, min: 0 }} + /> + + + + setFormValues({ ...formValues, max_output_tokens: e.target.value })} + size="small" + helperText="tokens,如: 128000" + inputProps={{ step: 1, min: 0 }} + /> + + + + + + {/* 定价配置 */} + + }> + 定价 (USD / 百万 Token) + + + + + setFormValues({ ...formValues, input_price: e.target.value })} + size="small" + helperText="$/1M tokens" + inputProps={{ step: 0.01, min: 0 }} + /> + + + + setFormValues({ ...formValues, output_price: e.target.value })} + size="small" + helperText="$/1M tokens" + inputProps={{ step: 0.01, min: 0 }} + /> + + + + setFormValues({ ...formValues, cache_write_price: e.target.value })} + size="small" + helperText="$/1M tokens" + inputProps={{ step: 0.01, min: 0 }} + /> + + + + setFormValues({ ...formValues, cache_read_price: e.target.value })} + size="small" + helperText="$/1M tokens" + inputProps={{ step: 0.01, min: 0 }} + /> + + + + setFormValues({ ...formValues, pricing_discount: e.target.value })} + size="small" + helperText="如: 0.8 表示8折" + inputProps={{ step: 0.01, min: 0, max: 1 }} + /> + + + + setFormValues({ ...formValues, icon: e.target.value })} + size="small" + helperText="auto 或图标 URL" + /> + + + + + + {/* Responses 配置 */} + + }> + Responses 配置 + + + + + + 推理摘要 + + + + + + + Prompt 缓存 + + + + + + + + {/* 能力配置 */} + + }> + 能力 + + + + + Input modalities + setFormValues({ ...formValues, input_modalities: v })} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + renderInput={(params) => ( + + )} + /> + + + + Output modalities + setFormValues({ ...formValues, output_modalities: v })} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + renderInput={(params) => ( + + )} + /> + + + + + 支持函数调用 + setFormValues({ ...formValues, supports_function_calling: e.target.checked })} + /> + + + + + + + 支持 Computer Use + + + + + setFormValues({ ...formValues, supports_computer_use: e.target.checked })} + /> + + + + + 推理能力 (supports_reasoning) + + + + + 功能标签 + setFormValues({ ...formValues, tags: v })} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + renderInput={(params) => ( + + )} + /> + + + + + + + + + + + + + + ); +}; + +export default ModelConfigEditor; diff --git a/web/berry/src/views/Channel/index.js b/web/berry/src/views/Channel/index.js index bf6df24..c69ce63 100644 --- a/web/berry/src/views/Channel/index.js +++ b/web/berry/src/views/Channel/index.js @@ -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() { <> 渠道 - + + + + @@ -283,6 +294,9 @@ export default function ChannelPage() { /> + + {/* Model Configuration Editor */} + setOpenConfigEditor(false)} /> ); }