feat(model):模型能力显示
This commit is contained in:
1 parent
04042f58c9
commit
f26bb74bcf
8 files changed
+155
-57
No files matched your search
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user