feat(catalog): fetch model catalog from Zenmux API instead of local JSON
- 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:
parent
8df4a2670b
commit
885ad0507b
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user