diff --git a/internal/logic/admin/model.go b/internal/logic/admin/model.go index cea92b2..e6363b7 100644 --- a/internal/logic/admin/model.go +++ b/internal/logic/admin/model.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "math" + "strings" "github.com/gogf/gf/v2/errors/gcode" "github.com/gogf/gf/v2/errors/gerror" @@ -447,7 +448,7 @@ func (s *sAdmin) FetchOfficialPricing(ctx context.Context, req *v1.PricingFetchO return nil, common.NewBusinessError(404, "模型不存在") } - sources := make([]*v1.OfficialPricingSource, 0, 2) + sources := make([]*v1.OfficialPricingSource, 0, 4) // Source 1: LiteLLM litellmSource := fetchLiteLLMSource(ctx, model.ModelId) @@ -457,6 +458,14 @@ func (s *sAdmin) FetchOfficialPricing(ctx context.Context, req *v1.PricingFetchO modelsDevSource := fetchModelsDevSource(ctx, model.ModelId) sources = append(sources, modelsDevSource) + // Source 3: BaseLLM + baseLLMSource := fetchBaseLLMSource(ctx, model.ModelId) + sources = append(sources, baseLLMSource) + + // Source 4: OpenRouter + openRouterSource := fetchOpenRouterSource(ctx, model.ModelId) + sources = append(sources, openRouterSource) + return &v1.PricingFetchOfficialRes{ ModelName: model.ModelId, Sources: sources, @@ -543,46 +552,162 @@ func fetchModelsDevSource(ctx context.Context, modelName string) *v1.OfficialPri return source } +func fetchBaseLLMSource(ctx context.Context, modelName string) *v1.OfficialPricingSource { + source := &v1.OfficialPricingSource{Source: "basellm"} + + data, err := common.FetchBaseLLMPricing(ctx) + if err != nil { + g.Log().Warningf(ctx, "[FetchOfficialPricing] basellm fetch failed for %s: %v", modelName, err) + source.Error = err.Error() + return source + } + + matchedName, entry := common.FindBaseLLMModel(data, modelName) + if entry == nil { + return source + } + + cacheReadPrice := 0.0 + if entry.PricePerMCacheRead != nil { + cacheReadPrice = *entry.PricePerMCacheRead + } + cacheWritePrice := 0.0 + if entry.PricePerMCacheWrite != nil { + cacheWritePrice = *entry.PricePerMCacheWrite + } + + source.Found = true + source.Provider = entry.VendorName + source.MaxContext = common.ParseBaseLLMContext(entry.Tags) + source.Pricing = &v1.OfficialPricingItem{ + InputPrice: roundTo2(entry.PricePerMInput), + OutputPrice: roundTo2(entry.PricePerMOutput), + CacheReadPrice: roundTo2(cacheReadPrice), + CacheCreationPrice: roundTo2(cacheWritePrice), + BillingMode: "token", + } + + g.Log().Debugf(ctx, "[FetchOfficialPricing] basellm matched %s -> %s", modelName, matchedName) + return source +} + +func fetchOpenRouterSource(ctx context.Context, modelName string) *v1.OfficialPricingSource { + source := &v1.OfficialPricingSource{Source: "openrouter"} + + data, err := common.FetchOpenRouterModels(ctx) + if err != nil { + g.Log().Warningf(ctx, "[FetchOfficialPricing] openrouter fetch failed for %s: %v", modelName, err) + source.Error = err.Error() + return source + } + + matchedName, entry := common.FindOpenRouterModel(data, modelName) + if entry == nil || entry.Pricing == nil { + return source + } + + inputPrice := common.OpenRouterPricePerM(entry.Pricing.Prompt) + outputPrice := common.OpenRouterPricePerM(entry.Pricing.Completion) + cacheReadPrice := common.OpenRouterPricePerM(entry.Pricing.InputCacheRead) + cacheWritePrice := common.OpenRouterPricePerM(entry.Pricing.InputCacheWrite) + + source.Found = true + if idx := strings.Index(entry.ID, "/"); idx >= 0 { + source.Provider = entry.ID[:idx] + } + source.MaxContext = entry.ContextLength + if entry.TopProvider != nil && entry.TopProvider.MaxCompletionTokens > 0 { + source.MaxOutput = entry.TopProvider.MaxCompletionTokens + } + source.Pricing = &v1.OfficialPricingItem{ + InputPrice: roundTo2(inputPrice), + OutputPrice: roundTo2(outputPrice), + CacheReadPrice: roundTo2(cacheReadPrice), + CacheCreationPrice: roundTo2(cacheWritePrice), + BillingMode: "token", + } + + g.Log().Debugf(ctx, "[FetchOfficialPricing] openrouter matched %s -> %s", modelName, matchedName) + return source +} + func roundTo2(v float64) float64 { return math.Round(v*100) / 100 } // FetchOfficialModelInfo 按模型名称拉取官方模型信息(上下文长度+能力特性) +// 按优先级尝试 LiteLLM → OpenRouter → BaseLLM,返回第一个匹配的结果 func (s *sAdmin) FetchOfficialModelInfo(ctx context.Context, req *v1.ModelFetchOfficialInfoReq) (*v1.ModelFetchOfficialInfoRes, error) { - data, err := common.FetchLiteLLMPricing(ctx) - if err != nil { + var lastError string + + // Source 1: LiteLLM + if data, err := common.FetchLiteLLMPricing(ctx); err != nil { g.Log().Warningf(ctx, "[FetchOfficialModelInfo] litellm fetch failed for %s: %v", req.ModelName, err) - return &v1.ModelFetchOfficialInfoRes{Found: false, Error: err.Error()}, nil + lastError = err.Error() + } else if _, entry := common.FindLiteLLMModel(data, req.ModelName); entry != nil { + return &v1.ModelFetchOfficialInfoRes{ + Found: true, + Provider: entry.LitellmProvider, + MaxContextTokens: entry.MaxInputTokens, + MaxOutputTokens: entry.MaxOutputTokens, + Capabilities: map[string]bool{ + "vision": entry.SupportsVision, + "function_calling": entry.SupportsFunctionCalling, + "parallel_function_calling": entry.SupportsParallelFuncCalling, + "tool_choice": entry.SupportsToolChoice, + "response_schema": entry.SupportsResponseSchema, + "system_messages": entry.SupportsSystemMessages, + "prompt_caching": entry.SupportsPromptCaching, + "audio_input": entry.SupportsAudioInput, + "audio_output": entry.SupportsAudioOutput, + "pdf_input": entry.SupportsPdfInput, + "embedding_image": entry.SupportsEmbeddingImage, + "reasoning": entry.SupportsReasoning, + "web_search": entry.SupportsWebSearch, + }, + }, nil } - _, entry := common.FindLiteLLMModel(data, req.ModelName) - if entry == nil { - return &v1.ModelFetchOfficialInfoRes{Found: false}, nil + // Source 2: OpenRouter + if data, err := common.FetchOpenRouterModels(ctx); err != nil { + g.Log().Warningf(ctx, "[FetchOfficialModelInfo] openrouter fetch failed for %s: %v", req.ModelName, err) + lastError = err.Error() + } else if _, entry := common.FindOpenRouterModel(data, req.ModelName); entry != nil { + maxOutput := 0 + if entry.TopProvider != nil { + maxOutput = entry.TopProvider.MaxCompletionTokens + } + provider := "" + if idx := strings.Index(entry.ID, "/"); idx >= 0 { + provider = entry.ID[:idx] + } + return &v1.ModelFetchOfficialInfoRes{ + Found: true, + Provider: provider, + MaxContextTokens: entry.ContextLength, + MaxOutputTokens: maxOutput, + Capabilities: common.ParseOpenRouterCapabilities(entry), + }, nil } - capabilities := map[string]bool{ - "vision": entry.SupportsVision, - "function_calling": entry.SupportsFunctionCalling, - "parallel_function_calling": entry.SupportsParallelFuncCalling, - "tool_choice": entry.SupportsToolChoice, - "response_schema": entry.SupportsResponseSchema, - "system_messages": entry.SupportsSystemMessages, - "prompt_caching": entry.SupportsPromptCaching, - "audio_input": entry.SupportsAudioInput, - "audio_output": entry.SupportsAudioOutput, - "pdf_input": entry.SupportsPdfInput, - "embedding_image": entry.SupportsEmbeddingImage, - "reasoning": entry.SupportsReasoning, - "web_search": entry.SupportsWebSearch, + // Source 3: BaseLLM + if data, err := common.FetchBaseLLMPricing(ctx); err != nil { + g.Log().Warningf(ctx, "[FetchOfficialModelInfo] basellm fetch failed for %s: %v", req.ModelName, err) + lastError = err.Error() + } else if _, entry := common.FindBaseLLMModel(data, req.ModelName); entry != nil { + return &v1.ModelFetchOfficialInfoRes{ + Found: true, + Provider: entry.VendorName, + MaxContextTokens: common.ParseBaseLLMContext(entry.Tags), + Capabilities: common.ParseBaseLLMCapabilities(entry.Tags), + }, nil } - return &v1.ModelFetchOfficialInfoRes{ - Found: true, - Provider: entry.LitellmProvider, - MaxContextTokens: entry.MaxInputTokens, - MaxOutputTokens: entry.MaxOutputTokens, - Capabilities: capabilities, - }, nil + res := &v1.ModelFetchOfficialInfoRes{Found: false} + if lastError != "" { + res.Error = lastError + } + return res, nil } // ExportModels exports model list to CSV or Excel. diff --git a/internal/logic/common/pricing_source.go b/internal/logic/common/pricing_source.go index 85ff479..93ac739 100644 --- a/internal/logic/common/pricing_source.go +++ b/internal/logic/common/pricing_source.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "math" + "strconv" "strings" "time" @@ -111,7 +112,7 @@ func FindLiteLLMModel(data map[string]*LiteLLMModelEntry, modelName string) (str } // Try common provider prefixes that LiteLLM uses - providers := []string{"openai/", "anthropic/", "vertex_ai/", "bedrock/", "azure/"} + providers := []string{"openai/", "anthropic/", "vertex_ai/", "bedrock/", "azure/", "deepseek/", "volcengine/"} for _, prefix := range providers { if entry, ok := data[prefix+modelName]; ok { return prefix + modelName, entry @@ -289,3 +290,314 @@ func FindModelsDevModel(data map[string]*ModelsDevModelEntry, modelName string) return "", nil } + +// ---------- BaseLLM 数据源(国内模型覆盖好) ---------- + +const ( + baseLLMURL = "https://basellm.github.io/llm-metadata/api/newapi/models.json" + baseLLMCacheKey = "basellm_pricing_data" + baseLLMCacheTTL = 24 * time.Hour +) + +// BaseLLMModelEntry represents a single model entry in the BaseLLM data. +type BaseLLMModelEntry struct { + ModelName string `json:"model_name"` + VendorName string `json:"vendor_name"` + Tags string `json:"tags"` + Status int `json:"status"` + PricePerMInput float64 `json:"price_per_m_input"` + PricePerMOutput float64 `json:"price_per_m_output"` + PricePerMCacheRead *float64 `json:"price_per_m_cache_read"` + PricePerMCacheWrite *float64 `json:"price_per_m_cache_write"` +} + +var baseLLMCache = gcache.New() + +// FetchBaseLLMPricing fetches and caches the BaseLLM pricing data. +func FetchBaseLLMPricing(ctx context.Context) (map[string]*BaseLLMModelEntry, error) { + cached, err := baseLLMCache.Get(ctx, baseLLMCacheKey) + if err == nil && cached != nil { + if data, ok := cached.Val().(map[string]*BaseLLMModelEntry); ok { + return data, nil + } + } + + g.Log().Info(ctx, "[PricingSource] fetching BaseLLM pricing data from remote...") + + resp, err := g.Client().SetHeaderMap(map[string]string{ + "User-Agent": "github.com/qianfree/team-api/1.0", + }).Get(ctx, baseLLMURL) + if err != nil { + return nil, fmt.Errorf("获取远程数据失败: %w", err) + } + defer resp.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("获取远程数据失败: HTTP %d", resp.StatusCode) + } + + body := resp.ReadAll() + if len(body) == 0 { + return nil, fmt.Errorf("获取远程数据失败: 响应为空") + } + + var raw struct { + Data []*BaseLLMModelEntry `json:"data"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("获取远程数据失败: JSON 解析失败: %w", err) + } + + result := make(map[string]*BaseLLMModelEntry, len(raw.Data)) + for _, entry := range raw.Data { + if entry.ModelName == "" || entry.Status != 1 { + continue + } + name := strings.ToLower(entry.ModelName) + if _, exists := result[name]; !exists { + result[name] = entry + } + } + + if err := baseLLMCache.Set(ctx, baseLLMCacheKey, result, baseLLMCacheTTL); err != nil { + g.Log().Warningf(ctx, "[PricingSource] failed to cache BaseLLM data: %v", err) + } + + g.Log().Infof(ctx, "[PricingSource] loaded %d models from BaseLLM", len(result)) + return result, nil +} + +// FindBaseLLMModel searches for a model in the BaseLLM pricing data. +func FindBaseLLMModel(data map[string]*BaseLLMModelEntry, modelName string) (string, *BaseLLMModelEntry) { + name := strings.ToLower(modelName) + if entry, ok := data[name]; ok { + return name, entry + } + return "", nil +} + +// ParseBaseLLMContext extracts context window size from BaseLLM tags (e.g. "128K" → 128000, "1M" → 1000000). +func ParseBaseLLMContext(tags string) int { + for _, tag := range strings.Split(tags, ",") { + tag = strings.TrimSpace(tag) + if len(tag) < 2 { + continue + } + suffix := tag[len(tag)-1:] + numStr := tag[:len(tag)-1] + switch suffix { + case "K": + if num, err := strconv.ParseFloat(numStr, 64); err == nil { + return int(num * 1000) + } + case "M": + if num, err := strconv.ParseFloat(numStr, 64); err == nil { + return int(num * 1_000_000) + } + } + } + return 0 +} + +// ParseBaseLLMCapabilities extracts capability map from BaseLLM tags. +func ParseBaseLLMCapabilities(tags string) map[string]bool { + caps := make(map[string]bool) + for _, tag := range strings.Split(tags, ",") { + switch strings.TrimSpace(tag) { + case "Vision": + caps["vision"] = true + case "Tools": + caps["function_calling"] = true + caps["tool_choice"] = true + case "Reasoning": + caps["reasoning"] = true + case "Audio": + caps["audio_input"] = true + caps["audio_output"] = true + case "Files": + caps["pdf_input"] = true + } + } + return caps +} + +// ---------- OpenRouter 数据源 ---------- + +const ( + openRouterURL = "https://openrouter.ai/api/v1/models" + openRouterCacheKey = "openrouter_models_data" + openRouterCacheTTL = 24 * time.Hour +) + +// OpenRouterModelEntry represents a single model entry in the OpenRouter API. +type OpenRouterModelEntry struct { + ID string `json:"id"` + Name string `json:"name"` + ContextLength int `json:"context_length"` + Architecture *OpenRouterArchitecture `json:"architecture"` + Pricing *OpenRouterPricing `json:"pricing"` + TopProvider *OpenRouterTopProvider `json:"top_provider"` + SupportedParams []string `json:"supported_parameters"` +} + +// OpenRouterArchitecture describes model input/output modalities. +type OpenRouterArchitecture struct { + Modality string `json:"modality"` + InputModalities []string `json:"input_modalities"` + OutputModalities []string `json:"output_modalities"` +} + +// OpenRouterPricing holds per-token pricing (USD/token, string format). +type OpenRouterPricing struct { + Prompt string `json:"prompt"` + Completion string `json:"completion"` + InputCacheRead string `json:"input_cache_read"` + InputCacheWrite string `json:"input_cache_write"` +} + +// OpenRouterTopProvider holds provider-level limits. +type OpenRouterTopProvider struct { + ContextLength int `json:"context_length"` + MaxCompletionTokens int `json:"max_completion_tokens"` +} + +var openRouterCache = gcache.New() + +// FetchOpenRouterModels fetches and caches the OpenRouter model catalog. +func FetchOpenRouterModels(ctx context.Context) (map[string]*OpenRouterModelEntry, error) { + cached, err := openRouterCache.Get(ctx, openRouterCacheKey) + if err == nil && cached != nil { + if data, ok := cached.Val().(map[string]*OpenRouterModelEntry); ok { + return data, nil + } + } + + g.Log().Info(ctx, "[PricingSource] fetching OpenRouter model data from remote...") + + resp, err := g.Client().SetHeaderMap(map[string]string{ + "User-Agent": "github.com/qianfree/team-api/1.0", + }).Get(ctx, openRouterURL) + if err != nil { + return nil, fmt.Errorf("获取远程数据失败: %w", err) + } + defer resp.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("获取远程数据失败: HTTP %d", resp.StatusCode) + } + + body := resp.ReadAll() + if len(body) == 0 { + return nil, fmt.Errorf("获取远程数据失败: 响应为空") + } + + var raw struct { + Data []*OpenRouterModelEntry `json:"data"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("获取远程数据失败: JSON 解析失败: %w", err) + } + + // 同时以完整 ID 和裸模型名作为 key,方便匹配 + result := make(map[string]*OpenRouterModelEntry, len(raw.Data)*2) + for _, entry := range raw.Data { + if entry.ID == "" { + continue + } + result[entry.ID] = entry + if idx := strings.LastIndex(entry.ID, "/"); idx >= 0 { + bareName := entry.ID[idx+1:] + if _, exists := result[bareName]; !exists { + result[bareName] = entry + } + } + } + + if err := openRouterCache.Set(ctx, openRouterCacheKey, result, openRouterCacheTTL); err != nil { + g.Log().Warningf(ctx, "[PricingSource] failed to cache OpenRouter data: %v", err) + } + + g.Log().Infof(ctx, "[PricingSource] loaded %d models from OpenRouter", len(raw.Data)) + return result, nil +} + +// FindOpenRouterModel searches for a model in the OpenRouter data. +func FindOpenRouterModel(data map[string]*OpenRouterModelEntry, modelName string) (string, *OpenRouterModelEntry) { + if entry, ok := data[modelName]; ok { + return modelName, entry + } + + providers := []string{ + "openai/", "anthropic/", "google/", "meta-llama/", "mistralai/", + "deepseek/", "qwen/", "zhipu/", "moonshot/", "minimax/", + "baichuan/", "01-ai/", "stepfun/", "volcengine/", + } + for _, prefix := range providers { + if entry, ok := data[prefix+modelName]; ok { + return prefix + modelName, entry + } + } + + for _, prefix := range providers { + if strings.HasPrefix(modelName, prefix) { + stripped := strings.TrimPrefix(modelName, prefix) + if entry, ok := data[stripped]; ok { + return stripped, entry + } + } + } + + return "", nil +} + +// ParseOpenRouterCapabilities extracts capability map from OpenRouter model entry. +func ParseOpenRouterCapabilities(entry *OpenRouterModelEntry) map[string]bool { + caps := make(map[string]bool) + + paramSet := make(map[string]bool, len(entry.SupportedParams)) + for _, p := range entry.SupportedParams { + paramSet[p] = true + } + if paramSet["tools"] || paramSet["tool_choice"] { + caps["function_calling"] = true + caps["tool_choice"] = true + } + if paramSet["reasoning"] || paramSet["include_reasoning"] { + caps["reasoning"] = true + } + if paramSet["response_format"] || paramSet["structured_outputs"] { + caps["response_schema"] = true + } + + if entry.Architecture != nil { + for _, m := range entry.Architecture.InputModalities { + switch m { + case "image": + caps["vision"] = true + case "audio": + caps["audio_input"] = true + case "file": + caps["pdf_input"] = true + } + } + for _, m := range entry.Architecture.OutputModalities { + if m == "audio" { + caps["audio_output"] = true + } + } + } + + return caps +} + +// OpenRouterPricePerM converts an OpenRouter per-token price string to USD/1M tokens. +func OpenRouterPricePerM(perToken string) float64 { + if perToken == "" { + return 0 + } + v, err := strconv.ParseFloat(perToken, 64) + if err != nil { + return 0 + } + return v * 1_000_000 +}