From 885ad0507b84b4806f971fc830a8d6017516be5f Mon Sep 17 00:00:00 2001 From: hjjjj <1311711287@qq.com> Date: Mon, 9 Mar 2026 10:21:16 +0800 Subject: [PATCH] feat(catalog): fetch model catalog from Zenmux API instead of local JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace static model-catalog.json with live Zenmux listByFilter API - Map Zenmux schema: slug→id, suitable_api→protocols, supports_reasoning, pricing - Derive tags (function-call, vision, reasoning) from Zenmux fields - Cache response 5 minutes; ctoken configurable via ZENMUX_CTOKEN env var - Filter result to user's group-available models as before Co-Authored-By: Claude Sonnet 4.6 --- controller/model.go | 218 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 216 insertions(+), 2 deletions(-) diff --git a/controller/model.go b/controller/model.go index dcbe709..d4e54e1 100644 --- a/controller/model.go +++ b/controller/model.go @@ -1,7 +1,14 @@ package controller import ( + "encoding/json" "fmt" + "net/http" + "os" + "strings" + "sync" + "time" + "github.com/gin-gonic/gin" "github.com/songquanpeng/one-api/common/ctxkey" "github.com/songquanpeng/one-api/model" @@ -11,8 +18,6 @@ import ( "github.com/songquanpeng/one-api/relay/channeltype" "github.com/songquanpeng/one-api/relay/meta" relaymodel "github.com/songquanpeng/one-api/relay/model" - "net/http" - "strings" ) // https://platform.openai.com/docs/api-reference/models/list @@ -211,3 +216,212 @@ func GetUserAvailableModels(c *gin.Context) { }) 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"` + 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"` + 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"` + 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"` + 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, + InputModalities: inMod, + OutputModalities: outMod, + ContextLength: z.ContextLength, + MaxOutputTokens: z.MaxCompletionTokens, + InputPrice: parsePrice(z.PricingPrompt), + OutputPrice: parsePrice(z.PricingCompletion), + 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 +} + +// 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() + 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 + } + + availableModels, err := model.CacheGetGroupModels(ctx, userGroup) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + // Build a set for O(1) lookup + available := make(map[string]bool, len(availableModels)) + for _, m := range availableModels { + available[m] = true + } + + catalog, err := fetchZenmuxCatalog() + if err != nil { + // Return empty catalog with warning rather than failing hard + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "model catalog unavailable: " + err.Error(), + "data": []ModelCatalogItem{}, + }) + return + } + + // Filter to models actually available to this user + 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, + }) +} +