feat(catalog): fetch model catalog from Zenmux API instead of local JSON
Some checks failed
CI / Unit tests (push) Has been cancelled
CI / commit_lint (push) Has been cancelled

- 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 <noreply@anthropic.com>
This commit is contained in:
hjjjj 2026-03-09 10:21:16 +08:00
parent 8df4a2670b
commit 885ad0507b

View File

@ -1,7 +1,14 @@
package controller package controller
import ( import (
"encoding/json"
"fmt" "fmt"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/ctxkey" "github.com/songquanpeng/one-api/common/ctxkey"
"github.com/songquanpeng/one-api/model" "github.com/songquanpeng/one-api/model"
@ -11,8 +18,6 @@ import (
"github.com/songquanpeng/one-api/relay/channeltype" "github.com/songquanpeng/one-api/relay/channeltype"
"github.com/songquanpeng/one-api/relay/meta" "github.com/songquanpeng/one-api/relay/meta"
relaymodel "github.com/songquanpeng/one-api/relay/model" relaymodel "github.com/songquanpeng/one-api/relay/model"
"net/http"
"strings"
) )
// https://platform.openai.com/docs/api-reference/models/list // https://platform.openai.com/docs/api-reference/models/list
@ -211,3 +216,212 @@ func GetUserAvailableModels(c *gin.Context) {
}) })
return 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,
})
}