feat(model):模型能力显示

This commit is contained in:
qian committed 2026-05-16 10:53:23 +08:00
1 parent 04042f58c9
commit f26bb74bcf
8 files changed
+155 -57

No files matched your search

+2
View File
@@ -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"`
+1
View File
@@ -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"`
+3 -1
View File
@@ -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)
+8 -8
View File
@@ -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
+3 -1
View File
@@ -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,
+2 -1
View File
@@ -448,7 +448,8 @@ onMounted(() => {
<AButton type="primary" size="small" class="mt-2" @click="applyOfficialPricing(src.pricing)">应用此价格</AButton>
</template>
<template v-else>
<div class="official-not-found">未找到定价数据</div>
<div v-if="src.error" class="official-not-found" style="color: rgb(var(--danger-6))">获取远程数据失败: {{ src.error }}</div>
<div v-else class="official-not-found">未找到定价数据</div>
</template>
</div>
</div>
+5 -1
View File
@@ -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
+131 -45
View File
@@ -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<string, string> = {
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<ModelItem[]>([])
const loading = ref(false)
const searchQuery = ref('')
@@ -40,12 +89,12 @@ const categoryLabel: Record<string, string> = {
rerank: '重排',
}
const categoryBadgeClass: Record<string, string> = {
chat: 'badge-primary',
embedding: 'badge-purple',
image: 'badge-warning',
audio: 'badge-success',
rerank: 'badge-gray',
const categoryColor: Record<string, string> = {
chat: '#14b8a6',
embedding: '#8b5cf6',
image: '#f59e0b',
audio: '#10b981',
rerank: '#6b7280',
}
const filteredModels = computed(() => {
@@ -105,7 +154,6 @@ onMounted(fetchModels)
<!-- Filters -->
<div class="card mb-6">
<div class="flex flex-col gap-4 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<!-- Category tabs -->
<div class="tabs">
<button
v-for="cat in categories"
@@ -115,10 +163,7 @@ onMounted(fetchModels)
@click="activeCategory = cat.value"
>
{{ cat.label }}
<span
v-if="cat.value"
class="ml-1 text-xs text-gray-400"
>
<span v-if="cat.value" class="ml-1 text-xs text-gray-400">
({{ models.filter((m) => m.category === cat.value).length }})
</span>
<span v-else class="ml-1 text-xs text-gray-400">
@@ -126,16 +171,9 @@ onMounted(fetchModels)
</span>
</button>
</div>
<!-- Search -->
<div class="relative w-full sm:w-64">
<Icon name="search" size="sm" class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
v-model="searchQuery"
type="text"
class="input pl-9"
placeholder="搜索模型..."
/>
<input v-model="searchQuery" type="text" class="input pl-9" placeholder="搜索模型..." />
</div>
</div>
</div>
@@ -174,39 +212,87 @@ onMounted(fetchModels)
<div
v-for="m in items"
:key="m.id"
class="card card-hover p-5"
class="card card-hover p-5 flex flex-col relative overflow-hidden"
>
<div class="flex items-start justify-between gap-2 mb-3">
<div class="min-w-0">
<!-- Discount ribbon (top-right corner) -->
<div
v-if="m.discount_ratio && m.discount_ratio < 1"
class="absolute top-3 -right-6 w-24 h-5 flex items-center justify-center"
style="transform: rotate(45deg); background: linear-gradient(135deg, #f43f5e, #fb923c); box-shadow: 0 2px 8px rgba(244,63,94,0.35);"
>
<span class="text-white font-bold text-[10px] tracking-wide">
{{ '-' + ((1 - m.discount_ratio) * 100).toFixed(0) + '%' }}
</span>
</div>
<!-- Section 1: Category + Name -->
<div class="mb-3" :class="{ 'pr-14': m.discount_ratio && m.discount_ratio < 1 }">
<div class="flex items-center gap-2">
<span
class="shrink-0 px-2.5 py-0.5 text-[10px] font-medium text-white rounded-full"
:style="{ background: categoryColor[m.category] || '#6b7280' }"
>
{{ categoryLabel[m.category] || m.category }}
</span>
<h3 class="text-sm font-semibold text-gray-900 truncate">{{ m.model_name || m.model_id }}</h3>
<p class="text-xs text-gray-400 font-mono truncate mt-0.5">{{ m.model_id }}</p>
</div>
<span class="badge shrink-0" :class="categoryBadgeClass[m.category] || 'badge-gray'">
{{ categoryLabel[m.category] || m.category }}
</span>
<div class="flex items-center gap-1 mt-0.5">
<p class="text-xs text-gray-400 font-mono truncate">{{ m.model_id }}</p>
<button
class="shrink-0 p-0.5 rounded text-gray-300 hover:text-primary-500 transition-colors duration-150"
title="复制模型编码"
@click="copyModelId(m.model_id)"
>
<Icon v-if="copiedId !== m.model_id" name="copy" size="xs" />
<Icon v-else name="check" size="xs" class="text-emerald-500" />
</button>
</div>
</div>
<p v-if="m.description" class="text-xs text-gray-500 mb-3 line-clamp-2">{{ m.description }}</p>
<div class="flex flex-wrap gap-3 text-xs text-gray-500">
<span v-if="m.max_context_tokens" class="flex items-center gap-1">
<Icon name="document" size="xs" class="text-gray-300" />
上下文 {{ formatTokens(m.max_context_tokens) }}
</span>
<span v-if="m.max_output_tokens" class="flex items-center gap-1">
<Icon name="arrowUp" size="xs" class="text-gray-300" />
输出 {{ formatTokens(m.max_output_tokens) }}
</span>
<span v-if="m.max_concurrency" class="flex items-center gap-1">
<Icon name="chart" size="xs" class="text-gray-300" />
并发 {{ m.max_concurrency }}
</span>
<!-- Section 2: Description (fixed height, always reserved) -->
<div class="mb-3 h-8">
<p v-if="m.description" class="text-xs text-gray-500 line-clamp-2">{{ m.description }}</p>
<span v-else class="text-xs text-gray-300">暂无描述</span>
</div>
<div v-if="m.discount_ratio && m.discount_ratio < 1" class="mt-3 pt-3 border-t border-gray-100">
<span class="badge badge-success">
{{ (m.discount_ratio * 100).toFixed(0) }}% 折扣
</span>
<!-- Section 3: Token info + Tags -->
<div class="flex items-center justify-between gap-2 text-xs text-gray-500 flex-1">
<div class="flex flex-wrap gap-3">
<span class="flex items-center gap-1">
<Icon name="document" size="xs" class="text-gray-300" />
{{ m.max_context_tokens ? formatTokens(m.max_context_tokens) : '-' }}
</span>
<span class="flex items-center gap-1">
<Icon name="arrowUp" size="xs" class="text-gray-300" />
{{ m.max_output_tokens ? formatTokens(m.max_output_tokens) : '-' }}
</span>
<span class="flex items-center gap-1">
<Icon name="chart" size="xs" class="text-gray-300" />
{{ m.max_concurrency ?? '-' }}
</span>
</div>
<div v-if="parseTags(m.tags).length" class="flex flex-wrap gap-1 justify-end">
<span
v-for="tag in parseTags(m.tags)"
:key="tag"
class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500"
>
{{ tag }}
</span>
</div>
</div>
<!-- Section 4: Capabilities (bottom, fixed height) -->
<div class="mt-3 pt-3 border-t border-gray-100 min-h-[28px]">
<div v-if="parseCapabilities(m.capabilities).length" class="flex flex-wrap gap-1">
<span
v-for="cap in parseCapabilities(m.capabilities)"
:key="cap"
class="inline-flex items-center rounded-md bg-primary-50 px-1.5 py-0.5 text-[10px] font-medium text-primary-600"
>
{{ capabilityLabel[cap] || cap }}
</span>
</div>
</div>
</div>
</div>