diff --git a/api/admin/v1/model.go b/api/admin/v1/model.go index 6ace380..d233192 100644 --- a/api/admin/v1/model.go +++ b/api/admin/v1/model.go @@ -172,6 +172,7 @@ type PricingFetchOfficialRes struct { type OfficialPricingSource struct { Source string `json:"source"` // 数据来源("litellm" / "models.dev") Found bool `json:"found"` // 是否找到 + Error string `json:"error,omitempty"` // 获取远程数据失败时的错误信息 Provider string `json:"provider,omitempty"` // 供应商(anthropic/openai/...) Mode string `json:"mode,omitempty"` // 模型类型(chat/embedding/...) MaxContext int `json:"max_context_tokens,omitempty"` // 最大上下文 @@ -197,6 +198,7 @@ type ModelFetchOfficialInfoReq struct { // ModelFetchOfficialInfoRes 官方模型信息响应 type ModelFetchOfficialInfoRes struct { Found bool `json:"found"` + Error string `json:"error,omitempty"` // 获取远程数据失败时的错误信息 Provider string `json:"provider,omitempty"` MaxContextTokens int `json:"max_context_tokens,omitempty"` MaxOutputTokens int `json:"max_output_tokens,omitempty"` diff --git a/api/tenant/v1/models.go b/api/tenant/v1/models.go index 460e6db..92a6ab7 100644 --- a/api/tenant/v1/models.go +++ b/api/tenant/v1/models.go @@ -23,6 +23,7 @@ type TenantAvailableModelItem struct { MaxOutput int `json:"max_output_tokens"` Description string `json:"description"` Tags string `json:"tags"` + Capabilities string `json:"capabilities"` BillingMode *string `json:"billing_mode"` PerRequestPrice *float64 `json:"per_request_price"` DiscountRatio *float64 `json:"discount_ratio"` diff --git a/internal/logic/admin/model.go b/internal/logic/admin/model.go index c1baf87..11986db 100644 --- a/internal/logic/admin/model.go +++ b/internal/logic/admin/model.go @@ -457,6 +457,7 @@ func fetchLiteLLMSource(ctx context.Context, modelName string) *v1.OfficialPrici data, err := common.FetchLiteLLMPricing(ctx) if err != nil { g.Log().Warningf(ctx, "[FetchOfficialPricing] litellm fetch failed for %s: %v", modelName, err) + source.Error = err.Error() return source } @@ -498,6 +499,7 @@ func fetchModelsDevSource(ctx context.Context, modelName string) *v1.OfficialPri data, err := common.FetchModelsDevPricing(ctx) if err != nil { g.Log().Warningf(ctx, "[FetchOfficialPricing] models.dev fetch failed for %s: %v", modelName, err) + source.Error = err.Error() return source } @@ -538,7 +540,7 @@ func (s *sAdmin) FetchOfficialModelInfo(ctx context.Context, req *v1.ModelFetchO data, err := common.FetchLiteLLMPricing(ctx) if err != nil { g.Log().Warningf(ctx, "[FetchOfficialModelInfo] litellm fetch failed for %s: %v", req.ModelName, err) - return &v1.ModelFetchOfficialInfoRes{Found: false}, nil + return &v1.ModelFetchOfficialInfoRes{Found: false, Error: err.Error()}, nil } _, entry := common.FindLiteLLMModel(data, req.ModelName) diff --git a/internal/logic/common/pricing_source.go b/internal/logic/common/pricing_source.go index 1536d26..11bb820 100644 --- a/internal/logic/common/pricing_source.go +++ b/internal/logic/common/pricing_source.go @@ -65,22 +65,22 @@ func FetchLiteLLMPricing(ctx context.Context) (map[string]*LiteLLMModelEntry, er "User-Agent": "github.com/qianfree/team-api/1.0", }).Get(ctx, litellmPricingURL) if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) + return nil, fmt.Errorf("获取远程数据失败: %w", err) } defer resp.Close() if resp.StatusCode != 200 { - return nil, fmt.Errorf("remote returned status %d", resp.StatusCode) + return nil, fmt.Errorf("获取远程数据失败: HTTP %d", resp.StatusCode) } body := resp.ReadAll() if len(body) == 0 { - return nil, fmt.Errorf("empty response body") + return nil, fmt.Errorf("获取远程数据失败: 响应为空") } var rawEntries map[string]json.RawMessage if err := json.Unmarshal(body, &rawEntries); err != nil { - return nil, fmt.Errorf("JSON parse failed: %w", err) + return nil, fmt.Errorf("获取远程数据失败: JSON 解析失败: %w", err) } delete(rawEntries, "sample_spec") @@ -182,22 +182,22 @@ func FetchModelsDevPricing(ctx context.Context) (map[string]*ModelsDevModelEntry "User-Agent": "github.com/qianfree/team-api/1.0", }).Get(ctx, modelsDevURL) if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) + return nil, fmt.Errorf("获取远程数据失败: %w", err) } defer resp.Close() if resp.StatusCode != 200 { - return nil, fmt.Errorf("remote returned status %d", resp.StatusCode) + return nil, fmt.Errorf("获取远程数据失败: HTTP %d", resp.StatusCode) } body := resp.ReadAll() if len(body) == 0 { - return nil, fmt.Errorf("empty response body") + return nil, fmt.Errorf("获取远程数据失败: 响应为空") } var rawProviders map[string]ModelsDevProvider if err := json.Unmarshal(body, &rawProviders); err != nil { - return nil, fmt.Errorf("JSON parse failed: %w", err) + return nil, fmt.Errorf("获取远程数据失败: JSON 解析失败: %w", err) } // Build map: model_name → cheapest ModelsDevModelEntry across providers diff --git a/internal/logic/tenant/model.go b/internal/logic/tenant/model.go index 4d06ccd..2470b2a 100644 --- a/internal/logic/tenant/model.go +++ b/internal/logic/tenant/model.go @@ -33,6 +33,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab MaxOutputTokens int `json:"max_output_tokens"` Description string `json:"description"` Tags string `json:"tags"` + Capabilities string `json:"capabilities"` BillingMode *string `json:"billing_mode"` PerRequestPrice *float64 `json:"per_request_price"` DiscountRatio *float64 `json:"discount_ratio"` @@ -40,7 +41,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab } err := query. - Fields("mdl_tenant_models.id, m.model_id, m.model_name, m.category, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, mdl_tenant_models.billing_mode, mdl_tenant_models.per_request_price, mdl_tenant_models.discount_ratio, mdl_tenant_models.max_concurrency"). + Fields("mdl_tenant_models.id, m.model_id, m.model_name, m.category, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities, mdl_tenant_models.billing_mode, mdl_tenant_models.per_request_price, mdl_tenant_models.discount_ratio, mdl_tenant_models.max_concurrency"). OrderAsc("m.category"). OrderAsc("m.model_id"). Scan(&results) @@ -59,6 +60,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab MaxOutput: r.MaxOutputTokens, Description: r.Description, Tags: r.Tags, + Capabilities: r.Capabilities, BillingMode: r.BillingMode, PerRequestPrice: r.PerRequestPrice, DiscountRatio: r.DiscountRatio, diff --git a/web/admin/src/views/ai/ModelPricingPage.vue b/web/admin/src/views/ai/ModelPricingPage.vue index a7757ee..2a96c12 100644 --- a/web/admin/src/views/ai/ModelPricingPage.vue +++ b/web/admin/src/views/ai/ModelPricingPage.vue @@ -448,7 +448,8 @@ onMounted(() => { 应用此价格 diff --git a/web/admin/src/views/ai/ModelsPage.vue b/web/admin/src/views/ai/ModelsPage.vue index e899cd7..a04a189 100644 --- a/web/admin/src/views/ai/ModelsPage.vue +++ b/web/admin/src/views/ai/ModelsPage.vue @@ -88,7 +88,11 @@ async function fetchOfficialInfo() { const res: any = await request.get('/admin/models/official-info', { params: { model_name: modelName } }) const info = res.data?.data || res.data if (!info?.found) { - Message.warning('未找到该模型的官方数据') + if (info?.error) { + Message.warning(`获取远程数据失败: ${info.error}`) + } else { + Message.warning('未找到该模型的官方数据') + } return } if (info.max_context_tokens) form.max_context_tokens = info.max_context_tokens diff --git a/web/tenant/src/views/service/ModelsPage.vue b/web/tenant/src/views/service/ModelsPage.vue index 4ec3569..da7c9e6 100644 --- a/web/tenant/src/views/service/ModelsPage.vue +++ b/web/tenant/src/views/service/ModelsPage.vue @@ -12,12 +12,61 @@ interface ModelItem { max_output_tokens: number description: string tags: string + capabilities: string billing_mode: string | null per_request_price: number | null discount_ratio: number | null max_concurrency: number | null } +const capabilityLabel: Record = { + vision: '图片识别', + function_calling: '工具调用', + parallel_function_calling: '并行工具调用', + tool_choice: '工具选择', + response_schema: '结构化输出', + system_messages: '系统消息', + prompt_caching: '提示词缓存', + audio_input: '音频输入', + audio_output: '音频输出', + pdf_input: 'PDF 输入', + embedding_image: '图像嵌入', + reasoning: '深度思考', + web_search: '联网搜索', +} + +function parseCapabilities(raw: string): string[] { + if (!raw) return [] + try { + const obj = JSON.parse(raw) + return Object.entries(obj) + .filter(([, v]) => v === true) + .map(([k]) => k) + } catch { + return [] + } +} + +function parseTags(raw: string): string[] { + if (!raw) return [] + try { + const arr = JSON.parse(raw) + return Array.isArray(arr) ? arr : [] + } catch { + return [] + } +} + +const copiedId = ref('') + +function copyModelId(id: string) { + navigator.clipboard.writeText(id) + copiedId.value = id + setTimeout(() => { + if (copiedId.value === id) copiedId.value = '' + }, 1500) +} + const models = ref([]) const loading = ref(false) const searchQuery = ref('') @@ -40,12 +89,12 @@ const categoryLabel: Record = { rerank: '重排', } -const categoryBadgeClass: Record = { - chat: 'badge-primary', - embedding: 'badge-purple', - image: 'badge-warning', - audio: 'badge-success', - rerank: 'badge-gray', +const categoryColor: Record = { + chat: '#14b8a6', + embedding: '#8b5cf6', + image: '#f59e0b', + audio: '#10b981', + rerank: '#6b7280', } const filteredModels = computed(() => { @@ -105,7 +154,6 @@ onMounted(fetchModels)
-
- -
- +
@@ -174,39 +212,87 @@ onMounted(fetchModels)
-
-
+ +
+ + {{ '-' + ((1 - m.discount_ratio) * 100).toFixed(0) + '%' }} + +
+ + +
+
+ + {{ categoryLabel[m.category] || m.category }} +

{{ m.model_name || m.model_id }}

-

{{ m.model_id }}

- - {{ categoryLabel[m.category] || m.category }} - +
+

{{ m.model_id }}

+ +
-

{{ m.description }}

- -
- - - 上下文 {{ formatTokens(m.max_context_tokens) }} - - - - 输出 {{ formatTokens(m.max_output_tokens) }} - - - - 并发 {{ m.max_concurrency }} - + +
+

{{ m.description }}

+ 暂无描述
-
- - {{ (m.discount_ratio * 100).toFixed(0) }}% 折扣 - + +
+
+ + + {{ m.max_context_tokens ? formatTokens(m.max_context_tokens) : '-' }} + + + + {{ m.max_output_tokens ? formatTokens(m.max_output_tokens) : '-' }} + + + + {{ m.max_concurrency ?? '-' }} + +
+
+ + {{ tag }} + +
+
+ + +
+
+ + {{ capabilityLabel[cap] || cap }} + +