package controller import ( "encoding/json" "fmt" "net/http" "os" "strconv" "strings" "sync" "time" "github.com/gin-gonic/gin" "github.com/songquanpeng/one-api/common/ctxkey" "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" "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"` } // 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" }() ) 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, } } 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 } catalog, err := fetchZenmuxCatalog() if err != nil { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "model catalog unavailable: " + err.Error(), "data": []ModelCatalogItem{}, }) return } result := make([]ModelCatalogItem, 0, len(catalog)) for _, item := range catalog { 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":"","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), }) }