From a9a224e7dd482df8b616c9f5249630638296c4f6 Mon Sep 17 00:00:00 2001
From: hjjjj <1311711287@qq.com>
Date: Wed, 8 Apr 2026 16:33:27 +0800
Subject: [PATCH] feat: add model configuration management and logging
enhancements
- 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.
---
.gitignore | 4 +-
common/gin.go | 13 +
controller/model.go | 509 +++++++++-
controller/relay.go | 12 +
model/option.go | 9 +-
relay/model/message.go | 44 +-
router/api.go | 9 +
web/berry/src/components/ModelConfigEditor.js | 955 ++++++++++++++++++
web/berry/src/views/Channel/index.js | 22 +-
9 files changed, 1548 insertions(+), 29 deletions(-)
create mode 100644 web/berry/src/components/ModelConfigEditor.js
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 (
+ <>
+
+
+ {/* 编辑/添加模型对话框 */}
+
+ >
+ );
+};
+
+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() {
<>
渠道
- } onClick={() => handleOpenModal(0)}>
- 新建渠道
-
+
+ }
+ onClick={() => setOpenConfigEditor(true)}
+ >
+ 模型配置
+
+ } onClick={() => handleOpenModal(0)}>
+ 新建渠道
+
+
@@ -283,6 +294,9 @@ export default function ChannelPage() {
/>
+
+ {/* Model Configuration Editor */}
+ setOpenConfigEditor(false)} />
>
);
}