refactor(global-model): migrate model metadata to flexible config structure

将模型配置从多个固定字段(description, official_url, icon_url, default_supports_* 等)
统一为灵活的 config JSON 字段,提高扩展性。同时优化前端模型创建表单,支持从 models-dev
列表直接选择模型快速填充。

主要变更:
- 后端:模型表迁移,支持 config JSON 存储模型能力和元信息
- 前端:GlobalModelFormDialog 支持两种创建方式(列表选择/手动填写)
- API 类型更新,对齐新的数据结构
This commit is contained in:
fawney19
2025-12-16 12:21:21 +08:00
parent a94aeca2d3
commit 33265b4b13
26 changed files with 1230 additions and 645 deletions

View File

@@ -63,7 +63,7 @@ docker-compose up -d
# 4. 更新 # 4. 更新
docker-compose pull && docker-compose up -d docker-compose pull && docker-compose up -d
# 5. 数据库迁移(首次部署或更新后执行 # 5. 数据库迁移 - 更新后执行
./migrate.sh ./migrate.sh
``` ```

View File

@@ -0,0 +1,86 @@
"""refactor global_model to use config json field
Revision ID: 1cc6942cf06f
Revises: 180e63a9c83a
Create Date: 2025-12-16 03:11:32.480976+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '1cc6942cf06f'
down_revision = '180e63a9c83a'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""应用迁移:升级到新版本
1. 添加 config 列
2. 把旧数据迁移到 config
3. 删除旧列
"""
# 1. 添加 config 列(使用 JSONB 类型,支持索引和更高效的查询)
op.add_column('global_models', sa.Column('config', postgresql.JSONB(), nullable=True))
# 2. 迁移数据:把旧字段合并到 config JSON
# 注意:使用 COALESCE 为布尔字段设置默认值,避免数据丢失
# - streaming 默认 true大多数模型支持
# - 其他能力默认 false
# - jsonb_strip_nulls 只移除 null 字段,不影响 false 值
op.execute("""
UPDATE global_models
SET config = jsonb_strip_nulls(jsonb_build_object(
'streaming', COALESCE(default_supports_streaming, true),
'vision', CASE WHEN COALESCE(default_supports_vision, false) THEN true ELSE NULL END,
'function_calling', CASE WHEN COALESCE(default_supports_function_calling, false) THEN true ELSE NULL END,
'extended_thinking', CASE WHEN COALESCE(default_supports_extended_thinking, false) THEN true ELSE NULL END,
'image_generation', CASE WHEN COALESCE(default_supports_image_generation, false) THEN true ELSE NULL END,
'description', description,
'icon_url', icon_url,
'official_url', official_url
))
""")
# 3. 删除旧列
op.drop_column('global_models', 'default_supports_streaming')
op.drop_column('global_models', 'default_supports_vision')
op.drop_column('global_models', 'default_supports_function_calling')
op.drop_column('global_models', 'default_supports_extended_thinking')
op.drop_column('global_models', 'default_supports_image_generation')
op.drop_column('global_models', 'description')
op.drop_column('global_models', 'icon_url')
op.drop_column('global_models', 'official_url')
def downgrade() -> None:
"""回滚迁移:降级到旧版本"""
# 1. 添加旧列
op.add_column('global_models', sa.Column('icon_url', sa.VARCHAR(length=500), nullable=True))
op.add_column('global_models', sa.Column('official_url', sa.VARCHAR(length=500), nullable=True))
op.add_column('global_models', sa.Column('description', sa.TEXT(), nullable=True))
op.add_column('global_models', sa.Column('default_supports_streaming', sa.BOOLEAN(), nullable=True))
op.add_column('global_models', sa.Column('default_supports_vision', sa.BOOLEAN(), nullable=True))
op.add_column('global_models', sa.Column('default_supports_function_calling', sa.BOOLEAN(), nullable=True))
op.add_column('global_models', sa.Column('default_supports_extended_thinking', sa.BOOLEAN(), nullable=True))
op.add_column('global_models', sa.Column('default_supports_image_generation', sa.BOOLEAN(), nullable=True))
# 2. 从 config 恢复数据
op.execute("""
UPDATE global_models
SET
default_supports_streaming = COALESCE((config->>'streaming')::boolean, true),
default_supports_vision = COALESCE((config->>'vision')::boolean, false),
default_supports_function_calling = COALESCE((config->>'function_calling')::boolean, false),
default_supports_extended_thinking = COALESCE((config->>'extended_thinking')::boolean, false),
default_supports_image_generation = COALESCE((config->>'image_generation')::boolean, false),
description = config->>'description',
icon_url = config->>'icon_url',
official_url = config->>'official_url'
""")
# 3. 删除 config 列
op.drop_column('global_models', 'config')

View File

@@ -407,67 +407,45 @@ export interface TieredPricingConfig {
export interface GlobalModelCreate { export interface GlobalModelCreate {
name: string name: string
display_name: string display_name: string
description?: string
official_url?: string
icon_url?: string
// 按次计费配置(可选,与阶梯计费叠加) // 按次计费配置(可选,与阶梯计费叠加)
default_price_per_request?: number default_price_per_request?: number
// 阶梯计费配置(必填,固定价格用单阶梯表示) // 阶梯计费配置(必填,固定价格用单阶梯表示)
default_tiered_pricing: TieredPricingConfig default_tiered_pricing: TieredPricingConfig
// 默认能力配置
default_supports_vision?: boolean
default_supports_function_calling?: boolean
default_supports_streaming?: boolean
default_supports_extended_thinking?: boolean
default_supports_image_generation?: boolean
// Key 能力配置 - 模型支持的能力列表 // Key 能力配置 - 模型支持的能力列表
supported_capabilities?: string[] supported_capabilities?: string[]
// 模型配置JSON格式- 包含能力、规格、元信息等
config?: Record<string, any>
is_active?: boolean is_active?: boolean
} }
export interface GlobalModelUpdate { export interface GlobalModelUpdate {
display_name?: string display_name?: string
description?: string
official_url?: string
icon_url?: string
is_active?: boolean is_active?: boolean
// 按次计费配置 // 按次计费配置
default_price_per_request?: number | null // null 表示清空 default_price_per_request?: number | null // null 表示清空
// 阶梯计费配置 // 阶梯计费配置
default_tiered_pricing?: TieredPricingConfig default_tiered_pricing?: TieredPricingConfig
// 默认能力配置
default_supports_vision?: boolean
default_supports_function_calling?: boolean
default_supports_streaming?: boolean
default_supports_extended_thinking?: boolean
default_supports_image_generation?: boolean
// Key 能力配置 - 模型支持的能力列表 // Key 能力配置 - 模型支持的能力列表
supported_capabilities?: string[] | null supported_capabilities?: string[] | null
// 模型配置JSON格式- 包含能力、规格、元信息等
config?: Record<string, any> | null
} }
export interface GlobalModelResponse { export interface GlobalModelResponse {
id: string id: string
name: string name: string
display_name: string display_name: string
description?: string
official_url?: string
icon_url?: string
is_active: boolean is_active: boolean
// 按次计费配置 // 按次计费配置
default_price_per_request?: number default_price_per_request?: number
// 阶梯计费配置(必填) // 阶梯计费配置(必填)
default_tiered_pricing: TieredPricingConfig default_tiered_pricing: TieredPricingConfig
// 默认能力配置
default_supports_vision?: boolean
default_supports_function_calling?: boolean
default_supports_streaming?: boolean
default_supports_extended_thinking?: boolean
default_supports_image_generation?: boolean
// Key 能力配置 - 模型支持的能力列表 // Key 能力配置 - 模型支持的能力列表
supported_capabilities?: string[] | null supported_capabilities?: string[] | null
// 模型配置JSON格式
config?: Record<string, any> | null
// 统计数据 // 统计数据
provider_count?: number provider_count?: number
alias_count?: number
usage_count?: number usage_count?: number
created_at: string created_at: string
updated_at?: string updated_at?: string

View File

@@ -0,0 +1,244 @@
/**
* Models.dev API 服务
* 通过后端代理获取 models.dev 数据(解决跨域问题)
*/
import api from './client'
// 缓存配置
const CACHE_KEY = 'models_dev_cache'
const CACHE_DURATION = 15 * 60 * 1000 // 15 分钟
// Models.dev API 数据结构
export interface ModelsDevCost {
input?: number
output?: number
reasoning?: number
cache_read?: number
}
export interface ModelsDevLimit {
context?: number
output?: number
}
export interface ModelsDevModel {
id: string
name: string
family?: string
reasoning?: boolean
tool_call?: boolean
structured_output?: boolean
temperature?: boolean
attachment?: boolean
knowledge?: string
release_date?: string
last_updated?: string
input?: string[] // 输入模态: text, image, audio, video, pdf
output?: string[] // 输出模态: text, image, audio
open_weights?: boolean
cost?: ModelsDevCost
limit?: ModelsDevLimit
deprecated?: boolean
}
export interface ModelsDevProvider {
id: string
env?: string[]
npm?: string
api?: string
name: string
doc?: string
models: Record<string, ModelsDevModel>
}
export type ModelsDevData = Record<string, ModelsDevProvider>
// 扁平化的模型列表项(用于搜索和选择)
export interface ModelsDevModelItem {
providerId: string
providerName: string
modelId: string
modelName: string
family?: string
inputPrice?: number
outputPrice?: number
contextLimit?: number
outputLimit?: number
supportsVision?: boolean
supportsToolCall?: boolean
supportsReasoning?: boolean
deprecated?: boolean
// 用于 display_metadata 的额外字段
knowledgeCutoff?: string
releaseDate?: string
inputModalities?: string[]
outputModalities?: string[]
}
interface CacheData {
timestamp: number
data: ModelsDevData
}
// 内存缓存
let memoryCache: CacheData | null = null
/**
* 获取 models.dev 数据(带缓存)
*/
export async function getModelsDevData(): Promise<ModelsDevData> {
// 1. 检查内存缓存
if (memoryCache && Date.now() - memoryCache.timestamp < CACHE_DURATION) {
return memoryCache.data
}
// 2. 检查 localStorage 缓存
try {
const cached = localStorage.getItem(CACHE_KEY)
if (cached) {
const cacheData: CacheData = JSON.parse(cached)
if (Date.now() - cacheData.timestamp < CACHE_DURATION) {
memoryCache = cacheData
return cacheData.data
}
}
} catch {
// 缓存解析失败,忽略
}
// 3. 从后端代理获取新数据
const response = await api.get<ModelsDevData>('/api/admin/models/external')
const data = response.data
// 4. 更新缓存
const cacheData: CacheData = {
timestamp: Date.now(),
data,
}
memoryCache = cacheData
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData))
} catch {
// localStorage 写入失败,忽略
}
return data
}
/**
* 获取扁平化的模型列表
*/
export async function getModelsDevList(): Promise<ModelsDevModelItem[]> {
const data = await getModelsDevData()
const items: ModelsDevModelItem[] = []
for (const [providerId, provider] of Object.entries(data)) {
if (!provider.models) continue
for (const [modelId, model] of Object.entries(provider.models)) {
items.push({
providerId,
providerName: provider.name,
modelId,
modelName: model.name || modelId,
family: model.family,
inputPrice: model.cost?.input,
outputPrice: model.cost?.output,
contextLimit: model.limit?.context,
outputLimit: model.limit?.output,
supportsVision: model.input?.includes('image'),
supportsToolCall: model.tool_call,
supportsReasoning: model.reasoning,
deprecated: model.deprecated,
// display_metadata 相关字段
knowledgeCutoff: model.knowledge,
releaseDate: model.release_date,
inputModalities: model.input,
outputModalities: model.output,
})
}
}
// 按 provider 名称和模型名称排序
items.sort((a, b) => {
const providerCompare = a.providerName.localeCompare(b.providerName)
if (providerCompare !== 0) return providerCompare
return a.modelName.localeCompare(b.modelName)
})
return items
}
/**
* 搜索模型
*/
export async function searchModelsDevModels(
query: string,
options?: {
limit?: number
excludeDeprecated?: boolean
}
): Promise<ModelsDevModelItem[]> {
const allModels = await getModelsDevList()
const { limit = 50, excludeDeprecated = true } = options || {}
const queryLower = query.toLowerCase()
const filtered = allModels.filter((model) => {
if (excludeDeprecated && model.deprecated) return false
// 搜索模型 ID、名称、provider 名称、family
return (
model.modelId.toLowerCase().includes(queryLower) ||
model.modelName.toLowerCase().includes(queryLower) ||
model.providerName.toLowerCase().includes(queryLower) ||
model.family?.toLowerCase().includes(queryLower)
)
})
// 排序:精确匹配优先
filtered.sort((a, b) => {
const aExact =
a.modelId.toLowerCase() === queryLower ||
a.modelName.toLowerCase() === queryLower
const bExact =
b.modelId.toLowerCase() === queryLower ||
b.modelName.toLowerCase() === queryLower
if (aExact && !bExact) return -1
if (!aExact && bExact) return 1
return 0
})
return filtered.slice(0, limit)
}
/**
* 获取特定模型详情
*/
export async function getModelsDevModel(
providerId: string,
modelId: string
): Promise<ModelsDevModel | null> {
const data = await getModelsDevData()
return data[providerId]?.models?.[modelId] || null
}
/**
* 获取 provider logo URL
*/
export function getProviderLogoUrl(providerId: string): string {
return `https://models.dev/logos/${providerId}.svg`
}
/**
* 清除缓存
*/
export function clearModelsDevCache(): void {
memoryCache = null
try {
localStorage.removeItem(CACHE_KEY)
} catch {
// 忽略错误
}
}

View File

@@ -9,20 +9,14 @@ export interface PublicGlobalModel {
id: string id: string
name: string name: string
display_name: string | null display_name: string | null
description: string | null
icon_url: string | null
is_active: boolean is_active: boolean
// 阶梯计费配置 // 阶梯计费配置
default_tiered_pricing: TieredPricingConfig default_tiered_pricing: TieredPricingConfig
default_price_per_request: number | null // 按次计费价格 default_price_per_request: number | null // 按次计费价格
// 能力
default_supports_vision: boolean
default_supports_function_calling: boolean
default_supports_streaming: boolean
default_supports_extended_thinking: boolean
default_supports_image_generation: boolean
// Key 能力支持 // Key 能力支持
supported_capabilities: string[] | null supported_capabilities: string[] | null
// 模型配置JSON
config: Record<string, any> | null
} }
export interface PublicGlobalModelListResponse { export interface PublicGlobalModelListResponse {

View File

@@ -299,7 +299,7 @@ function formatDuration(ms: number): string {
const hours = Math.floor(ms / (1000 * 60 * 60)) const hours = Math.floor(ms / (1000 * 60 * 60))
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60)) const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60))
if (hours > 0) { if (hours > 0) {
return `${hours}h${minutes > 0 ? minutes + 'm' : ''}` return `${hours}h${minutes > 0 ? `${minutes}m` : ''}`
} }
return `${minutes}m` return `${minutes}m`
} }

View File

@@ -2,174 +2,258 @@
<Dialog <Dialog
:model-value="open" :model-value="open"
:title="isEditMode ? '编辑模型' : '创建统一模型'" :title="isEditMode ? '编辑模型' : '创建统一模型'"
:description="isEditMode ? '修改模型配置和价格信息' : '添加一个新的全局模型定义'" :description="isEditMode ? '修改模型配置和价格信息' : ''"
:icon="isEditMode ? SquarePen : Layers" :icon="isEditMode ? SquarePen : Layers"
size="xl" size="3xl"
@update:model-value="handleDialogUpdate" @update:model-value="handleDialogUpdate"
> >
<form <div
class="space-y-5 max-h-[70vh] overflow-y-auto pr-1" class="flex gap-4"
@submit.prevent="handleSubmit" :class="isEditMode ? '' : 'h-[500px]'"
> >
<!-- 基本信息 --> <!-- 左侧模型选择仅创建模式 -->
<section class="space-y-3"> <div
<h4 class="font-medium text-sm"> v-if="!isEditMode"
基本信息 class="w-[200px] shrink-0 flex flex-col h-full"
</h4>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label
for="model-name"
class="text-xs"
>模型名称 *</Label>
<Input
id="model-name"
v-model="form.name"
placeholder="claude-3-5-sonnet-20241022"
:disabled="isEditMode"
required
/>
<p
v-if="!isEditMode"
class="text-xs text-muted-foreground"
>
创建后不可修改
</p>
</div>
<div class="space-y-1.5">
<Label
for="model-display-name"
class="text-xs"
>显示名称 *</Label>
<Input
id="model-display-name"
v-model="form.display_name"
placeholder="Claude 3.5 Sonnet"
required
/>
</div>
</div>
<div class="space-y-1.5">
<Label
for="model-description"
class="text-xs"
>描述</Label>
<Input
id="model-description"
v-model="form.description"
placeholder="简短描述此模型的特点"
/>
</div>
</section>
<!-- 能力配置 -->
<section class="space-y-2">
<h4 class="font-medium text-sm">
默认能力
</h4>
<div class="flex flex-wrap gap-2">
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
<input
v-model="form.default_supports_streaming"
type="checkbox"
class="rounded"
>
<Zap class="w-3.5 h-3.5 text-muted-foreground" />
<span>流式输出</span>
</label>
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
<input
v-model="form.default_supports_vision"
type="checkbox"
class="rounded"
>
<Eye class="w-3.5 h-3.5 text-muted-foreground" />
<span>视觉理解</span>
</label>
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
<input
v-model="form.default_supports_function_calling"
type="checkbox"
class="rounded"
>
<Wrench class="w-3.5 h-3.5 text-muted-foreground" />
<span>工具调用</span>
</label>
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
<input
v-model="form.default_supports_extended_thinking"
type="checkbox"
class="rounded"
>
<Brain class="w-3.5 h-3.5 text-muted-foreground" />
<span>深度思考</span>
</label>
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
<input
v-model="form.default_supports_image_generation"
type="checkbox"
class="rounded"
>
<Image class="w-3.5 h-3.5 text-muted-foreground" />
<span>图像生成</span>
</label>
</div>
</section>
<!-- Key 能力配置 -->
<section
v-if="availableCapabilities.length > 0"
class="space-y-2"
> >
<h4 class="font-medium text-sm"> <!-- 搜索框 -->
Key 能力支持 <div class="relative mb-3">
</h4> <Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<div class="flex flex-wrap gap-2">
<label
v-for="cap in availableCapabilities"
:key="cap.name"
class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm"
>
<input
type="checkbox"
:checked="form.supported_capabilities?.includes(cap.name)"
class="rounded"
@change="toggleCapability(cap.name)"
>
<span>{{ cap.display_name }}</span>
</label>
</div>
</section>
<!-- 价格配置 -->
<section class="space-y-3">
<h4 class="font-medium text-sm">
价格配置
</h4>
<TieredPricingEditor
ref="tieredPricingEditorRef"
v-model="tieredPricing"
:show-cache1h="form.supported_capabilities?.includes('cache_1h')"
/>
<!-- 按次计费 -->
<div class="flex items-center gap-3 pt-2 border-t">
<Label class="text-xs whitespace-nowrap">按次计费 ($/)</Label>
<Input <Input
:model-value="form.default_price_per_request ?? ''" v-model="searchQuery"
type="number" type="text"
step="0.001" placeholder="搜索模型..."
min="0" class="pl-8 h-8 text-sm"
class="w-32"
placeholder="留空不启用"
@update:model-value="(v) => form.default_price_per_request = parseNumberInput(v, { allowFloat: true })"
/> />
<span class="text-xs text-muted-foreground">每次请求固定费用,可与 Token 计费叠加</span>
</div> </div>
</section>
</form> <!-- 模型列表两级结构 -->
<div class="flex-1 overflow-y-auto border rounded-lg min-h-0">
<div
v-if="loading"
class="flex items-center justify-center h-32"
>
<Loader2 class="w-5 h-5 animate-spin text-muted-foreground" />
</div>
<template v-else>
<!-- 提供商分组 -->
<div
v-for="group in groupedModels"
:key="group.providerId"
class="border-b last:border-b-0"
>
<!-- 提供商标题行 -->
<div
class="flex items-center gap-2 px-2.5 py-2 cursor-pointer hover:bg-muted text-sm"
@click="toggleProvider(group.providerId)"
>
<ChevronRight
class="w-3.5 h-3.5 text-muted-foreground transition-transform shrink-0"
:class="expandedProvider === group.providerId ? 'rotate-90' : ''"
/>
<img
:src="getProviderLogoUrl(group.providerId)"
:alt="group.providerName"
class="w-4 h-4 rounded shrink-0"
@error="handleLogoError"
>
<span class="truncate font-medium text-xs flex-1">{{ group.providerName }}</span>
<span class="text-[10px] text-muted-foreground shrink-0">{{ group.models.length }}</span>
</div>
<!-- 模型列表 -->
<div
v-if="expandedProvider === group.providerId"
class="bg-muted/30"
>
<div
v-for="model in group.models"
:key="model.modelId"
class="flex items-center gap-2 pl-7 pr-2.5 py-1.5 cursor-pointer text-xs border-t"
:class="selectedModel?.modelId === model.modelId && selectedModel?.providerId === model.providerId
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted'"
@click="selectModel(model)"
>
<span class="truncate">{{ model.modelName }}</span>
</div>
</div>
</div>
<div
v-if="groupedModels.length === 0"
class="text-center py-8 text-sm text-muted-foreground"
>
{{ searchQuery ? '未找到模型' : '加载中...' }}
</div>
</template>
</div>
</div>
<!-- 右侧表单 -->
<div
class="flex-1 overflow-y-auto h-full"
:class="isEditMode ? 'max-h-[70vh]' : ''"
>
<form
class="space-y-5"
@submit.prevent="handleSubmit"
>
<!-- 基本信息 -->
<section class="space-y-3">
<h4 class="font-medium text-sm">
基本信息
</h4>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label
for="model-name"
class="text-xs"
>模型名称 *</Label>
<Input
id="model-name"
v-model="form.name"
placeholder="claude-3-5-sonnet-20241022"
:disabled="isEditMode"
required
/>
</div>
<div class="space-y-1.5">
<Label
for="model-display-name"
class="text-xs"
>显示名称 *</Label>
<Input
id="model-display-name"
v-model="form.display_name"
placeholder="Claude 3.5 Sonnet"
required
/>
</div>
</div>
<div class="space-y-1.5">
<Label
for="model-description"
class="text-xs"
>描述</Label>
<Input
id="model-description"
:model-value="form.config?.description || ''"
placeholder="简短描述此模型的特点"
@update:model-value="(v) => setConfigField('description', v || undefined)"
/>
</div>
</section>
<!-- 能力配置 -->
<section class="space-y-2">
<h4 class="font-medium text-sm">
默认能力
</h4>
<div class="flex flex-wrap gap-2">
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input
type="checkbox"
:checked="form.config?.streaming !== false"
class="rounded"
@change="setConfigField('streaming', ($event.target as HTMLInputElement).checked)"
>
<Zap class="w-3.5 h-3.5 text-muted-foreground" />
<span>流式</span>
</label>
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input
type="checkbox"
:checked="form.config?.vision === true"
class="rounded"
@change="setConfigField('vision', ($event.target as HTMLInputElement).checked)"
>
<Eye class="w-3.5 h-3.5 text-muted-foreground" />
<span>视觉</span>
</label>
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input
type="checkbox"
:checked="form.config?.function_calling === true"
class="rounded"
@change="setConfigField('function_calling', ($event.target as HTMLInputElement).checked)"
>
<Wrench class="w-3.5 h-3.5 text-muted-foreground" />
<span>工具</span>
</label>
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input
type="checkbox"
:checked="form.config?.extended_thinking === true"
class="rounded"
@change="setConfigField('extended_thinking', ($event.target as HTMLInputElement).checked)"
>
<Brain class="w-3.5 h-3.5 text-muted-foreground" />
<span>思考</span>
</label>
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input
type="checkbox"
:checked="form.config?.image_generation === true"
class="rounded"
@change="setConfigField('image_generation', ($event.target as HTMLInputElement).checked)"
>
<Image class="w-3.5 h-3.5 text-muted-foreground" />
<span>生图</span>
</label>
</div>
</section>
<!-- Key 能力配置 -->
<section
v-if="availableCapabilities.length > 0"
class="space-y-2"
>
<h4 class="font-medium text-sm">
Key 能力支持
</h4>
<div class="flex flex-wrap gap-2">
<label
v-for="cap in availableCapabilities"
:key="cap.name"
class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm"
>
<input
type="checkbox"
:checked="form.supported_capabilities?.includes(cap.name)"
class="rounded"
@change="toggleCapability(cap.name)"
>
<span>{{ cap.display_name }}</span>
</label>
</div>
</section>
<!-- 价格配置 -->
<section class="space-y-3">
<h4 class="font-medium text-sm">
价格配置
</h4>
<TieredPricingEditor
ref="tieredPricingEditorRef"
v-model="tieredPricing"
:show-cache1h="form.supported_capabilities?.includes('cache_1h')"
/>
<div class="flex items-center gap-3 pt-2 border-t">
<Label class="text-xs whitespace-nowrap">按次计费</Label>
<Input
:model-value="form.default_price_per_request ?? ''"
type="number"
step="0.001"
min="0"
class="w-24"
placeholder="$/"
@update:model-value="(v) => form.default_price_per_request = parseNumberInput(v, { allowFloat: true })"
/>
<span class="text-xs text-muted-foreground">可与 Token 计费叠加</span>
</div>
</section>
</form>
</div>
</div>
<template #footer> <template #footer>
<Button <Button
@@ -180,7 +264,7 @@
取消 取消
</Button> </Button>
<Button <Button
:disabled="submitting" :disabled="submitting || !form.name || !form.display_name"
@click="handleSubmit" @click="handleSubmit"
> >
<Loader2 <Loader2
@@ -189,19 +273,35 @@
/> />
{{ isEditMode ? '保存' : '创建' }} {{ isEditMode ? '保存' : '创建' }}
</Button> </Button>
<Button
v-if="selectedModel && !isEditMode"
type="button"
variant="ghost"
@click="clearSelection"
>
清空
</Button>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { Eye, Wrench, Brain, Zap, Image, Loader2, Layers, SquarePen } from 'lucide-vue-next' import {
Eye, Wrench, Brain, Zap, Image, Loader2, Layers, SquarePen,
Search, ChevronRight
} from 'lucide-vue-next'
import { Dialog, Button, Input, Label } from '@/components/ui' import { Dialog, Button, Input, Label } from '@/components/ui'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog' import { useFormDialog } from '@/composables/useFormDialog'
import { parseNumberInput } from '@/utils/form' import { parseNumberInput } from '@/utils/form'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import TieredPricingEditor from './TieredPricingEditor.vue' import TieredPricingEditor from './TieredPricingEditor.vue'
import {
getModelsDevList,
getProviderLogoUrl,
type ModelsDevModelItem,
} from '@/api/models-dev'
import { import {
createGlobalModel, createGlobalModel,
updateGlobalModel, updateGlobalModel,
@@ -226,42 +326,138 @@ const { success, error: showError } = useToast()
const submitting = ref(false) const submitting = ref(false)
const tieredPricingEditorRef = ref<InstanceType<typeof TieredPricingEditor> | null>(null) const tieredPricingEditorRef = ref<InstanceType<typeof TieredPricingEditor> | null>(null)
// 阶梯计费配置(统一使用,固定价格就是单阶梯) // 模型列表相关
const loading = ref(false)
const searchQuery = ref('')
const allModels = ref<ModelsDevModelItem[]>([])
const selectedModel = ref<ModelsDevModelItem | null>(null)
const expandedProvider = ref<string | null>(null)
// 按提供商分组的模型
interface ProviderGroup {
providerId: string
providerName: string
models: ModelsDevModelItem[]
}
const groupedModels = computed(() => {
let models = allModels.value.filter(m => !m.deprecated)
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
models = models.filter(model =>
model.providerId.toLowerCase().includes(query) ||
model.providerName.toLowerCase().includes(query) ||
model.modelId.toLowerCase().includes(query) ||
model.modelName.toLowerCase().includes(query) ||
model.family?.toLowerCase().includes(query)
)
}
// 按提供商分组
const groups = new Map<string, ProviderGroup>()
for (const model of models) {
if (!groups.has(model.providerId)) {
groups.set(model.providerId, {
providerId: model.providerId,
providerName: model.providerName,
models: []
})
}
groups.get(model.providerId)!.models.push(model)
}
// 转换为数组并排序
let result = Array.from(groups.values())
// 如果有搜索词,把提供商名称/ID匹配的排在前面
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result.sort((a, b) => {
const aProviderMatch = a.providerId.toLowerCase().includes(query) || a.providerName.toLowerCase().includes(query)
const bProviderMatch = b.providerId.toLowerCase().includes(query) || b.providerName.toLowerCase().includes(query)
if (aProviderMatch && !bProviderMatch) return -1
if (!aProviderMatch && bProviderMatch) return 1
return a.providerName.localeCompare(b.providerName)
})
} else {
result.sort((a, b) => a.providerName.localeCompare(b.providerName))
}
return result
})
// 搜索时如果只有一个提供商,自动展开
watch(groupedModels, (groups) => {
if (searchQuery.value && groups.length === 1) {
expandedProvider.value = groups[0].providerId
}
})
// 切换提供商展开状态
function toggleProvider(providerId: string) {
expandedProvider.value = expandedProvider.value === providerId ? null : providerId
}
// 阶梯计费配置
const tieredPricing = ref<TieredPricingConfig | null>(null) const tieredPricing = ref<TieredPricingConfig | null>(null)
interface FormData { interface FormData {
name: string name: string
display_name: string display_name: string
description?: string
default_price_per_request?: number default_price_per_request?: number
default_supports_streaming?: boolean
default_supports_image_generation?: boolean
default_supports_vision?: boolean
default_supports_function_calling?: boolean
default_supports_extended_thinking?: boolean
supported_capabilities?: string[] supported_capabilities?: string[]
config?: Record<string, any>
is_active?: boolean is_active?: boolean
} }
const defaultForm = (): FormData => ({ const defaultForm = (): FormData => ({
name: '', name: '',
display_name: '', display_name: '',
description: '',
default_price_per_request: undefined, default_price_per_request: undefined,
default_supports_streaming: true,
default_supports_image_generation: false,
default_supports_vision: false,
default_supports_function_calling: false,
default_supports_extended_thinking: false,
supported_capabilities: [], supported_capabilities: [],
config: { streaming: true },
is_active: true, is_active: true,
}) })
const form = ref<FormData>(defaultForm()) const form = ref<FormData>(defaultForm())
const KEEP_FALSE_CONFIG_KEYS = new Set(['streaming'])
// 设置 config 字段
function setConfigField(key: string, value: any) {
if (!form.value.config) {
form.value.config = {}
}
if (value === undefined || value === '' || (value === false && !KEEP_FALSE_CONFIG_KEYS.has(key))) {
delete form.value.config[key]
} else {
form.value.config[key] = value
}
}
// Key 能力选项 // Key 能力选项
const availableCapabilities = ref<CapabilityDefinition[]>([]) const availableCapabilities = ref<CapabilityDefinition[]>([])
// 加载模型列表
async function loadModels() {
if (allModels.value.length > 0) return
loading.value = true
try {
allModels.value = await getModelsDevList()
} catch (err) {
log.error('Failed to load models:', err)
} finally {
loading.value = false
}
}
// 打开对话框时加载数据
watch(() => props.open, (isOpen) => {
if (isOpen && !props.model) {
loadModels()
}
})
// 加载可用能力列表 // 加载可用能力列表
async function loadCapabilities() { async function loadCapabilities() {
try { try {
@@ -284,15 +480,66 @@ function toggleCapability(capName: string) {
} }
} }
// 组件挂载时加载能力列表
onMounted(() => { onMounted(() => {
loadCapabilities() loadCapabilities()
}) })
// 选择模型并填充表单
function selectModel(model: ModelsDevModelItem) {
selectedModel.value = model
expandedProvider.value = model.providerId
form.value.name = model.modelId
form.value.display_name = model.modelName
// 构建 config
const config: Record<string, any> = {
streaming: true,
}
if (model.supportsVision) config.vision = true
if (model.supportsToolCall) config.function_calling = true
if (model.supportsReasoning) config.extended_thinking = true
if (model.contextLimit) config.context_limit = model.contextLimit
if (model.outputLimit) config.output_limit = model.outputLimit
if (model.knowledgeCutoff) config.knowledge_cutoff = model.knowledgeCutoff
if (model.family) config.family = model.family
if (model.releaseDate) config.release_date = model.releaseDate
if (model.inputModalities?.length) config.input_modalities = model.inputModalities
if (model.outputModalities?.length) config.output_modalities = model.outputModalities
form.value.config = config
if (model.inputPrice !== undefined || model.outputPrice !== undefined) {
tieredPricing.value = {
tiers: [{
up_to: null,
input_price_per_1m: model.inputPrice || 0,
output_price_per_1m: model.outputPrice || 0,
}]
}
} else {
tieredPricing.value = null
}
}
// 清除选择(手动填写)
function clearSelection() {
selectedModel.value = null
form.value = defaultForm()
tieredPricing.value = null
}
// Logo 加载失败处理
function handleLogoError(event: Event) {
const img = event.target as HTMLImageElement
img.style.display = 'none'
}
// 重置表单 // 重置表单
function resetForm() { function resetForm() {
form.value = defaultForm() form.value = defaultForm()
tieredPricing.value = null tieredPricing.value = null
searchQuery.value = ''
selectedModel.value = null
expandedProvider.value = null
} }
// 加载模型数据(编辑模式) // 加载模型数据(编辑模式)
@@ -301,18 +548,11 @@ function loadModelData() {
form.value = { form.value = {
name: props.model.name, name: props.model.name,
display_name: props.model.display_name, display_name: props.model.display_name,
description: props.model.description,
default_price_per_request: props.model.default_price_per_request, default_price_per_request: props.model.default_price_per_request,
default_supports_streaming: props.model.default_supports_streaming,
default_supports_image_generation: props.model.default_supports_image_generation,
default_supports_vision: props.model.default_supports_vision,
default_supports_function_calling: props.model.default_supports_function_calling,
default_supports_extended_thinking: props.model.default_supports_extended_thinking,
supported_capabilities: [...(props.model.supported_capabilities || [])], supported_capabilities: [...(props.model.supported_capabilities || [])],
config: props.model.config ? { ...props.model.config } : { streaming: true },
is_active: props.model.is_active, is_active: props.model.is_active,
} }
// 加载阶梯计费配置(深拷贝)
if (props.model.default_tiered_pricing) { if (props.model.default_tiered_pricing) {
tieredPricing.value = JSON.parse(JSON.stringify(props.model.default_tiered_pricing)) tieredPricing.value = JSON.parse(JSON.stringify(props.model.default_tiered_pricing))
} }
@@ -339,24 +579,22 @@ async function handleSubmit() {
return return
} }
// 获取包含自动计算缓存价格的最终数据
const finalTiers = tieredPricingEditorRef.value?.getFinalTiers() const finalTiers = tieredPricingEditorRef.value?.getFinalTiers()
const finalTieredPricing = finalTiers ? { tiers: finalTiers } : tieredPricing.value const finalTieredPricing = finalTiers ? { tiers: finalTiers } : tieredPricing.value
// 清理空的 config
const cleanConfig = form.value.config && Object.keys(form.value.config).length > 0
? form.value.config
: undefined
submitting.value = true submitting.value = true
try { try {
if (isEditMode.value && props.model) { if (isEditMode.value && props.model) {
const updateData: GlobalModelUpdate = { const updateData: GlobalModelUpdate = {
display_name: form.value.display_name, display_name: form.value.display_name,
description: form.value.description, config: cleanConfig || null,
// 使用 null 而不是 undefined 来显式清空字段
default_price_per_request: form.value.default_price_per_request ?? null, default_price_per_request: form.value.default_price_per_request ?? null,
default_tiered_pricing: finalTieredPricing, default_tiered_pricing: finalTieredPricing,
default_supports_streaming: form.value.default_supports_streaming,
default_supports_image_generation: form.value.default_supports_image_generation,
default_supports_vision: form.value.default_supports_vision,
default_supports_function_calling: form.value.default_supports_function_calling,
default_supports_extended_thinking: form.value.default_supports_extended_thinking,
supported_capabilities: form.value.supported_capabilities?.length ? form.value.supported_capabilities : null, supported_capabilities: form.value.supported_capabilities?.length ? form.value.supported_capabilities : null,
is_active: form.value.is_active, is_active: form.value.is_active,
} }
@@ -366,14 +604,9 @@ async function handleSubmit() {
const createData: GlobalModelCreate = { const createData: GlobalModelCreate = {
name: form.value.name!, name: form.value.name!,
display_name: form.value.display_name!, display_name: form.value.display_name!,
description: form.value.description, config: cleanConfig,
default_price_per_request: form.value.default_price_per_request || undefined, default_price_per_request: form.value.default_price_per_request ?? undefined,
default_tiered_pricing: finalTieredPricing, default_tiered_pricing: finalTieredPricing,
default_supports_streaming: form.value.default_supports_streaming,
default_supports_image_generation: form.value.default_supports_image_generation,
default_supports_vision: form.value.default_supports_vision,
default_supports_function_calling: form.value.default_supports_function_calling,
default_supports_extended_thinking: form.value.default_supports_extended_thinking,
supported_capabilities: form.value.supported_capabilities?.length ? form.value.supported_capabilities : undefined, supported_capabilities: form.value.supported_capabilities?.length ? form.value.supported_capabilities : undefined,
is_active: form.value.is_active, is_active: form.value.is_active,
} }

View File

@@ -38,12 +38,12 @@
> >
<Copy class="w-3 h-3" /> <Copy class="w-3 h-3" />
</button> </button>
<template v-if="model.description"> <template v-if="model.config?.description">
<span class="shrink-0">·</span> <span class="shrink-0">·</span>
<span <span
class="text-xs truncate" class="text-xs truncate"
:title="model.description" :title="model.config?.description"
>{{ model.description }}</span> >{{ model.config?.description }}</span>
</template> </template>
</div> </div>
</div> </div>
@@ -143,10 +143,10 @@
</p> </p>
</div> </div>
<Badge <Badge
:variant="model.default_supports_streaming ?? false ? 'default' : 'secondary'" :variant="model.config?.streaming !== false ? 'default' : 'secondary'"
class="text-xs" class="text-xs"
> >
{{ model.default_supports_streaming ?? false ? '支持' : '不支持' }} {{ model.config?.streaming !== false ? '支持' : '不支持' }}
</Badge> </Badge>
</div> </div>
<div class="flex items-center gap-2 p-3 rounded-lg border"> <div class="flex items-center gap-2 p-3 rounded-lg border">
@@ -160,10 +160,10 @@
</p> </p>
</div> </div>
<Badge <Badge
:variant="model.default_supports_image_generation ?? false ? 'default' : 'secondary'" :variant="model.config?.image_generation === true ? 'default' : 'secondary'"
class="text-xs" class="text-xs"
> >
{{ model.default_supports_image_generation ?? false ? '支持' : '不支持' }} {{ model.config?.image_generation === true ? '支持' : '不支持' }}
</Badge> </Badge>
</div> </div>
<div class="flex items-center gap-2 p-3 rounded-lg border"> <div class="flex items-center gap-2 p-3 rounded-lg border">
@@ -177,10 +177,10 @@
</p> </p>
</div> </div>
<Badge <Badge
:variant="model.default_supports_vision ?? false ? 'default' : 'secondary'" :variant="model.config?.vision === true ? 'default' : 'secondary'"
class="text-xs" class="text-xs"
> >
{{ model.default_supports_vision ?? false ? '支持' : '不支持' }} {{ model.config?.vision === true ? '支持' : '不支持' }}
</Badge> </Badge>
</div> </div>
<div class="flex items-center gap-2 p-3 rounded-lg border"> <div class="flex items-center gap-2 p-3 rounded-lg border">
@@ -194,10 +194,10 @@
</p> </p>
</div> </div>
<Badge <Badge
:variant="model.default_supports_function_calling ?? false ? 'default' : 'secondary'" :variant="model.config?.function_calling === true ? 'default' : 'secondary'"
class="text-xs" class="text-xs"
> >
{{ model.default_supports_function_calling ?? false ? '支持' : '不支持' }} {{ model.config?.function_calling === true ? '支持' : '不支持' }}
</Badge> </Badge>
</div> </div>
<div class="flex items-center gap-2 p-3 rounded-lg border"> <div class="flex items-center gap-2 p-3 rounded-lg border">
@@ -211,10 +211,10 @@
</p> </p>
</div> </div>
<Badge <Badge
:variant="model.default_supports_extended_thinking ?? false ? 'default' : 'secondary'" :variant="model.config?.extended_thinking === true ? 'default' : 'secondary'"
class="text-xs" class="text-xs"
> >
{{ model.default_supports_extended_thinking ?? false ? '支持' : '不支持' }} {{ model.config?.extended_thinking === true ? '支持' : '不支持' }}
</Badge> </Badge>
</div> </div>
</div> </div>
@@ -396,11 +396,11 @@
</div> </div>
<div class="p-3 rounded-lg border bg-muted/20"> <div class="p-3 rounded-lg border bg-muted/20">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">别名数量</Label> <Label class="text-xs text-muted-foreground">调用次数</Label>
<Tag class="w-4 h-4 text-muted-foreground" /> <BarChart3 class="w-4 h-4 text-muted-foreground" />
</div> </div>
<p class="text-2xl font-bold mt-1"> <p class="text-2xl font-bold mt-1">
{{ model.alias_count || 0 }} {{ model.usage_count || 0 }}
</p> </p>
</div> </div>
</div> </div>
@@ -455,105 +455,153 @@
<template v-else-if="providers.length > 0"> <template v-else-if="providers.length > 0">
<!-- 桌面端表格 --> <!-- 桌面端表格 -->
<Table class="hidden sm:table"> <Table class="hidden sm:table">
<TableHeader> <TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent"> <TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-10 font-semibold"> <TableHead class="h-10 font-semibold">
Provider Provider
</TableHead> </TableHead>
<TableHead class="w-[120px] h-10 font-semibold"> <TableHead class="w-[120px] h-10 font-semibold">
能力 能力
</TableHead> </TableHead>
<TableHead class="w-[180px] h-10 font-semibold"> <TableHead class="w-[180px] h-10 font-semibold">
价格 ($/M) 价格 ($/M)
</TableHead> </TableHead>
<TableHead class="w-[80px] h-10 font-semibold text-center"> <TableHead class="w-[80px] h-10 font-semibold text-center">
操作 操作
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow <TableRow
v-for="provider in providers"
:key="provider.id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
>
<TableCell class="py-3">
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full shrink-0"
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
:title="provider.is_active ? '活跃' : '停用'"
/>
<span class="font-medium truncate">{{ provider.display_name }}</span>
</div>
</TableCell>
<TableCell class="py-3">
<div class="flex gap-0.5">
<Zap
v-if="provider.supports_streaming"
class="w-3.5 h-3.5 text-muted-foreground"
title="流式输出"
/>
<Eye
v-if="provider.supports_vision"
class="w-3.5 h-3.5 text-muted-foreground"
title="视觉理解"
/>
<Wrench
v-if="provider.supports_function_calling"
class="w-3.5 h-3.5 text-muted-foreground"
title="工具调用"
/>
</div>
</TableCell>
<TableCell class="py-3">
<div class="text-xs font-mono space-y-0.5">
<!-- Token 计费输入/输出 -->
<div v-if="(provider.input_price_per_1m || 0) > 0 || (provider.output_price_per_1m || 0) > 0">
<span class="text-muted-foreground">输入/输出:</span>
<span class="ml-1">${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}</span>
<!-- 阶梯标记 -->
<span
v-if="(provider.tier_count || 1) > 1"
class="ml-1 text-muted-foreground"
title="阶梯计费"
>[阶梯]</span>
</div>
<!-- 缓存价格 -->
<div
v-if="(provider.cache_creation_price_per_1m || 0) > 0 || (provider.cache_read_price_per_1m || 0) > 0"
class="text-muted-foreground"
>
<span>缓存:</span>
<span class="ml-1">${{ (provider.cache_creation_price_per_1m || 0).toFixed(2) }}/${{ (provider.cache_read_price_per_1m || 0).toFixed(2) }}</span>
</div>
<!-- 1h 缓存价格 -->
<div
v-if="(provider.cache_1h_creation_price_per_1m || 0) > 0"
class="text-muted-foreground"
>
<span>1h 缓存:</span>
<span class="ml-1">${{ (provider.cache_1h_creation_price_per_1m || 0).toFixed(2) }}</span>
</div>
<!-- 按次计费 -->
<div v-if="(provider.price_per_request || 0) > 0">
<span class="text-muted-foreground">按次:</span>
<span class="ml-1">${{ (provider.price_per_request || 0).toFixed(3) }}/</span>
</div>
<!-- 无定价 -->
<span
v-if="!(provider.input_price_per_1m || 0) && !(provider.output_price_per_1m || 0) && !(provider.price_per_request || 0)"
class="text-muted-foreground"
>-</span>
</div>
</TableCell>
<TableCell class="py-3 text-center">
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="编辑此关联"
@click="$emit('editProvider', provider)"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:title="provider.is_active ? '停用此关联' : '启用此关联'"
@click="$emit('toggleProviderStatus', provider)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="删除此关联"
@click="$emit('deleteProvider', provider)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- 移动端卡片列表 -->
<div class="sm:hidden divide-y divide-border/40">
<div
v-for="provider in providers" v-for="provider in providers"
:key="provider.id" :key="provider.id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors" class="p-4 space-y-3"
> >
<TableCell class="py-3"> <div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 min-w-0">
<span <span
class="w-2 h-2 rounded-full shrink-0" class="w-2 h-2 rounded-full shrink-0"
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'" :class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
:title="provider.is_active ? '活跃' : '停用'"
/> />
<span class="font-medium truncate">{{ provider.display_name }}</span> <span class="font-medium truncate">{{ provider.display_name }}</span>
</div> </div>
</TableCell> <div class="flex items-center gap-1 shrink-0">
<TableCell class="py-3">
<div class="flex gap-0.5">
<Zap
v-if="provider.supports_streaming"
class="w-3.5 h-3.5 text-muted-foreground"
title="流式输出"
/>
<Eye
v-if="provider.supports_vision"
class="w-3.5 h-3.5 text-muted-foreground"
title="视觉理解"
/>
<Wrench
v-if="provider.supports_function_calling"
class="w-3.5 h-3.5 text-muted-foreground"
title="工具调用"
/>
</div>
</TableCell>
<TableCell class="py-3">
<div class="text-xs font-mono space-y-0.5">
<!-- Token 计费输入/输出 -->
<div v-if="(provider.input_price_per_1m || 0) > 0 || (provider.output_price_per_1m || 0) > 0">
<span class="text-muted-foreground">输入/输出:</span>
<span class="ml-1">${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}</span>
<!-- 阶梯标记 -->
<span
v-if="(provider.tier_count || 1) > 1"
class="ml-1 text-muted-foreground"
title="阶梯计费"
>[阶梯]</span>
</div>
<!-- 缓存价格 -->
<div
v-if="(provider.cache_creation_price_per_1m || 0) > 0 || (provider.cache_read_price_per_1m || 0) > 0"
class="text-muted-foreground"
>
<span>缓存:</span>
<span class="ml-1">${{ (provider.cache_creation_price_per_1m || 0).toFixed(2) }}/${{ (provider.cache_read_price_per_1m || 0).toFixed(2) }}</span>
</div>
<!-- 1h 缓存价格 -->
<div
v-if="(provider.cache_1h_creation_price_per_1m || 0) > 0"
class="text-muted-foreground"
>
<span>1h 缓存:</span>
<span class="ml-1">${{ (provider.cache_1h_creation_price_per_1m || 0).toFixed(2) }}</span>
</div>
<!-- 按次计费 -->
<div v-if="(provider.price_per_request || 0) > 0">
<span class="text-muted-foreground">按次:</span>
<span class="ml-1">${{ (provider.price_per_request || 0).toFixed(3) }}/</span>
</div>
<!-- 无定价 -->
<span
v-if="!(provider.input_price_per_1m || 0) && !(provider.output_price_per_1m || 0) && !(provider.price_per_request || 0)"
class="text-muted-foreground"
>-</span>
</div>
</TableCell>
<TableCell class="py-3 text-center">
<div class="flex items-center justify-center gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
title="编辑此关联"
@click="$emit('editProvider', provider)" @click="$emit('editProvider', provider)"
> >
<Edit class="w-3.5 h-3.5" /> <Edit class="w-3.5 h-3.5" />
@@ -562,7 +610,6 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
:title="provider.is_active ? '停用此关联' : '启用此关联'"
@click="$emit('toggleProviderStatus', provider)" @click="$emit('toggleProviderStatus', provider)"
> >
<Power class="w-3.5 h-3.5" /> <Power class="w-3.5 h-3.5" />
@@ -571,82 +618,35 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
title="删除此关联"
@click="$emit('deleteProvider', provider)" @click="$emit('deleteProvider', provider)"
> >
<Trash2 class="w-3.5 h-3.5" /> <Trash2 class="w-3.5 h-3.5" />
</Button> </Button>
</div> </div>
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- 移动端卡片列表 -->
<div class="sm:hidden divide-y divide-border/40">
<div
v-for="provider in providers"
:key="provider.id"
class="p-4 space-y-3"
>
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<span
class="w-2 h-2 rounded-full shrink-0"
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
/>
<span class="font-medium truncate">{{ provider.display_name }}</span>
</div> </div>
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-3 text-xs">
<Button <div class="flex gap-1">
variant="ghost" <Zap
size="icon" v-if="provider.supports_streaming"
class="h-7 w-7" class="w-3.5 h-3.5 text-muted-foreground"
@click="$emit('editProvider', provider)" />
<Eye
v-if="provider.supports_vision"
class="w-3.5 h-3.5 text-muted-foreground"
/>
<Wrench
v-if="provider.supports_function_calling"
class="w-3.5 h-3.5 text-muted-foreground"
/>
</div>
<div
v-if="(provider.input_price_per_1m || 0) > 0 || (provider.output_price_per_1m || 0) > 0"
class="text-muted-foreground font-mono"
> >
<Edit class="w-3.5 h-3.5" /> ${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}
</Button> </div>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('toggleProviderStatus', provider)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('deleteProvider', provider)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div> </div>
</div> </div>
<div class="flex items-center gap-3 text-xs">
<div class="flex gap-1">
<Zap
v-if="provider.supports_streaming"
class="w-3.5 h-3.5 text-muted-foreground"
/>
<Eye
v-if="provider.supports_vision"
class="w-3.5 h-3.5 text-muted-foreground"
/>
<Wrench
v-if="provider.supports_function_calling"
class="w-3.5 h-3.5 text-muted-foreground"
/>
</div>
<div
v-if="(provider.input_price_per_1m || 0) > 0 || (provider.output_price_per_1m || 0) > 0"
class="text-muted-foreground font-mono"
>
${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}
</div>
</div>
</div>
</div> </div>
</template> </template>
@@ -695,7 +695,8 @@ import {
Loader2, Loader2,
RefreshCw, RefreshCw,
Copy, Copy,
Layers Layers,
BarChart3
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import Card from '@/components/ui/card.vue' import Card from '@/components/ui/card.vue'

View File

@@ -117,8 +117,12 @@
class="text-center py-6 text-muted-foreground border rounded-lg border-dashed" class="text-center py-6 text-muted-foreground border rounded-lg border-dashed"
> >
<Tag class="w-8 h-8 mx-auto mb-2 opacity-50" /> <Tag class="w-8 h-8 mx-auto mb-2 opacity-50" />
<p class="text-sm">未配置映射</p> <p class="text-sm">
<p class="text-xs mt-1">将只使用主模型名称</p> 未配置映射
</p>
<p class="text-xs mt-1">
将只使用主模型名称
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -611,41 +611,42 @@ export const MOCK_GLOBAL_MODELS: GlobalModelResponse[] = [
id: 'gm-001', id: 'gm-001',
name: 'claude-haiku-4-5-20251001', name: 'claude-haiku-4-5-20251001',
display_name: 'claude-haiku-4-5', display_name: 'claude-haiku-4-5',
description: 'Anthropic 最快速的 Claude 4 系列模型',
is_active: true, is_active: true,
default_tiered_pricing: { default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 1.00, output_price_per_1m: 5.00, cache_creation_price_per_1m: 1.25, cache_read_price_per_1m: 0.1 }] tiers: [{ up_to: null, input_price_per_1m: 1.00, output_price_per_1m: 5.00, cache_creation_price_per_1m: 1.25, cache_read_price_per_1m: 0.1 }]
}, },
default_supports_vision: true, config: {
default_supports_function_calling: true, streaming: true,
default_supports_streaming: true, vision: true,
default_supports_extended_thinking: true, function_calling: true,
extended_thinking: true,
description: 'Anthropic 最快速的 Claude 4 系列模型'
},
provider_count: 3, provider_count: 3,
alias_count: 2,
created_at: '2024-01-01T00:00:00Z' created_at: '2024-01-01T00:00:00Z'
}, },
{ {
id: 'gm-002', id: 'gm-002',
name: 'claude-opus-4-5-20251101', name: 'claude-opus-4-5-20251101',
display_name: 'claude-opus-4-5', display_name: 'claude-opus-4-5',
description: 'Anthropic 最强大的模型',
is_active: true, is_active: true,
default_tiered_pricing: { default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 5.00, output_price_per_1m: 25.00, cache_creation_price_per_1m: 6.25, cache_read_price_per_1m: 0.5 }] tiers: [{ up_to: null, input_price_per_1m: 5.00, output_price_per_1m: 25.00, cache_creation_price_per_1m: 6.25, cache_read_price_per_1m: 0.5 }]
}, },
default_supports_vision: true, config: {
default_supports_function_calling: true, streaming: true,
default_supports_streaming: true, vision: true,
default_supports_extended_thinking: true, function_calling: true,
extended_thinking: true,
description: 'Anthropic 最强大的模型'
},
provider_count: 2, provider_count: 2,
alias_count: 1,
created_at: '2024-01-01T00:00:00Z' created_at: '2024-01-01T00:00:00Z'
}, },
{ {
id: 'gm-003', id: 'gm-003',
name: 'claude-sonnet-4-5-20250929', name: 'claude-sonnet-4-5-20250929',
display_name: 'claude-sonnet-4-5', display_name: 'claude-sonnet-4-5',
description: 'Anthropic 平衡型模型,支持 1h 缓存和 CLI 1M 上下文',
is_active: true, is_active: true,
default_tiered_pricing: { default_tiered_pricing: {
tiers: [ tiers: [
@@ -677,116 +678,124 @@ export const MOCK_GLOBAL_MODELS: GlobalModelResponse[] = [
} }
] ]
}, },
default_supports_vision: true, config: {
default_supports_function_calling: true, streaming: true,
default_supports_streaming: true, vision: true,
default_supports_extended_thinking: true, function_calling: true,
extended_thinking: true,
description: 'Anthropic 平衡型模型,支持 1h 缓存和 CLI 1M 上下文'
},
supported_capabilities: ['cache_1h', 'cli_1m'], supported_capabilities: ['cache_1h', 'cli_1m'],
provider_count: 3, provider_count: 3,
alias_count: 2,
created_at: '2024-01-01T00:00:00Z' created_at: '2024-01-01T00:00:00Z'
}, },
{ {
id: 'gm-004', id: 'gm-004',
name: 'gemini-3-pro-image-preview', name: 'gemini-3-pro-image-preview',
display_name: 'gemini-3-pro-image-preview', display_name: 'gemini-3-pro-image-preview',
description: 'Google Gemini 3 Pro 图像生成预览版',
is_active: true, is_active: true,
default_price_per_request: 0.300, default_price_per_request: 0.300,
default_tiered_pricing: { default_tiered_pricing: {
tiers: [] tiers: []
}, },
default_supports_vision: true, config: {
default_supports_function_calling: false, streaming: true,
default_supports_streaming: true, vision: true,
default_supports_image_generation: true, function_calling: false,
image_generation: true,
description: 'Google Gemini 3 Pro 图像生成预览版'
},
provider_count: 1, provider_count: 1,
alias_count: 0,
created_at: '2024-01-01T00:00:00Z' created_at: '2024-01-01T00:00:00Z'
}, },
{ {
id: 'gm-005', id: 'gm-005',
name: 'gemini-3-pro-preview', name: 'gemini-3-pro-preview',
display_name: 'gemini-3-pro-preview', display_name: 'gemini-3-pro-preview',
description: 'Google Gemini 3 Pro 预览版',
is_active: true, is_active: true,
default_tiered_pricing: { default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 2.00, output_price_per_1m: 12.00 }] tiers: [{ up_to: null, input_price_per_1m: 2.00, output_price_per_1m: 12.00 }]
}, },
default_supports_vision: true, config: {
default_supports_function_calling: true, streaming: true,
default_supports_streaming: true, vision: true,
default_supports_extended_thinking: true, function_calling: true,
extended_thinking: true,
description: 'Google Gemini 3 Pro 预览版'
},
provider_count: 1, provider_count: 1,
alias_count: 0,
created_at: '2024-01-01T00:00:00Z' created_at: '2024-01-01T00:00:00Z'
}, },
{ {
id: 'gm-006', id: 'gm-006',
name: 'gpt-5.1', name: 'gpt-5.1',
display_name: 'gpt-5.1', display_name: 'gpt-5.1',
description: 'OpenAI GPT-5.1 模型',
is_active: true, is_active: true,
default_tiered_pricing: { default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }] tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }]
}, },
default_supports_vision: true, config: {
default_supports_function_calling: true, streaming: true,
default_supports_streaming: true, vision: true,
default_supports_extended_thinking: true, function_calling: true,
extended_thinking: true,
description: 'OpenAI GPT-5.1 模型'
},
provider_count: 2, provider_count: 2,
alias_count: 1,
created_at: '2024-01-01T00:00:00Z' created_at: '2024-01-01T00:00:00Z'
}, },
{ {
id: 'gm-007', id: 'gm-007',
name: 'gpt-5.1-codex', name: 'gpt-5.1-codex',
display_name: 'gpt-5.1-codex', display_name: 'gpt-5.1-codex',
description: 'OpenAI GPT-5.1 Codex 代码专用模型',
is_active: true, is_active: true,
default_tiered_pricing: { default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }] tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }]
}, },
default_supports_vision: true, config: {
default_supports_function_calling: true, streaming: true,
default_supports_streaming: true, vision: true,
default_supports_extended_thinking: true, function_calling: true,
extended_thinking: true,
description: 'OpenAI GPT-5.1 Codex 代码专用模型'
},
provider_count: 2, provider_count: 2,
alias_count: 0,
created_at: '2024-01-01T00:00:00Z' created_at: '2024-01-01T00:00:00Z'
}, },
{ {
id: 'gm-008', id: 'gm-008',
name: 'gpt-5.1-codex-max', name: 'gpt-5.1-codex-max',
display_name: 'gpt-5.1-codex-max', display_name: 'gpt-5.1-codex-max',
description: 'OpenAI GPT-5.1 Codex Max 代码专用增强版',
is_active: true, is_active: true,
default_tiered_pricing: { default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }] tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }]
}, },
default_supports_vision: true, config: {
default_supports_function_calling: true, streaming: true,
default_supports_streaming: true, vision: true,
default_supports_extended_thinking: true, function_calling: true,
extended_thinking: true,
description: 'OpenAI GPT-5.1 Codex Max 代码专用增强版'
},
provider_count: 2, provider_count: 2,
alias_count: 0,
created_at: '2024-01-01T00:00:00Z' created_at: '2024-01-01T00:00:00Z'
}, },
{ {
id: 'gm-009', id: 'gm-009',
name: 'gpt-5.1-codex-mini', name: 'gpt-5.1-codex-mini',
display_name: 'gpt-5.1-codex-mini', display_name: 'gpt-5.1-codex-mini',
description: 'OpenAI GPT-5.1 Codex Mini 轻量代码模型',
is_active: true, is_active: true,
default_tiered_pricing: { default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }] tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }]
}, },
default_supports_vision: true, config: {
default_supports_function_calling: true, streaming: true,
default_supports_streaming: true, vision: true,
default_supports_extended_thinking: true, function_calling: true,
extended_thinking: true,
description: 'OpenAI GPT-5.1 Codex Mini 轻量代码模型'
},
provider_count: 2, provider_count: 2,
alias_count: 0,
created_at: '2024-01-01T00:00:00Z' created_at: '2024-01-01T00:00:00Z'
} }
] ]

View File

@@ -1000,17 +1000,11 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
id: m.id, id: m.id,
name: m.name, name: m.name,
display_name: m.display_name, display_name: m.display_name,
description: m.description,
icon_url: null,
is_active: m.is_active, is_active: m.is_active,
default_tiered_pricing: m.default_tiered_pricing, default_tiered_pricing: m.default_tiered_pricing,
default_price_per_request: null, default_price_per_request: m.default_price_per_request,
default_supports_vision: m.default_supports_vision, supported_capabilities: m.supported_capabilities,
default_supports_function_calling: m.default_supports_function_calling, config: m.config
default_supports_streaming: m.default_supports_streaming,
default_supports_extended_thinking: m.default_supports_extended_thinking || false,
default_supports_image_generation: false,
supported_capabilities: null
})), })),
total: MOCK_GLOBAL_MODELS.length total: MOCK_GLOBAL_MODELS.length
}) })

View File

@@ -935,7 +935,10 @@ onBeforeUnmount(() => {
:key="`${index}-${aliasIndex}`" :key="`${index}-${aliasIndex}`"
> >
<TableCell> <TableCell>
<Badge variant="outline" class="text-xs"> <Badge
variant="outline"
class="text-xs"
>
{{ mapping.provider_name }} {{ mapping.provider_name }}
</Badge> </Badge>
</TableCell> </TableCell>
@@ -981,7 +984,10 @@ onBeforeUnmount(() => {
class="p-4 space-y-2" class="p-4 space-y-2"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Badge variant="outline" class="text-xs"> <Badge
variant="outline"
class="text-xs"
>
{{ mapping.provider_name }} {{ mapping.provider_name }}
</Badge> </Badge>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">

View File

@@ -111,9 +111,6 @@
<TableHead class="w-[80px] text-center"> <TableHead class="w-[80px] text-center">
提供商 提供商
</TableHead> </TableHead>
<TableHead class="w-[70px] text-center">
别名/映射
</TableHead>
<TableHead class="w-[80px] text-center"> <TableHead class="w-[80px] text-center">
调用次数 调用次数
</TableHead> </TableHead>
@@ -128,7 +125,7 @@
<TableBody> <TableBody>
<TableRow v-if="loading"> <TableRow v-if="loading">
<TableCell <TableCell
colspan="8" colspan="7"
class="text-center py-8" class="text-center py-8"
> >
<Loader2 class="w-6 h-6 animate-spin mx-auto" /> <Loader2 class="w-6 h-6 animate-spin mx-auto" />
@@ -136,7 +133,7 @@
</TableRow> </TableRow>
<TableRow v-else-if="filteredGlobalModels.length === 0"> <TableRow v-else-if="filteredGlobalModels.length === 0">
<TableCell <TableCell
colspan="8" colspan="7"
class="text-center py-8 text-muted-foreground" class="text-center py-8 text-muted-foreground"
> >
没有找到匹配的模型 没有找到匹配的模型
@@ -171,27 +168,27 @@
<div class="space-y-1 w-fit"> <div class="space-y-1 w-fit">
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
<Zap <Zap
v-if="model.default_supports_streaming" v-if="model.config?.streaming !== false"
class="w-4 h-4 text-muted-foreground" class="w-4 h-4 text-muted-foreground"
title="流式输出" title="流式输出"
/> />
<Image <Image
v-if="model.default_supports_image_generation" v-if="model.config?.image_generation === true"
class="w-4 h-4 text-muted-foreground" class="w-4 h-4 text-muted-foreground"
title="图像生成" title="图像生成"
/> />
<Eye <Eye
v-if="model.default_supports_vision" v-if="model.config?.vision === true"
class="w-4 h-4 text-muted-foreground" class="w-4 h-4 text-muted-foreground"
title="视觉理解" title="视觉理解"
/> />
<Wrench <Wrench
v-if="model.default_supports_function_calling" v-if="model.config?.function_calling === true"
class="w-4 h-4 text-muted-foreground" class="w-4 h-4 text-muted-foreground"
title="工具调用" title="工具调用"
/> />
<Brain <Brain
v-if="model.default_supports_extended_thinking" v-if="model.config?.extended_thinking === true"
class="w-4 h-4 text-muted-foreground" class="w-4 h-4 text-muted-foreground"
title="深度思考" title="深度思考"
/> />
@@ -244,11 +241,6 @@
{{ model.provider_count || 0 }} {{ model.provider_count || 0 }}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell class="text-center">
<Badge variant="secondary">
{{ model.alias_count || 0 }}
</Badge>
</TableCell>
<TableCell class="text-center"> <TableCell class="text-center">
<span class="text-sm font-mono">{{ formatUsageCount(model.usage_count || 0) }}</span> <span class="text-sm font-mono">{{ formatUsageCount(model.usage_count || 0) }}</span>
</TableCell> </TableCell>
@@ -369,23 +361,23 @@
<!-- 第二行能力图标 --> <!-- 第二行能力图标 -->
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
<Zap <Zap
v-if="model.default_supports_streaming" v-if="model.config?.streaming !== false"
class="w-4 h-4 text-muted-foreground" class="w-4 h-4 text-muted-foreground"
/> />
<Image <Image
v-if="model.default_supports_image_generation" v-if="model.config?.image_generation === true"
class="w-4 h-4 text-muted-foreground" class="w-4 h-4 text-muted-foreground"
/> />
<Eye <Eye
v-if="model.default_supports_vision" v-if="model.config?.vision === true"
class="w-4 h-4 text-muted-foreground" class="w-4 h-4 text-muted-foreground"
/> />
<Wrench <Wrench
v-if="model.default_supports_function_calling" v-if="model.config?.function_calling === true"
class="w-4 h-4 text-muted-foreground" class="w-4 h-4 text-muted-foreground"
/> />
<Brain <Brain
v-if="model.default_supports_extended_thinking" v-if="model.config?.extended_thinking === true"
class="w-4 h-4 text-muted-foreground" class="w-4 h-4 text-muted-foreground"
/> />
</div> </div>
@@ -393,7 +385,6 @@
<!-- 第三行统计信息 --> <!-- 第三行统计信息 -->
<div class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground"> <div class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span>提供商 {{ model.provider_count || 0 }}</span> <span>提供商 {{ model.provider_count || 0 }}</span>
<span>别名 {{ model.alias_count || 0 }}</span>
<span>调用 {{ formatUsageCount(model.usage_count || 0) }}</span> <span>调用 {{ formatUsageCount(model.usage_count || 0) }}</span>
<span <span
v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')" v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')"
@@ -1022,19 +1013,19 @@ const filteredGlobalModels = computed(() => {
// 能力筛选 // 能力筛选
if (capabilityFilters.value.streaming) { if (capabilityFilters.value.streaming) {
result = result.filter(m => m.default_supports_streaming) result = result.filter(m => m.config?.streaming !== false)
} }
if (capabilityFilters.value.imageGeneration) { if (capabilityFilters.value.imageGeneration) {
result = result.filter(m => m.default_supports_image_generation) result = result.filter(m => m.config?.image_generation === true)
} }
if (capabilityFilters.value.vision) { if (capabilityFilters.value.vision) {
result = result.filter(m => m.default_supports_vision) result = result.filter(m => m.config?.vision === true)
} }
if (capabilityFilters.value.toolUse) { if (capabilityFilters.value.toolUse) {
result = result.filter(m => m.default_supports_function_calling) result = result.filter(m => m.config?.function_calling === true)
} }
if (capabilityFilters.value.extendedThinking) { if (capabilityFilters.value.extendedThinking) {
result = result.filter(m => m.default_supports_extended_thinking) result = result.filter(m => m.config?.extended_thinking === true)
} }
return result return result

View File

@@ -226,8 +226,8 @@
<div <div
v-for="announcement in announcements" v-for="announcement in announcements"
:key="announcement.id" :key="announcement.id"
class="p-4 space-y-2 cursor-pointer transition-colors"
:class="[ :class="[
'p-4 space-y-2 cursor-pointer transition-colors',
announcement.is_read ? 'hover:bg-muted/30' : 'bg-primary/5 hover:bg-primary/10' announcement.is_read ? 'hover:bg-muted/30' : 'bg-primary/5 hover:bg-primary/10'
]" ]"
@click="viewAnnouncementDetail(announcement)" @click="viewAnnouncementDetail(announcement)"

View File

@@ -165,17 +165,17 @@
<TableCell class="py-4"> <TableCell class="py-4">
<div class="flex gap-1.5"> <div class="flex gap-1.5">
<Eye <Eye
v-if="model.default_supports_vision" v-if="model.config?.vision === true"
class="w-4 h-4 text-muted-foreground" class="w-4 h-4 text-muted-foreground"
title="Vision" title="Vision"
/> />
<Wrench <Wrench
v-if="model.default_supports_function_calling" v-if="model.config?.function_calling === true"
class="w-4 h-4 text-muted-foreground" class="w-4 h-4 text-muted-foreground"
title="Tool Use" title="Tool Use"
/> />
<Brain <Brain
v-if="model.default_supports_extended_thinking" v-if="model.config?.extended_thinking === true"
class="w-4 h-4 text-muted-foreground" class="w-4 h-4 text-muted-foreground"
title="Extended Thinking" title="Extended Thinking"
/> />
@@ -253,15 +253,15 @@
<!-- 第二行能力图标 --> <!-- 第二行能力图标 -->
<div class="flex gap-1.5"> <div class="flex gap-1.5">
<Eye <Eye
v-if="model.default_supports_vision" v-if="model.config?.vision === true"
class="w-4 h-4 text-muted-foreground" class="w-4 h-4 text-muted-foreground"
/> />
<Wrench <Wrench
v-if="model.default_supports_function_calling" v-if="model.config?.function_calling === true"
class="w-4 h-4 text-muted-foreground" class="w-4 h-4 text-muted-foreground"
/> />
<Brain <Brain
v-if="model.default_supports_extended_thinking" v-if="model.config?.extended_thinking === true"
class="w-4 h-4 text-muted-foreground" class="w-4 h-4 text-muted-foreground"
/> />
</div> </div>
@@ -485,13 +485,13 @@ const filteredModels = computed(() => {
// 能力筛选 // 能力筛选
if (capabilityFilters.value.vision) { if (capabilityFilters.value.vision) {
result = result.filter(m => m.default_supports_vision) result = result.filter(m => m.config?.vision === true)
} }
if (capabilityFilters.value.toolUse) { if (capabilityFilters.value.toolUse) {
result = result.filter(m => m.default_supports_function_calling) result = result.filter(m => m.config?.function_calling === true)
} }
if (capabilityFilters.value.extendedThinking) { if (capabilityFilters.value.extendedThinking) {
result = result.filter(m => m.default_supports_extended_thinking) result = result.filter(m => m.config?.extended_thinking === true)
} }
return result return result

View File

@@ -38,10 +38,10 @@
</button> </button>
</div> </div>
<p <p
v-if="model.description" v-if="model.config?.description"
class="text-xs text-muted-foreground" class="text-xs text-muted-foreground"
> >
{{ model.description }} {{ model.config?.description }}
</p> </p>
</div> </div>
<Button <Button
@@ -73,10 +73,10 @@
</p> </p>
</div> </div>
<Badge <Badge
:variant="model.default_supports_streaming ?? false ? 'default' : 'secondary'" :variant="model.config?.streaming !== false ? 'default' : 'secondary'"
class="text-xs" class="text-xs"
> >
{{ model.default_supports_streaming ?? false ? '支持' : '不支持' }} {{ model.config?.streaming !== false ? '支持' : '不支持' }}
</Badge> </Badge>
</div> </div>
<div class="flex items-center gap-2 p-3 rounded-lg border"> <div class="flex items-center gap-2 p-3 rounded-lg border">
@@ -90,10 +90,10 @@
</p> </p>
</div> </div>
<Badge <Badge
:variant="model.default_supports_image_generation ?? false ? 'default' : 'secondary'" :variant="model.config?.image_generation === true ? 'default' : 'secondary'"
class="text-xs" class="text-xs"
> >
{{ model.default_supports_image_generation ?? false ? '支持' : '不支持' }} {{ model.config?.image_generation === true ? '支持' : '不支持' }}
</Badge> </Badge>
</div> </div>
<div class="flex items-center gap-2 p-3 rounded-lg border"> <div class="flex items-center gap-2 p-3 rounded-lg border">
@@ -107,10 +107,10 @@
</p> </p>
</div> </div>
<Badge <Badge
:variant="model.default_supports_vision ?? false ? 'default' : 'secondary'" :variant="model.config?.vision === true ? 'default' : 'secondary'"
class="text-xs" class="text-xs"
> >
{{ model.default_supports_vision ?? false ? '支持' : '不支持' }} {{ model.config?.vision === true ? '支持' : '不支持' }}
</Badge> </Badge>
</div> </div>
<div class="flex items-center gap-2 p-3 rounded-lg border"> <div class="flex items-center gap-2 p-3 rounded-lg border">
@@ -124,10 +124,10 @@
</p> </p>
</div> </div>
<Badge <Badge
:variant="model.default_supports_function_calling ?? false ? 'default' : 'secondary'" :variant="model.config?.function_calling === true ? 'default' : 'secondary'"
class="text-xs" class="text-xs"
> >
{{ model.default_supports_function_calling ?? false ? '支持' : '不支持' }} {{ model.config?.function_calling === true ? '支持' : '不支持' }}
</Badge> </Badge>
</div> </div>
<div class="flex items-center gap-2 p-3 rounded-lg border"> <div class="flex items-center gap-2 p-3 rounded-lg border">
@@ -141,10 +141,10 @@
</p> </p>
</div> </div>
<Badge <Badge
:variant="model.default_supports_extended_thinking ?? false ? 'default' : 'secondary'" :variant="model.config?.extended_thinking === true ? 'default' : 'secondary'"
class="text-xs" class="text-xs"
> >
{{ model.default_supports_extended_thinking ?? false ? '支持' : '不支持' }} {{ model.config?.extended_thinking === true ? '支持' : '不支持' }}
</Badge> </Badge>
</div> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@
from fastapi import APIRouter from fastapi import APIRouter
from .catalog import router as catalog_router from .catalog import router as catalog_router
from .external import router as external_router
from .global_models import router as global_models_router from .global_models import router as global_models_router
router = APIRouter(prefix="/api/admin/models", tags=["Admin - Model Management"]) router = APIRouter(prefix="/api/admin/models", tags=["Admin - Model Management"])
@@ -12,3 +13,4 @@ router = APIRouter(prefix="/api/admin/models", tags=["Admin - Model Management"]
# 挂载子路由 # 挂载子路由
router.include_router(catalog_router) router.include_router(catalog_router)
router.include_router(global_models_router) router.include_router(global_models_router)
router.include_router(external_router)

View File

@@ -72,10 +72,12 @@ class AdminGetModelCatalogAdapter(AdminApiAdapter):
for gm in global_models: for gm in global_models:
gm_id = gm.id gm_id = gm.id
provider_entries: List[ModelCatalogProviderDetail] = [] provider_entries: List[ModelCatalogProviderDetail] = []
# 从 config JSON 读取能力标志
gm_config = gm.config or {}
capability_flags = { capability_flags = {
"supports_vision": gm.default_supports_vision or False, "supports_vision": gm_config.get("vision", False),
"supports_function_calling": gm.default_supports_function_calling or False, "supports_function_calling": gm_config.get("function_calling", False),
"supports_streaming": gm.default_supports_streaming or False, "supports_streaming": gm_config.get("streaming", True),
} }
# 遍历该 GlobalModel 的所有关联提供商 # 遍历该 GlobalModel 的所有关联提供商
@@ -140,7 +142,7 @@ class AdminGetModelCatalogAdapter(AdminApiAdapter):
ModelCatalogItem( ModelCatalogItem(
global_model_name=gm.name, global_model_name=gm.name,
display_name=gm.display_name, display_name=gm.display_name,
description=gm.description, description=gm_config.get("description"),
providers=provider_entries, providers=provider_entries,
price_range=price_range, price_range=price_range,
total_providers=len(provider_entries), total_providers=len(provider_entries),

View File

@@ -0,0 +1,79 @@
"""
models.dev 外部模型数据代理
"""
import json
from typing import Any, Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from src.clients import get_redis_client
from src.core.logger import logger
from src.models.database import User
from src.utils.auth_utils import require_admin
router = APIRouter()
CACHE_KEY = "aether:external:models_dev"
CACHE_TTL = 15 * 60 # 15 分钟
async def _get_cached_data() -> Optional[dict[str, Any]]:
"""从 Redis 获取缓存数据"""
redis = await get_redis_client()
if redis is None:
return None
try:
cached = await redis.get(CACHE_KEY)
if cached:
result: dict[str, Any] = json.loads(cached)
return result
except Exception as e:
logger.warning(f"读取 models.dev 缓存失败: {e}")
return None
async def _set_cached_data(data: dict) -> None:
"""将数据写入 Redis 缓存"""
redis = await get_redis_client()
if redis is None:
return
try:
await redis.setex(CACHE_KEY, CACHE_TTL, json.dumps(data, ensure_ascii=False))
except Exception as e:
logger.warning(f"写入 models.dev 缓存失败: {e}")
@router.get("/external")
async def get_external_models(_: User = Depends(require_admin)) -> JSONResponse:
"""
获取 models.dev 的模型数据(代理请求,解决跨域问题)
数据缓存 15 分钟(使用 Redis多 worker 共享)
"""
# 检查缓存
cached = await _get_cached_data()
if cached is not None:
return JSONResponse(content=cached)
# 从 models.dev 获取数据
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get("https://models.dev/api.json")
response.raise_for_status()
data = response.json()
# 写入缓存
await _set_cached_data(data)
return JSONResponse(content=data)
except httpx.TimeoutException:
raise HTTPException(status_code=504, detail="请求 models.dev 超时")
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=502, detail=f"models.dev 返回错误: {e.response.status_code}"
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"获取外部模型数据失败: {str(e)}")

View File

@@ -187,21 +187,15 @@ class AdminCreateGlobalModelAdapter(AdminApiAdapter):
db=context.db, db=context.db,
name=self.payload.name, name=self.payload.name,
display_name=self.payload.display_name, display_name=self.payload.display_name,
description=self.payload.description,
official_url=self.payload.official_url,
icon_url=self.payload.icon_url,
is_active=self.payload.is_active, is_active=self.payload.is_active,
# 按次计费配置 # 按次计费配置
default_price_per_request=self.payload.default_price_per_request, default_price_per_request=self.payload.default_price_per_request,
# 阶梯计费配置 # 阶梯计费配置
default_tiered_pricing=tiered_pricing_dict, default_tiered_pricing=tiered_pricing_dict,
# 默认能力配置
default_supports_vision=self.payload.default_supports_vision,
default_supports_function_calling=self.payload.default_supports_function_calling,
default_supports_streaming=self.payload.default_supports_streaming,
default_supports_extended_thinking=self.payload.default_supports_extended_thinking,
# Key 能力配置 # Key 能力配置
supported_capabilities=self.payload.supported_capabilities, supported_capabilities=self.payload.supported_capabilities,
# 模型配置JSON
config=self.payload.config,
) )
logger.info(f"GlobalModel 已创建: id={global_model.id} name={global_model.name}") logger.info(f"GlobalModel 已创建: id={global_model.id} name={global_model.name}")

View File

@@ -210,9 +210,9 @@ class PublicModelsAdapter(PublicApiAdapter):
provider_display_name=provider.display_name, provider_display_name=provider.display_name,
name=unified_name, name=unified_name,
display_name=display_name, display_name=display_name,
description=global_model.description if global_model else None, description=global_model.config.get("description") if global_model and global_model.config else None,
tags=None, tags=None,
icon_url=global_model.icon_url if global_model else None, icon_url=global_model.config.get("icon_url") if global_model and global_model.config else None,
input_price_per_1m=model.get_effective_input_price(), input_price_per_1m=model.get_effective_input_price(),
output_price_per_1m=model.get_effective_output_price(), output_price_per_1m=model.get_effective_output_price(),
cache_creation_price_per_1m=model.get_effective_cache_creation_price(), cache_creation_price_per_1m=model.get_effective_cache_creation_price(),
@@ -274,7 +274,6 @@ class PublicSearchModelsAdapter(PublicApiAdapter):
Model.provider_model_name.ilike(f"%{self.query}%") Model.provider_model_name.ilike(f"%{self.query}%")
| GlobalModel.name.ilike(f"%{self.query}%") | GlobalModel.name.ilike(f"%{self.query}%")
| GlobalModel.display_name.ilike(f"%{self.query}%") | GlobalModel.display_name.ilike(f"%{self.query}%")
| GlobalModel.description.ilike(f"%{self.query}%")
) )
query_stmt = query_stmt.filter(search_filter) query_stmt = query_stmt.filter(search_filter)
if self.provider_id is not None: if self.provider_id is not None:
@@ -293,9 +292,9 @@ class PublicSearchModelsAdapter(PublicApiAdapter):
provider_display_name=provider.display_name, provider_display_name=provider.display_name,
name=unified_name, name=unified_name,
display_name=display_name, display_name=display_name,
description=global_model.description if global_model else None, description=global_model.config.get("description") if global_model and global_model.config else None,
tags=None, tags=None,
icon_url=global_model.icon_url if global_model else None, icon_url=global_model.config.get("icon_url") if global_model and global_model.config else None,
input_price_per_1m=model.get_effective_input_price(), input_price_per_1m=model.get_effective_input_price(),
output_price_per_1m=model.get_effective_output_price(), output_price_per_1m=model.get_effective_output_price(),
cache_creation_price_per_1m=model.get_effective_cache_creation_price(), cache_creation_price_per_1m=model.get_effective_cache_creation_price(),
@@ -499,7 +498,6 @@ class PublicGlobalModelsAdapter(PublicApiAdapter):
or_( or_(
GlobalModel.name.ilike(search_term), GlobalModel.name.ilike(search_term),
GlobalModel.display_name.ilike(search_term), GlobalModel.display_name.ilike(search_term),
GlobalModel.description.ilike(search_term),
) )
) )
@@ -517,21 +515,11 @@ class PublicGlobalModelsAdapter(PublicApiAdapter):
id=gm.id, id=gm.id,
name=gm.name, name=gm.name,
display_name=gm.display_name, display_name=gm.display_name,
description=gm.description,
icon_url=gm.icon_url,
is_active=gm.is_active, is_active=gm.is_active,
default_price_per_request=gm.default_price_per_request, default_price_per_request=gm.default_price_per_request,
default_tiered_pricing=gm.default_tiered_pricing, default_tiered_pricing=gm.default_tiered_pricing,
default_supports_vision=gm.default_supports_vision or False,
default_supports_function_calling=gm.default_supports_function_calling or False,
default_supports_streaming=(
gm.default_supports_streaming
if gm.default_supports_streaming is not None
else True
),
default_supports_extended_thinking=gm.default_supports_extended_thinking
or False,
supported_capabilities=gm.supported_capabilities, supported_capabilities=gm.supported_capabilities,
config=gm.config,
) )
) )

View File

@@ -562,20 +562,15 @@ class PublicGlobalModelResponse(BaseModel):
id: str id: str
name: str name: str
display_name: Optional[str] = None display_name: Optional[str] = None
description: Optional[str] = None
icon_url: Optional[str] = None
is_active: bool = True is_active: bool = True
# 按次计费配置 # 按次计费配置
default_price_per_request: Optional[float] = None default_price_per_request: Optional[float] = None
# 阶梯计费配置 # 阶梯计费配置
default_tiered_pricing: Optional[dict] = None default_tiered_pricing: Optional[dict] = None
# 默认能力
default_supports_vision: bool = False
default_supports_function_calling: bool = False
default_supports_streaming: bool = True
default_supports_extended_thinking: bool = False
# Key 能力配置 # Key 能力配置
supported_capabilities: Optional[List[str]] = None supported_capabilities: Optional[List[str]] = None
# 模型配置JSON
config: Optional[dict] = None
class PublicGlobalModelListResponse(BaseModel): class PublicGlobalModelListResponse(BaseModel):

View File

@@ -26,6 +26,7 @@ from sqlalchemy import (
Text, Text,
UniqueConstraint, UniqueConstraint,
) )
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@@ -576,11 +577,6 @@ class GlobalModel(Base):
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True) id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
name = Column(String(100), unique=True, nullable=False, index=True) # 统一模型名(唯一) name = Column(String(100), unique=True, nullable=False, index=True) # 统一模型名(唯一)
display_name = Column(String(100), nullable=False) display_name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
# 模型元数据
icon_url = Column(String(500), nullable=True)
official_url = Column(String(500), nullable=True) # 官方文档链接
# 按次计费配置(每次请求的固定费用,美元)- 可选,与按 token 计费叠加 # 按次计费配置(每次请求的固定费用,美元)- 可选,与按 token 计费叠加
default_price_per_request = Column(Float, nullable=True, default=None) # 每次请求固定费用 default_price_per_request = Column(Float, nullable=True, default=None) # 每次请求固定费用
@@ -606,17 +602,34 @@ class GlobalModel(Base):
# } # }
default_tiered_pricing = Column(JSON, nullable=False) default_tiered_pricing = Column(JSON, nullable=False)
# 默认能力配置 - Provider 可覆盖
default_supports_vision = Column(Boolean, default=False, nullable=True)
default_supports_function_calling = Column(Boolean, default=False, nullable=True)
default_supports_streaming = Column(Boolean, default=True, nullable=True)
default_supports_extended_thinking = Column(Boolean, default=False, nullable=True)
default_supports_image_generation = Column(Boolean, default=False, nullable=True)
# Key 能力配置 - 模型支持的能力列表(如 ["cache_1h", "context_1m"] # Key 能力配置 - 模型支持的能力列表(如 ["cache_1h", "context_1m"]
# Key 只能启用模型支持的能力 # Key 只能启用模型支持的能力
supported_capabilities = Column(JSON, nullable=True, default=list) supported_capabilities = Column(JSON, nullable=True, default=list)
# 模型配置JSON格式- 包含能力、规格、元信息等
# 结构示例:
# {
# # 能力配置
# "streaming": true,
# "vision": true,
# "function_calling": true,
# "extended_thinking": false,
# "image_generation": false,
# # 规格参数
# "context_limit": 200000,
# "output_limit": 8192,
# # 元信息
# "description": "...",
# "icon_url": "...",
# "official_url": "...",
# "knowledge_cutoff": "2024-04",
# "family": "claude-3.5",
# "release_date": "2024-10-22",
# "input_modalities": ["text", "image"],
# "output_modalities": ["text"],
# }
config = Column(JSONB, nullable=True, default=dict)
# 状态 # 状态
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
@@ -767,11 +780,22 @@ class Model(Base):
"""获取有效的能力配置(通用辅助方法)""" """获取有效的能力配置(通用辅助方法)"""
local_value = getattr(self, attr_name, None) local_value = getattr(self, attr_name, None)
if local_value is not None: if local_value is not None:
return local_value return bool(local_value)
if self.global_model: if self.global_model:
global_value = getattr(self.global_model, f"default_{attr_name}", None) config_key_map = {
if global_value is not None: "supports_vision": "vision",
return global_value "supports_function_calling": "function_calling",
"supports_streaming": "streaming",
"supports_extended_thinking": "extended_thinking",
"supports_image_generation": "image_generation",
}
config_key = config_key_map.get(attr_name)
if config_key:
global_config = getattr(self.global_model, "config", None)
if isinstance(global_config, dict):
global_value = global_config.get(config_key)
if global_value is not None:
return bool(global_value)
return default return default
def get_effective_supports_vision(self) -> bool: def get_effective_supports_vision(self) -> bool:

View File

@@ -187,9 +187,6 @@ class GlobalModelCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="统一模型名(唯一)") name: str = Field(..., min_length=1, max_length=100, description="统一模型名(唯一)")
display_name: str = Field(..., min_length=1, max_length=100, description="显示名称") display_name: str = Field(..., min_length=1, max_length=100, description="显示名称")
description: Optional[str] = Field(None, description="模型描述")
official_url: Optional[str] = Field(None, max_length=500, description="官方文档链接")
icon_url: Optional[str] = Field(None, max_length=500, description="图标 URL")
# 按次计费配置(可选,与阶梯计费叠加) # 按次计费配置(可选,与阶梯计费叠加)
default_price_per_request: Optional[float] = Field(None, ge=0, description="每次请求固定费用") default_price_per_request: Optional[float] = Field(None, ge=0, description="每次请求固定费用")
# 统一阶梯计费配置(必填) # 统一阶梯计费配置(必填)
@@ -197,22 +194,15 @@ class GlobalModelCreate(BaseModel):
default_tiered_pricing: TieredPricingConfig = Field( default_tiered_pricing: TieredPricingConfig = Field(
..., description="阶梯计费配置(固定价格用单阶梯表示)" ..., description="阶梯计费配置(固定价格用单阶梯表示)"
) )
# 默认能力配置
default_supports_vision: Optional[bool] = Field(False, description="默认是否支持视觉")
default_supports_function_calling: Optional[bool] = Field(
False, description="默认是否支持函数调用"
)
default_supports_streaming: Optional[bool] = Field(True, description="默认是否支持流式输出")
default_supports_extended_thinking: Optional[bool] = Field(
False, description="默认是否支持扩展思考"
)
default_supports_image_generation: Optional[bool] = Field(
False, description="默认是否支持图像生成"
)
# Key 能力配置 - 模型支持的能力列表(如 ["cache_1h", "context_1m"] # Key 能力配置 - 模型支持的能力列表(如 ["cache_1h", "context_1m"]
supported_capabilities: Optional[List[str]] = Field( supported_capabilities: Optional[List[str]] = Field(
None, description="支持的 Key 能力列表" None, description="支持的 Key 能力列表"
) )
# 模型配置JSON格式- 包含能力、规格、元信息等
config: Optional[Dict[str, Any]] = Field(
None,
description="模型配置streaming, vision, context_limit, description 等)"
)
is_active: Optional[bool] = Field(True, description="是否激活") is_active: Optional[bool] = Field(True, description="是否激活")
@@ -220,9 +210,6 @@ class GlobalModelUpdate(BaseModel):
"""更新 GlobalModel 请求""" """更新 GlobalModel 请求"""
display_name: Optional[str] = Field(None, min_length=1, max_length=100) display_name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None
official_url: Optional[str] = Field(None, max_length=500)
icon_url: Optional[str] = Field(None, max_length=500)
is_active: Optional[bool] = None is_active: Optional[bool] = None
# 按次计费配置 # 按次计费配置
default_price_per_request: Optional[float] = Field(None, ge=0, description="每次请求固定费用") default_price_per_request: Optional[float] = Field(None, ge=0, description="每次请求固定费用")
@@ -230,16 +217,15 @@ class GlobalModelUpdate(BaseModel):
default_tiered_pricing: Optional[TieredPricingConfig] = Field( default_tiered_pricing: Optional[TieredPricingConfig] = Field(
None, description="阶梯计费配置" None, description="阶梯计费配置"
) )
# 默认能力配置
default_supports_vision: Optional[bool] = None
default_supports_function_calling: Optional[bool] = None
default_supports_streaming: Optional[bool] = None
default_supports_extended_thinking: Optional[bool] = None
default_supports_image_generation: Optional[bool] = None
# Key 能力配置 - 模型支持的能力列表(如 ["cache_1h", "context_1m"] # Key 能力配置 - 模型支持的能力列表(如 ["cache_1h", "context_1m"]
supported_capabilities: Optional[List[str]] = Field( supported_capabilities: Optional[List[str]] = Field(
None, description="支持的 Key 能力列表" None, description="支持的 Key 能力列表"
) )
# 模型配置JSON格式- 包含能力、规格、元信息等
config: Optional[Dict[str, Any]] = Field(
None,
description="模型配置streaming, vision, context_limit, description 等)"
)
class GlobalModelResponse(BaseModel): class GlobalModelResponse(BaseModel):
@@ -248,9 +234,6 @@ class GlobalModelResponse(BaseModel):
id: str id: str
name: str name: str
display_name: str display_name: str
description: Optional[str]
official_url: Optional[str]
icon_url: Optional[str]
is_active: bool is_active: bool
# 按次计费配置 # 按次计费配置
default_price_per_request: Optional[float] = Field(None, description="每次请求固定费用") default_price_per_request: Optional[float] = Field(None, description="每次请求固定费用")
@@ -258,16 +241,15 @@ class GlobalModelResponse(BaseModel):
default_tiered_pricing: TieredPricingConfig = Field( default_tiered_pricing: TieredPricingConfig = Field(
..., description="阶梯计费配置" ..., description="阶梯计费配置"
) )
# 默认能力配置
default_supports_vision: Optional[bool]
default_supports_function_calling: Optional[bool]
default_supports_streaming: Optional[bool]
default_supports_extended_thinking: Optional[bool]
default_supports_image_generation: Optional[bool]
# Key 能力配置 - 模型支持的能力列表 # Key 能力配置 - 模型支持的能力列表
supported_capabilities: Optional[List[str]] = Field( supported_capabilities: Optional[List[str]] = Field(
default=None, description="支持的 Key 能力列表" default=None, description="支持的 Key 能力列表"
) )
# 模型配置JSON格式
config: Optional[Dict[str, Any]] = Field(
default=None,
description="模型配置streaming, vision, context_limit, description 等)"
)
# 统计数据(可选) # 统计数据(可选)
provider_count: Optional[int] = Field(default=0, description="支持的 Provider 数量") provider_count: Optional[int] = Field(default=0, description="支持的 Provider 数量")
usage_count: Optional[int] = Field(default=0, description="调用次数") usage_count: Optional[int] = Field(default=0, description="调用次数")

View File

@@ -385,7 +385,7 @@ class ModelCacheService:
"is_active": model.is_active, "is_active": model.is_active,
"is_available": model.is_available if hasattr(model, "is_available") else True, "is_available": model.is_available if hasattr(model, "is_available") else True,
"price_per_request": ( "price_per_request": (
float(model.price_per_request) if model.price_per_request else None float(model.price_per_request) if model.price_per_request is not None else None
), ),
"tiered_pricing": model.tiered_pricing, "tiered_pricing": model.tiered_pricing,
"supports_vision": model.supports_vision, "supports_vision": model.supports_vision,
@@ -425,14 +425,15 @@ class ModelCacheService:
"id": global_model.id, "id": global_model.id,
"name": global_model.name, "name": global_model.name,
"display_name": global_model.display_name, "display_name": global_model.display_name,
"default_supports_vision": global_model.default_supports_vision,
"default_supports_function_calling": global_model.default_supports_function_calling,
"default_supports_streaming": global_model.default_supports_streaming,
"default_supports_extended_thinking": global_model.default_supports_extended_thinking,
"default_supports_image_generation": global_model.default_supports_image_generation,
"supported_capabilities": global_model.supported_capabilities, "supported_capabilities": global_model.supported_capabilities,
"config": global_model.config,
"default_tiered_pricing": global_model.default_tiered_pricing,
"default_price_per_request": (
float(global_model.default_price_per_request)
if global_model.default_price_per_request is not None
else None
),
"is_active": global_model.is_active, "is_active": global_model.is_active,
"description": global_model.description,
} }
@staticmethod @staticmethod
@@ -442,19 +443,10 @@ class ModelCacheService:
id=global_model_dict["id"], id=global_model_dict["id"],
name=global_model_dict["name"], name=global_model_dict["name"],
display_name=global_model_dict.get("display_name"), display_name=global_model_dict.get("display_name"),
default_supports_vision=global_model_dict.get("default_supports_vision", False),
default_supports_function_calling=global_model_dict.get(
"default_supports_function_calling", False
),
default_supports_streaming=global_model_dict.get("default_supports_streaming", True),
default_supports_extended_thinking=global_model_dict.get(
"default_supports_extended_thinking", False
),
default_supports_image_generation=global_model_dict.get(
"default_supports_image_generation", False
),
supported_capabilities=global_model_dict.get("supported_capabilities") or [], supported_capabilities=global_model_dict.get("supported_capabilities") or [],
config=global_model_dict.get("config"),
default_tiered_pricing=global_model_dict.get("default_tiered_pricing"),
default_price_per_request=global_model_dict.get("default_price_per_request"),
is_active=global_model_dict.get("is_active", True), is_active=global_model_dict.get("is_active", True),
description=global_model_dict.get("description"),
) )
return global_model return global_model

View File

@@ -62,7 +62,6 @@ class GlobalModelService:
query = query.filter( query = query.filter(
(GlobalModel.name.ilike(search_pattern)) (GlobalModel.name.ilike(search_pattern))
| (GlobalModel.display_name.ilike(search_pattern)) | (GlobalModel.display_name.ilike(search_pattern))
| (GlobalModel.description.ilike(search_pattern))
) )
# 按名称排序 # 按名称排序
@@ -75,21 +74,15 @@ class GlobalModelService:
db: Session, db: Session,
name: str, name: str,
display_name: str, display_name: str,
description: Optional[str] = None,
official_url: Optional[str] = None,
icon_url: Optional[str] = None,
is_active: Optional[bool] = True, is_active: Optional[bool] = True,
# 按次计费配置 # 按次计费配置
default_price_per_request: Optional[float] = None, default_price_per_request: Optional[float] = None,
# 阶梯计费配置(必填) # 阶梯计费配置(必填)
default_tiered_pricing: dict = None, default_tiered_pricing: dict = None,
# 默认能力配置
default_supports_vision: Optional[bool] = None,
default_supports_function_calling: Optional[bool] = None,
default_supports_streaming: Optional[bool] = None,
default_supports_extended_thinking: Optional[bool] = None,
# Key 能力配置 # Key 能力配置
supported_capabilities: Optional[List[str]] = None, supported_capabilities: Optional[List[str]] = None,
# 模型配置JSON
config: Optional[dict] = None,
) -> GlobalModel: ) -> GlobalModel:
"""创建 GlobalModel""" """创建 GlobalModel"""
# 检查名称是否已存在 # 检查名称是否已存在
@@ -100,21 +93,15 @@ class GlobalModelService:
global_model = GlobalModel( global_model = GlobalModel(
name=name, name=name,
display_name=display_name, display_name=display_name,
description=description,
official_url=official_url,
icon_url=icon_url,
is_active=is_active, is_active=is_active,
# 按次计费配置 # 按次计费配置
default_price_per_request=default_price_per_request, default_price_per_request=default_price_per_request,
# 阶梯计费配置 # 阶梯计费配置
default_tiered_pricing=default_tiered_pricing, default_tiered_pricing=default_tiered_pricing,
# 默认能力配置
default_supports_vision=default_supports_vision,
default_supports_function_calling=default_supports_function_calling,
default_supports_streaming=default_supports_streaming,
default_supports_extended_thinking=default_supports_extended_thinking,
# Key 能力配置 # Key 能力配置
supported_capabilities=supported_capabilities, supported_capabilities=supported_capabilities,
# 模型配置JSON
config=config,
) )
db.add(global_model) db.add(global_model)