hjjjj a9a224e7dd
Some checks failed
CI / Unit tests (push) Has been cancelled
CI / commit_lint (push) Has been cancelled
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.
2026-04-08 16:33:27 +08:00

1214 lines
35 KiB
Go

package controller
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"sync"
"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"
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"
)
// https://platform.openai.com/docs/api-reference/models/list
type OpenAIModelPermission struct {
Id string `json:"id"`
Object string `json:"object"`
Created int `json:"created"`
AllowCreateEngine bool `json:"allow_create_engine"`
AllowSampling bool `json:"allow_sampling"`
AllowLogprobs bool `json:"allow_logprobs"`
AllowSearchIndices bool `json:"allow_search_indices"`
AllowView bool `json:"allow_view"`
AllowFineTuning bool `json:"allow_fine_tuning"`
Organization string `json:"organization"`
Group *string `json:"group"`
IsBlocking bool `json:"is_blocking"`
}
type OpenAIModels struct {
Id string `json:"id"`
Object string `json:"object"`
Created int `json:"created"`
OwnedBy string `json:"owned_by"`
Permission []OpenAIModelPermission `json:"permission"`
Root string `json:"root"`
Parent *string `json:"parent"`
}
var models []OpenAIModels
var modelsMap map[string]OpenAIModels
var channelId2Models map[int][]string
func init() {
var permission []OpenAIModelPermission
permission = append(permission, OpenAIModelPermission{
Id: "modelperm-LwHkVFn8AcMItP432fKKDIKJ",
Object: "model_permission",
Created: 1626777600,
AllowCreateEngine: true,
AllowSampling: true,
AllowLogprobs: true,
AllowSearchIndices: false,
AllowView: true,
AllowFineTuning: false,
Organization: "*",
Group: nil,
IsBlocking: false,
})
// https://platform.openai.com/docs/models/model-endpoint-compatibility
for i := 0; i < apitype.Dummy; i++ {
if i == apitype.AIProxyLibrary {
continue
}
adaptor := relay.GetAdaptor(i)
channelName := adaptor.GetChannelName()
modelNames := adaptor.GetModelList()
for _, modelName := range modelNames {
models = append(models, OpenAIModels{
Id: modelName,
Object: "model",
Created: 1626777600,
OwnedBy: channelName,
Permission: permission,
Root: modelName,
Parent: nil,
})
}
}
for _, channelType := range openai.CompatibleChannels {
if channelType == channeltype.Azure {
continue
}
channelName, channelModelList := openai.GetCompatibleChannelMeta(channelType)
for _, modelName := range channelModelList {
models = append(models, OpenAIModels{
Id: modelName,
Object: "model",
Created: 1626777600,
OwnedBy: channelName,
Permission: permission,
Root: modelName,
Parent: nil,
})
}
}
modelsMap = make(map[string]OpenAIModels)
for _, model := range models {
modelsMap[model.Id] = model
}
channelId2Models = make(map[int][]string)
for i := 1; i < channeltype.Dummy; i++ {
adaptor := relay.GetAdaptor(channeltype.ToAPIType(i))
meta := &meta.Meta{
ChannelType: i,
}
adaptor.Init(meta)
channelId2Models[i] = adaptor.GetModelList()
}
}
func DashboardListModels(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": channelId2Models,
})
}
func ListAllModels(c *gin.Context) {
c.JSON(200, gin.H{
"object": "list",
"data": models,
})
}
func ListModels(c *gin.Context) {
ctx := c.Request.Context()
var availableModels []string
if c.GetString(ctxkey.AvailableModels) != "" {
availableModels = strings.Split(c.GetString(ctxkey.AvailableModels), ",")
} else {
userId := c.GetInt(ctxkey.Id)
userGroup, _ := model.CacheGetUserGroup(userId)
availableModels, _ = model.CacheGetGroupModels(ctx, userGroup)
}
modelSet := make(map[string]bool)
for _, availableModel := range availableModels {
modelSet[availableModel] = true
}
availableOpenAIModels := make([]OpenAIModels, 0)
for _, model := range models {
if _, ok := modelSet[model.Id]; ok {
modelSet[model.Id] = false
availableOpenAIModels = append(availableOpenAIModels, model)
}
}
for modelName, ok := range modelSet {
if ok {
availableOpenAIModels = append(availableOpenAIModels, OpenAIModels{
Id: modelName,
Object: "model",
Created: 1626777600,
OwnedBy: "custom",
Root: modelName,
Parent: nil,
})
}
}
c.JSON(200, gin.H{
"object": "list",
"data": availableOpenAIModels,
})
}
func RetrieveModel(c *gin.Context) {
modelId := c.Param("model")
if model, ok := modelsMap[modelId]; ok {
c.JSON(200, model)
} else {
Error := relaymodel.Error{
Message: fmt.Sprintf("The model '%s' does not exist", modelId),
Type: "invalid_request_error",
Param: "model",
Code: "model_not_found",
}
c.JSON(200, gin.H{
"error": Error,
})
}
}
func GetUserAvailableModels(c *gin.Context) {
ctx := c.Request.Context()
id := c.GetInt(ctxkey.Id)
userGroup, err := model.CacheGetUserGroup(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
models, err := model.CacheGetGroupModels(ctx, userGroup)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": models,
})
return
}
// --- Model Catalog (powered by Zenmux) ---
// ModelCatalogItem is the enriched model record returned to aiscri-xiong clients.
type ModelCatalogItem struct {
Id string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Description string `json:"description"`
InputModalities []string `json:"input_modalities"`
OutputModalities []string `json:"output_modalities"`
ContextLength int `json:"context_length"`
MaxOutputTokens int `json:"max_output_tokens"`
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"`
// SupportsReasoning: 0=none, 1=always-on, 2=toggleable
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.
type zenmuxModel struct {
Slug string `json:"slug"`
Name string `json:"name"`
Author string `json:"author"`
Description string `json:"description"`
InputModalities string `json:"input_modalities"` // comma-separated
OutputModalities string `json:"output_modalities"` // comma-separated
ContextLength int `json:"context_length"`
MaxCompletionTokens int `json:"max_completion_tokens"`
PricingPrompt string `json:"pricing_prompt"`
PricingCompletion string `json:"pricing_completion"`
PricingDiscount string `json:"pricing_discount"`
SuitableApi string `json:"suitable_api"` // comma-separated
SupportsReasoning int `json:"supports_reasoning"`
SupportedParameters string `json:"supported_parameters"` // comma-separated
}
type zenmuxListResponse struct {
Success bool `json:"success"`
Data []zenmuxModel `json:"data"`
}
var (
catalogCache []ModelCatalogItem
catalogCacheTime time.Time
catalogMu sync.RWMutex
catalogCacheTTL = 5 * time.Minute
// zenmuxCToken is the public frontend token embedded in the Zenmux website.
// Override via env var ZENMUX_CTOKEN if it ever changes.
zenmuxCToken = func() string {
if v := os.Getenv("ZENMUX_CTOKEN"); v != "" {
return v
}
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
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
func parsePrice(s string) float64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
var f float64
fmt.Sscanf(s, "%f", &f)
return f
}
func zenmuxToItem(z zenmuxModel) ModelCatalogItem {
inMod := splitCSV(z.InputModalities)
outMod := splitCSV(z.OutputModalities)
params := splitCSV(z.SupportedParameters)
// Derive tags
tags := []string{}
for _, p := range params {
if p == "tools" {
tags = append(tags, "function-call")
break
}
}
for _, m := range inMod {
if m == "image" {
tags = append(tags, "vision")
break
}
}
if z.SupportsReasoning > 0 {
tags = append(tags, "reasoning")
}
return ModelCatalogItem{
Id: z.Slug,
Name: z.Name,
Provider: z.Author,
Description: z.Description,
InputModalities: inMod,
OutputModalities: outMod,
ContextLength: z.ContextLength,
MaxOutputTokens: z.MaxCompletionTokens,
InputPrice: parsePrice(z.PricingPrompt),
OutputPrice: parsePrice(z.PricingCompletion),
PricingDiscount: z.PricingDiscount,
Tags: tags,
SupportsReasoning: z.SupportsReasoning,
SuitableApi: z.SuitableApi,
IsCustom: false,
}
}
func fetchZenmuxCatalog() ([]ModelCatalogItem, error) {
catalogMu.RLock()
if catalogCache != nil && time.Since(catalogCacheTime) < catalogCacheTTL {
defer catalogMu.RUnlock()
return catalogCache, nil
}
catalogMu.RUnlock()
url := "https://zenmux.ai/api/frontend/model/listByFilter?ctoken=" + zenmuxCToken + "&sort=newest&context_length=&keyword="
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("zenmux catalog fetch failed: %w", err)
}
defer resp.Body.Close()
var parsed zenmuxListResponse
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return nil, fmt.Errorf("zenmux catalog parse failed: %w", err)
}
if !parsed.Success {
return nil, fmt.Errorf("zenmux catalog returned success=false")
}
items := make([]ModelCatalogItem, 0, len(parsed.Data))
for _, z := range parsed.Data {
if z.Slug != "" {
items = append(items, zenmuxToItem(z))
}
}
catalogMu.Lock()
catalogCache = items
catalogCacheTime = time.Now()
catalogMu.Unlock()
return items, nil
}
// GetTokenQuota returns quota info for the authenticated relay token.
// GET /api/token-quota — requires TokenAuth middleware.
func GetTokenQuota(c *gin.Context) {
tokenId := c.GetInt(ctxkey.TokenId)
token, err := model.GetTokenById(tokenId)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"remain_quota": token.RemainQuota,
"used_quota": token.UsedQuota,
"unlimited_quota": token.UnlimitedQuota,
"expired_time": token.ExpiredTime,
"name": token.Name,
},
})
}
// GetModelCatalog returns models with capability metadata, filtered to only those
// available to the requesting user's group.
func GetModelCatalog(c *gin.Context) {
ctx := c.Request.Context()
var availableModels []string
if c.GetString(ctxkey.AvailableModels) != "" {
availableModels = strings.Split(c.GetString(ctxkey.AvailableModels), ",")
} else {
id := c.GetInt(ctxkey.Id)
userGroup, err := model.CacheGetUserGroup(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
var groupErr error
availableModels, groupErr = model.CacheGetGroupModels(ctx, userGroup)
if groupErr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": groupErr.Error()})
return
}
}
available := make(map[string]bool, len(availableModels))
for _, m := range availableModels {
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(),
"data": []ModelCatalogItem{},
})
return
}
// 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)
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": result,
})
}
// zenmuxChannelDef describes one Zenmux protocol → one-api channel mapping.
type zenmuxChannelDef struct {
Name string
Type int
Protocols []string // suitable_api values to include
}
// allZenmuxChannelDefs is the canonical channel mapping for Zenmux.
//
// Zenmux exposes a single OpenAI-compatible ingress at /api/v1 for ALL text models
// (chat.completions, responses, messages, gemini, generate).
// suitable_api is upstream metadata only — Zenmux always accepts OpenAI format on input.
//
// Imagen and Veo use https://zenmux.ai/api/vertex-ai (Vertex AI SDK) and are
// not handled here (one-api's relay framework doesn't support that format yet).
var allZenmuxChannelDefs = []zenmuxChannelDef{
{
Name: "Zenmux",
Type: channeltype.OpenAICompatible,
Protocols: []string{"chat.completions", "responses", "messages", "gemini", "generate"},
},
}
// GetZenmuxProtocols returns all distinct suitable_api values found in the Zenmux catalog.
// GET /api/zenmux/protocols — admin only, for debugging protocol → channel mapping.
func GetZenmuxProtocols(c *gin.Context) {
catalog, err := fetchZenmuxCatalog()
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
seen := make(map[string]int)
for _, item := range catalog {
for _, api := range splitCSV(item.SuitableApi) {
api = strings.TrimSpace(api)
if api != "" {
seen[api]++
}
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": seen})
}
// SetupZenmuxChannels creates all Zenmux protocol channels and syncs their model lists.
// POST /api/zenmux/setup — body: {"key":"<zenmux-api-key>","base_url":"https://zenmux.ai"}
// Skips channels that already exist (matched by name). Idempotent.
func SetupZenmuxChannels(c *gin.Context) {
var req struct {
Key string `json:"key"`
BaseURL string `json:"base_url"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Key == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "key is required"})
return
}
baseURL := req.BaseURL
if baseURL == "" {
baseURL = "https://zenmux.ai/api/v1"
}
catalog, err := fetchZenmuxCatalog()
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "fetch zenmux catalog failed: " + err.Error()})
return
}
type result struct {
Name string `json:"name"`
Models int `json:"models"`
Action string `json:"action"` // "created" | "skipped"
}
results := make([]result, 0, len(allZenmuxChannelDefs))
for _, def := range allZenmuxChannelDefs {
// Build protocol set for fast lookup.
wantProtos := make(map[string]bool, len(def.Protocols))
for _, p := range def.Protocols {
wantProtos[strings.ToLower(p)] = true
}
// Filter catalog to matching protocols.
slugs := make([]string, 0)
for _, item := range catalog {
for _, api := range splitCSV(item.SuitableApi) {
if wantProtos[strings.ToLower(strings.TrimSpace(api))] {
slugs = append(slugs, item.Id)
break
}
}
}
modelList := strings.Join(slugs, ",")
// Check if a channel with this name already exists.
var existing []model.Channel
model.DB.Where("name = ?", def.Name).Find(&existing)
if len(existing) > 0 {
// Update model list on the first match.
ch := existing[0]
ch.Models = modelList
_ = ch.Update()
results = append(results, result{Name: def.Name, Models: len(slugs), Action: "updated"})
continue
}
// Create new channel.
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",
Priority: &priority,
Weight: &weight,
}
if err := ch.Insert(); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("create channel %q failed: %s", def.Name, err.Error())})
return
}
results = append(results, result{Name: def.Name, Models: len(slugs), Action: "created"})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("setup complete: %d channels processed", len(results)),
"data": results,
})
}
// 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
func SyncZenmuxModels(c *gin.Context) {
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid channel id"})
return
}
// Build a set of requested protocols (lowercased) for O(1) lookup.
protocolParam := strings.TrimSpace(c.Query("protocol"))
wantProtocols := make(map[string]bool)
for _, p := range splitCSV(protocolParam) {
if p != "" {
wantProtocols[strings.ToLower(p)] = true
}
}
catalog, err := fetchZenmuxCatalog()
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
slugs := make([]string, 0, len(catalog))
for _, item := range catalog {
if len(wantProtocols) > 0 {
matched := false
for _, api := range splitCSV(item.SuitableApi) {
if wantProtocols[strings.ToLower(strings.TrimSpace(api))] {
matched = true
break
}
}
if !matched {
continue
}
}
slugs = append(slugs, item.Id)
}
modelList := strings.Join(slugs, ",")
ch, err := model.GetChannelById(channelId, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "channel not found"})
return
}
ch.Models = modelList
if err := ch.Update(); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
msg := fmt.Sprintf("synced %d models to channel", len(slugs))
if protocolParam != "" {
msg = fmt.Sprintf("synced %d models (protocol=%s) to channel", len(slugs), protocolParam)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": msg,
"data": len(slugs),
})
}
// SyncZenmuxRatios fetches the Zenmux model catalog and updates one-api's
// ModelRatio and CompletionRatio maps so every Zenmux model has correct billing.
//
// Ratio formula:
//
// modelRatio = input_price ($/1M tokens, raw)
// completionRatio = output_price / input_price (relative to input)
//
// POST /api/zenmux/sync-ratios — admin only.
func SyncZenmuxRatios(c *gin.Context) {
catalog, err := fetchZenmuxCatalog()
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "fetch catalog failed: " + err.Error()})
return
}
// Load current ratios so we only ADD/UPDATE Zenmux entries, not wipe custom ones.
var modelRatioMap map[string]float64
var completionRatioMap map[string]float64
_ = json.Unmarshal([]byte(billingratio.ModelRatio2JSONString()), &modelRatioMap)
_ = json.Unmarshal([]byte(billingratio.CompletionRatio2JSONString()), &completionRatioMap)
if modelRatioMap == nil {
modelRatioMap = make(map[string]float64)
}
if completionRatioMap == nil {
completionRatioMap = make(map[string]float64)
}
updated := 0
for _, item := range catalog {
// Only overwrite existing non-zero ratio if new price is also non-zero.
// This preserves manually configured ratios for models where Zenmux reports price=0
// (e.g. per-image billing models whose token price is not tracked here).
if item.InputPrice > 0 || modelRatioMap[item.Id] == 0 {
modelRatioMap[item.Id] = item.InputPrice
}
if item.InputPrice > 0 && item.OutputPrice > 0 {
completionRatioMap[item.Id] = item.OutputPrice / item.InputPrice
}
updated++
}
newModelJSON, err := json.Marshal(modelRatioMap)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "marshal model ratio failed: " + err.Error()})
return
}
if err = billingratio.UpdateModelRatioByJSONString(string(newModelJSON)); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "update model ratio failed: " + err.Error()})
return
}
if err = model.UpdateOption("ModelRatio", string(newModelJSON)); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "save model ratio failed: " + err.Error()})
return
}
newCompletionJSON, err := json.Marshal(completionRatioMap)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "marshal completion ratio failed: " + err.Error()})
return
}
if err = billingratio.UpdateCompletionRatioByJSONString(string(newCompletionJSON)); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "update completion ratio failed: " + err.Error()})
return
}
if err = model.UpdateOption("CompletionRatio", string(newCompletionJSON)); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "save completion ratio failed: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("synced ratios for %d models", updated),
})
}
// --- 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)
}