5 Commits

Author SHA1 Message Date
fawney19
d696c575e6 refactor(migrations): add idempotency checks to migration scripts 2025-12-16 17:46:38 +08:00
fawney19
46ff5a1a50 refactor(models): enhance model management with official provider marking and extended metadata
- Add OFFICIAL_PROVIDERS set to mark first-party vendors in models.dev
- Implement official provider marking function with cache compatibility
- Extend model metadata with family, context_limit, output_limit fields
- Improve frontend model selection UI with wider panel and better search
- Add dark mode support for provider logos
- Optimize scrollbar styling for model lists
- Update deployment documentation with clearer migration steps
2025-12-16 17:28:40 +08:00
fawney19
edce43d45f fix(auth): make get_current_user and get_current_user_from_header async functions
将 get_current_user 和 get_current_user_from_header 函数声明为 async,
并更新 AuthService.verify_token 的调用为 await,以正确处理异步 Token 验证。
2025-12-16 13:42:26 +08:00
fawney19
33265b4b13 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 类型更新,对齐新的数据结构
2025-12-16 12:21:21 +08:00
fawney19
a94aeca2d3 docs(deploy): add database migration step to deployment guide and create migration script 2025-12-16 09:21:24 +08:00
34 changed files with 1677 additions and 752 deletions

View File

@@ -60,8 +60,11 @@ python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
# 3. 部署 # 3. 部署
docker-compose up -d docker-compose up -d
# 4. 更新 # 4. 首次部署时, 初始化数据库
docker-compose pull && docker-compose up -d ./migrate.sh
# 5. 更新
docker-compose pull && docker-compose up -d && ./migrate.sh
``` ```
### Docker Compose本地构建镜像 ### Docker Compose本地构建镜像

View File

@@ -26,16 +26,66 @@ branch_labels = None
depends_on = None depends_on = None
def column_exists(bind, table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
result = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = :table_name AND column_name = :column_name
)
"""
),
{"table_name": table_name, "column_name": column_name},
)
return result.scalar()
def table_exists(bind, table_name: str) -> bool:
"""检查表是否存在"""
result = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = :table_name
)
"""
),
{"table_name": table_name},
)
return result.scalar()
def index_exists(bind, index_name: str) -> bool:
"""检查索引是否存在"""
result = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1 FROM pg_indexes
WHERE indexname = :index_name
)
"""
),
{"index_name": index_name},
)
return result.scalar()
def upgrade() -> None: def upgrade() -> None:
"""添加 provider_model_aliases 字段,迁移数据,删除 model_mappings 表""" """添加 provider_model_aliases 字段,迁移数据,删除 model_mappings 表"""
# 1. 添加 provider_model_aliases 字段 bind = op.get_bind()
# 1. 添加 provider_model_aliases 字段(如果不存在)
if not column_exists(bind, "models", "provider_model_aliases"):
op.add_column( op.add_column(
'models', 'models',
sa.Column('provider_model_aliases', sa.JSON(), nullable=True) sa.Column('provider_model_aliases', sa.JSON(), nullable=True)
) )
# 2. 迁移 model_mappings 数据 # 2. 迁移 model_mappings 数据(如果表存在)
bind = op.get_bind()
session = Session(bind=bind) session = Session(bind=bind)
model_mappings_table = sa.table( model_mappings_table = sa.table(
@@ -96,6 +146,8 @@ def upgrade() -> None:
# 查询所有活跃的 provider 级别 alias只迁移 is_active=True 且 mapping_type='alias' 的) # 查询所有活跃的 provider 级别 alias只迁移 is_active=True 且 mapping_type='alias' 的)
# 全局别名/映射不迁移(新架构不再支持 source_model -> GlobalModel.name 的解析) # 全局别名/映射不迁移(新架构不再支持 source_model -> GlobalModel.name 的解析)
# 仅当 model_mappings 表存在时执行迁移
if table_exists(bind, "model_mappings"):
mappings = session.execute( mappings = session.execute(
sa.select( sa.select(
model_mappings_table.c.source_model, model_mappings_table.c.source_model,
@@ -177,7 +229,8 @@ def upgrade() -> None:
op.drop_table('model_mappings') op.drop_table('model_mappings')
# 4. 添加索引优化别名解析性能 # 4. 添加索引优化别名解析性能
# provider_model_name 索引(支持精确匹配) # provider_model_name 索引(支持精确匹配,如果不存在
if not index_exists(bind, "idx_model_provider_model_name"):
op.create_index( op.create_index(
"idx_model_provider_model_name", "idx_model_provider_model_name",
"models", "models",
@@ -189,11 +242,22 @@ def upgrade() -> None:
# provider_model_aliases GIN 索引(支持 JSONB 查询,仅 PostgreSQL # provider_model_aliases GIN 索引(支持 JSONB 查询,仅 PostgreSQL
if bind.dialect.name == "postgresql": if bind.dialect.name == "postgresql":
# 将 json 列转为 jsonbjsonb 性能更好且支持 GIN 索引) # 将 json 列转为 jsonbjsonb 性能更好且支持 GIN 索引)
# 使用 IF NOT EXISTS 风格的检查来避免重复转换
op.execute( op.execute(
""" """
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'models'
AND column_name = 'provider_model_aliases'
AND data_type = 'json'
) THEN
ALTER TABLE models ALTER TABLE models
ALTER COLUMN provider_model_aliases TYPE jsonb ALTER COLUMN provider_model_aliases TYPE jsonb
USING provider_model_aliases::jsonb USING provider_model_aliases::jsonb;
END IF;
END $$;
""" """
) )
# 创建 GIN 索引 # 创建 GIN 索引

View File

@@ -5,8 +5,8 @@ Revises: e9b3d63f0cbf
Create Date: 2025-12-15 17:07:44.631032+00:00 Create Date: 2025-12-15 17:07:44.631032+00:00
""" """
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
@@ -16,9 +16,28 @@ branch_labels = None
depends_on = None depends_on = None
def column_exists(bind, table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
result = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = :table_name AND column_name = :column_name
)
"""
),
{"table_name": table_name, "column_name": column_name},
)
return result.scalar()
def upgrade() -> None: def upgrade() -> None:
"""应用迁移:升级到新版本""" """应用迁移:升级到新版本"""
# 添加首字时间字段到 usage 表 bind = op.get_bind()
# 添加首字时间字段到 usage 表(如果不存在)
if not column_exists(bind, "usage", "first_byte_time_ms"):
op.add_column('usage', sa.Column('first_byte_time_ms', sa.Integer(), nullable=True)) op.add_column('usage', sa.Column('first_byte_time_ms', sa.Integer(), nullable=True))

View File

@@ -0,0 +1,110 @@
"""refactor global_model to use config json field
Revision ID: 1cc6942cf06f
Revises: 180e63a9c83a
Create Date: 2025-12-16 03:11:32.480976+00:00
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '1cc6942cf06f'
down_revision = '180e63a9c83a'
branch_labels = None
depends_on = None
def column_exists(bind, table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
result = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = :table_name AND column_name = :column_name
)
"""
),
{"table_name": table_name, "column_name": column_name},
)
return result.scalar()
def upgrade() -> None:
"""应用迁移:升级到新版本
1. 添加 config 列
2. 把旧数据迁移到 config
3. 删除旧列
"""
bind = op.get_bind()
# 检查是否已经迁移过config 列存在且旧列不存在)
has_config = column_exists(bind, "global_models", "config")
has_old_columns = column_exists(bind, "global_models", "default_supports_streaming")
if has_config and not has_old_columns:
# 已完成迁移,跳过
return
# 1. 添加 config 列(使用 JSONB 类型,支持索引和更高效的查询)
if not has_config:
op.add_column('global_models', sa.Column('config', postgresql.JSONB(), nullable=True))
# 2. 迁移数据:把旧字段合并到 config JSON仅当旧列存在时
if has_old_columns:
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,288 @@
/**
* 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>
official?: boolean // 是否为官方提供商
}
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
supportsStructuredOutput?: boolean
supportsTemperature?: boolean
supportsAttachment?: boolean
openWeights?: boolean
deprecated?: boolean
official?: boolean // 是否来自官方提供商
// 用于 display_metadata 的额外字段
knowledgeCutoff?: string
releaseDate?: string
inputModalities?: string[]
outputModalities?: string[]
}
interface CacheData {
timestamp: number
data: ModelsDevData
}
// 内存缓存
let memoryCache: CacheData | null = null
function hasOfficialFlag(data: ModelsDevData): boolean {
return Object.values(data).some(provider => typeof provider?.official === 'boolean')
}
/**
* 获取 models.dev 数据(带缓存)
*/
export async function getModelsDevData(): Promise<ModelsDevData> {
// 1. 检查内存缓存
if (memoryCache && Date.now() - memoryCache.timestamp < CACHE_DURATION) {
// 兼容旧缓存:没有 official 字段时丢弃,强制刷新一次
if (hasOfficialFlag(memoryCache.data)) {
return memoryCache.data
}
memoryCache = null
}
// 2. 检查 localStorage 缓存
try {
const cached = localStorage.getItem(CACHE_KEY)
if (cached) {
const cacheData: CacheData = JSON.parse(cached)
if (Date.now() - cacheData.timestamp < CACHE_DURATION) {
// 兼容旧缓存:没有 official 字段时丢弃,强制刷新一次
if (hasOfficialFlag(cacheData.data)) {
memoryCache = cacheData
return cacheData.data
}
localStorage.removeItem(CACHE_KEY)
}
}
} 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
}
// 模型列表缓存(避免重复转换)
let modelsListCache: ModelsDevModelItem[] | null = null
let modelsListCacheTimestamp: number | null = null
/**
* 获取扁平化的模型列表
* 数据只加载一次,通过参数过滤官方/全部
*/
export async function getModelsDevList(officialOnly: boolean = true): Promise<ModelsDevModelItem[]> {
const data = await getModelsDevData()
const currentTimestamp = memoryCache?.timestamp ?? 0
// 如果缓存为空或数据已刷新,构建一次
if (!modelsListCache || modelsListCacheTimestamp !== currentTimestamp) {
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,
supportsStructuredOutput: model.structured_output,
supportsTemperature: model.temperature,
supportsAttachment: model.attachment,
openWeights: model.open_weights,
deprecated: model.deprecated,
official: provider.official,
// 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)
})
modelsListCache = items
modelsListCacheTimestamp = currentTimestamp
}
// 根据参数过滤
if (officialOnly) {
return modelsListCache.filter(m => m.official)
}
return modelsListCache
}
/**
* 搜索模型
* 搜索时包含所有提供商(包括第三方)
*/
export async function searchModelsDevModels(
query: string,
options?: {
limit?: number
excludeDeprecated?: boolean
}
): Promise<ModelsDevModelItem[]> {
// 搜索时包含全部提供商
const allModels = await getModelsDevList(false)
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
modelsListCache = null
modelsListCacheTimestamp = 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,13 +2,99 @@
<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"
>
<div
class="flex gap-4"
:class="isEditMode ? '' : 'h-[500px]'"
>
<!-- 左侧模型选择仅创建模式 -->
<div
v-if="!isEditMode"
class="w-[260px] shrink-0 flex flex-col h-full"
>
<!-- 搜索框 -->
<div class="relative mb-3">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
v-model="searchQuery"
type="text"
placeholder="搜索模型、提供商..."
class="pl-8 h-8 text-sm"
/>
</div>
<!-- 模型列表两级结构 -->
<div class="flex-1 overflow-y-auto border rounded-lg min-h-0 scrollbar-thin">
<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 dark:invert dark:brightness-90"
@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 scrollbar-thin"
:class="isEditMode ? 'max-h-[70vh]' : ''"
> >
<form <form
class="space-y-5 max-h-[70vh] overflow-y-auto pr-1" class="space-y-5"
@submit.prevent="handleSubmit" @submit.prevent="handleSubmit"
> >
<!-- 基本信息 --> <!-- 基本信息 -->
@@ -16,7 +102,6 @@
<h4 class="font-medium text-sm"> <h4 class="font-medium text-sm">
基本信息 基本信息
</h4> </h4>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label <Label
@@ -30,12 +115,6 @@
:disabled="isEditMode" :disabled="isEditMode"
required required
/> />
<p
v-if="!isEditMode"
class="text-xs text-muted-foreground"
>
创建后不可修改
</p>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label <Label
@@ -50,7 +129,6 @@
/> />
</div> </div>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label <Label
for="model-description" for="model-description"
@@ -58,10 +136,51 @@
>描述</Label> >描述</Label>
<Input <Input
id="model-description" id="model-description"
v-model="form.description" :model-value="form.config?.description || ''"
placeholder="简短描述此模型的特点" placeholder="简短描述此模型的特点"
@update:model-value="(v) => setConfigField('description', v || undefined)"
/> />
</div> </div>
<div class="grid grid-cols-3 gap-3">
<div class="space-y-1.5">
<Label
for="model-family"
class="text-xs"
>模型系列</Label>
<Input
id="model-family"
:model-value="form.config?.family || ''"
placeholder=" GPT-4Claude 3"
@update:model-value="(v) => setConfigField('family', v || undefined)"
/>
</div>
<div class="space-y-1.5">
<Label
for="model-context-limit"
class="text-xs"
>上下文限制</Label>
<Input
id="model-context-limit"
type="number"
:model-value="form.config?.context_limit ?? ''"
placeholder=" 128000"
@update:model-value="(v) => setConfigField('context_limit', v ? Number(v) : undefined)"
/>
</div>
<div class="space-y-1.5">
<Label
for="model-output-limit"
class="text-xs"
>输出限制</Label>
<Input
id="model-output-limit"
type="number"
:model-value="form.config?.output_limit ?? ''"
placeholder=" 8192"
@update:model-value="(v) => setConfigField('output_limit', v ? Number(v) : undefined)"
/>
</div>
</div>
</section> </section>
<!-- 能力配置 --> <!-- 能力配置 -->
@@ -70,50 +189,55 @@
默认能力 默认能力
</h4> </h4>
<div class="flex flex-wrap gap-2"> <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"> <label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input <input
v-model="form.default_supports_streaming"
type="checkbox" type="checkbox"
:checked="form.config?.streaming !== false"
class="rounded" class="rounded"
@change="setConfigField('streaming', ($event.target as HTMLInputElement).checked)"
> >
<Zap class="w-3.5 h-3.5 text-muted-foreground" /> <Zap class="w-3.5 h-3.5 text-muted-foreground" />
<span>流式输出</span> <span>流式</span>
</label> </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"> <label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input <input
v-model="form.default_supports_vision"
type="checkbox" type="checkbox"
:checked="form.config?.vision === true"
class="rounded" class="rounded"
@change="setConfigField('vision', ($event.target as HTMLInputElement).checked)"
> >
<Eye class="w-3.5 h-3.5 text-muted-foreground" /> <Eye class="w-3.5 h-3.5 text-muted-foreground" />
<span>视觉理解</span> <span>视觉</span>
</label> </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"> <label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input <input
v-model="form.default_supports_function_calling"
type="checkbox" type="checkbox"
:checked="form.config?.function_calling === true"
class="rounded" class="rounded"
@change="setConfigField('function_calling', ($event.target as HTMLInputElement).checked)"
> >
<Wrench class="w-3.5 h-3.5 text-muted-foreground" /> <Wrench class="w-3.5 h-3.5 text-muted-foreground" />
<span>工具调用</span> <span>工具</span>
</label> </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"> <label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input <input
v-model="form.default_supports_extended_thinking"
type="checkbox" type="checkbox"
:checked="form.config?.extended_thinking === true"
class="rounded" class="rounded"
@change="setConfigField('extended_thinking', ($event.target as HTMLInputElement).checked)"
> >
<Brain class="w-3.5 h-3.5 text-muted-foreground" /> <Brain class="w-3.5 h-3.5 text-muted-foreground" />
<span>深度思考</span> <span>思考</span>
</label> </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"> <label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input <input
v-model="form.default_supports_image_generation"
type="checkbox" type="checkbox"
:checked="form.config?.image_generation === true"
class="rounded" class="rounded"
@change="setConfigField('image_generation', ($event.target as HTMLInputElement).checked)"
> >
<Image class="w-3.5 h-3.5 text-muted-foreground" /> <Image class="w-3.5 h-3.5 text-muted-foreground" />
<span>图像生成</span> <span>生图</span>
</label> </label>
</div> </div>
</section> </section>
@@ -130,7 +254,7 @@
<label <label
v-for="cap in availableCapabilities" v-for="cap in availableCapabilities"
:key="cap.name" :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" class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm"
> >
<input <input
type="checkbox" type="checkbox"
@@ -153,23 +277,23 @@
v-model="tieredPricing" v-model="tieredPricing"
:show-cache1h="form.supported_capabilities?.includes('cache_1h')" :show-cache1h="form.supported_capabilities?.includes('cache_1h')"
/> />
<!-- 按次计费 -->
<div class="flex items-center gap-3 pt-2 border-t"> <div class="flex items-center gap-3 pt-2 border-t">
<Label class="text-xs whitespace-nowrap">按次计费 ($/)</Label> <Label class="text-xs whitespace-nowrap">按次计费</Label>
<Input <Input
:model-value="form.default_price_per_request ?? ''" :model-value="form.default_price_per_request ?? ''"
type="number" type="number"
step="0.001" step="0.001"
min="0" min="0"
class="w-32" class="w-24"
placeholder="留空不启用" placeholder="$/"
@update:model-value="(v) => form.default_price_per_request = parseNumberInput(v, { allowFloat: true })" @update:model-value="(v) => form.default_price_per_request = parseNumberInput(v, { allowFloat: true })"
/> />
<span class="text-xs text-muted-foreground">每次请求固定费用,可与 Token 计费叠加</span> <span class="text-xs text-muted-foreground">可与 Token 计费叠加</span>
</div> </div>
</section> </section>
</form> </form>
</div>
</div>
<template #footer> <template #footer>
<Button <Button
@@ -180,7 +304,7 @@
取消 取消
</Button> </Button>
<Button <Button
:disabled="submitting" :disabled="submitting || !form.name || !form.display_name"
@click="handleSubmit" @click="handleSubmit"
> >
<Loader2 <Loader2
@@ -189,19 +313,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 +366,147 @@ 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 allModelsCache = ref<ModelsDevModelItem[]>([]) // 全部模型(缓存)
const selectedModel = ref<ModelsDevModelItem | null>(null)
const expandedProvider = ref<string | null>(null)
// 当前显示的模型列表:有搜索词时用全部,否则只用官方
const allModels = computed(() => {
if (searchQuery.value) {
return allModelsCache.value
}
return allModelsCache.value.filter(m => m.official)
})
// 按提供商分组的模型
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 (allModelsCache.value.length > 0) return
loading.value = true
try {
// 只加载一次全部模型,过滤在 computed 中完成
allModelsCache.value = await getModelsDevList(false)
} 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 +529,70 @@ 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.supportsStructuredOutput) config.structured_output = true
if (model.supportsTemperature !== false) config.temperature = model.supportsTemperature
if (model.supportsAttachment) config.attachment = true
if (model.openWeights) config.open_weights = 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 +601,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 +632,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 +657,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>
@@ -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

@@ -1169,4 +1169,26 @@ body[theme-mode='dark'] .literary-annotation {
.scrollbar-hide::-webkit-scrollbar { .scrollbar-hide::-webkit-scrollbar {
display: none; display: none;
} }
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--border));
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.5);
}
} }

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>

12
migrate.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
# 数据库迁移脚本 - 在 Docker 容器内执行 Alembic 迁移
set -e
CONTAINER_NAME="aether-app"
echo "Running database migrations in container: $CONTAINER_NAME"
docker exec $CONTAINER_NAME alembic upgrade head
echo "Database migration completed successfully"

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,141 @@
"""
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 分钟
# 标记官方/一手提供商,前端可据此过滤第三方转售商
OFFICIAL_PROVIDERS = {
"anthropic", # Claude 官方
"openai", # OpenAI 官方
"google", # Gemini 官方
"google-vertex", # Google Vertex AI
"azure", # Azure OpenAI
"amazon-bedrock", # AWS Bedrock
"xai", # Grok 官方
"meta", # Llama 官方
"deepseek", # DeepSeek 官方
"mistral", # Mistral 官方
"cohere", # Cohere 官方
"zhipuai", # 智谱 AI 官方
"alibaba", # 阿里云(通义千问)
"minimax", # MiniMax 官方
"moonshot", # 月之暗面Kimi
"baichuan", # 百川智能
"ai21", # AI21 Labs
}
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}")
def _mark_official_providers(data: dict[str, Any]) -> dict[str, Any]:
"""为每个提供商标记是否为官方"""
result = {}
for provider_id, provider_data in data.items():
result[provider_id] = {
**provider_data,
"official": provider_id in OFFICIAL_PROVIDERS,
}
return result
@router.get("/external")
async def get_external_models(_: User = Depends(require_admin)) -> JSONResponse:
"""
获取 models.dev 的模型数据(代理请求,解决跨域问题)
数据缓存 15 分钟(使用 Redis多 worker 共享)
每个提供商会标记 official 字段,前端可据此过滤
"""
# 检查缓存
cached = await _get_cached_data()
if cached is not None:
# 兼容旧缓存:如果没有 official 字段则补全并回写
try:
needs_mark = False
for provider_data in cached.values():
if not isinstance(provider_data, dict) or "official" not in provider_data:
needs_mark = True
break
if needs_mark:
marked_cached = _mark_official_providers(cached)
await _set_cached_data(marked_cached)
return JSONResponse(content=marked_cached)
except Exception as e:
logger.warning(f"处理 models.dev 缓存数据失败,将直接返回原缓存: {e}")
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()
# 标记官方提供商
marked_data = _mark_official_providers(data)
# 写入缓存
await _set_cached_data(marked_data)
return JSONResponse(content=marked_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)}")
@router.delete("/external/cache")
async def clear_external_models_cache(_: User = Depends(require_admin)) -> dict:
"""清除 models.dev 缓存"""
redis = await get_redis_client()
if redis is None:
return {"cleared": False, "message": "Redis 未启用"}
try:
await redis.delete(CACHE_KEY)
return {"cleared": True}
except Exception as e:
raise HTTPException(status_code=500, 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

@@ -65,6 +65,21 @@ class ModelInfo:
created_at: Optional[str] # ISO 格式 created_at: Optional[str] # ISO 格式
created_timestamp: int # Unix 时间戳 created_timestamp: int # Unix 时间戳
provider_name: str provider_name: str
# 能力配置
streaming: bool = True
vision: bool = False
function_calling: bool = False
extended_thinking: bool = False
image_generation: bool = False
structured_output: bool = False
# 规格参数
context_limit: Optional[int] = None
output_limit: Optional[int] = None
# 元信息
family: Optional[str] = None
knowledge_cutoff: Optional[str] = None
input_modalities: Optional[list[str]] = None
output_modalities: Optional[list[str]] = None
def get_available_provider_ids(db: Session, api_formats: list[str]) -> set[str]: def get_available_provider_ids(db: Session, api_formats: list[str]) -> set[str]:
@@ -181,13 +196,19 @@ def _extract_model_info(model: Any) -> ModelInfo:
global_model = model.global_model global_model = model.global_model
model_id: str = global_model.name if global_model else model.provider_model_name model_id: str = global_model.name if global_model else model.provider_model_name
display_name: str = global_model.display_name if global_model else model.provider_model_name display_name: str = global_model.display_name if global_model else model.provider_model_name
description: Optional[str] = global_model.description if global_model else None
created_at: Optional[str] = ( created_at: Optional[str] = (
model.created_at.strftime("%Y-%m-%dT%H:%M:%SZ") if model.created_at else None model.created_at.strftime("%Y-%m-%dT%H:%M:%SZ") if model.created_at else None
) )
created_timestamp: int = int(model.created_at.timestamp()) if model.created_at else 0 created_timestamp: int = int(model.created_at.timestamp()) if model.created_at else 0
provider_name: str = model.provider.name if model.provider else "unknown" provider_name: str = model.provider.name if model.provider else "unknown"
# 从 GlobalModel.config 提取配置信息
config: dict = {}
description: Optional[str] = None
if global_model:
config = global_model.config or {}
description = config.get("description")
return ModelInfo( return ModelInfo(
id=model_id, id=model_id,
display_name=display_name, display_name=display_name,
@@ -195,6 +216,21 @@ def _extract_model_info(model: Any) -> ModelInfo:
created_at=created_at, created_at=created_at,
created_timestamp=created_timestamp, created_timestamp=created_timestamp,
provider_name=provider_name, provider_name=provider_name,
# 能力配置
streaming=config.get("streaming", True),
vision=config.get("vision", False),
function_calling=config.get("function_calling", False),
extended_thinking=config.get("extended_thinking", False),
image_generation=config.get("image_generation", False),
structured_output=config.get("structured_output", False),
# 规格参数
context_limit=config.get("context_limit"),
output_limit=config.get("output_limit"),
# 元信息
family=config.get("family"),
knowledge_cutoff=config.get("knowledge_cutoff"),
input_modalities=config.get("input_modalities"),
output_modalities=config.get("output_modalities"),
) )

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

@@ -251,8 +251,8 @@ def _build_gemini_list_response(
"version": "001", "version": "001",
"displayName": m.display_name, "displayName": m.display_name,
"description": m.description or f"Model {m.id}", "description": m.description or f"Model {m.id}",
"inputTokenLimit": 128000, "inputTokenLimit": m.context_limit if m.context_limit is not None else 128000,
"outputTokenLimit": 8192, "outputTokenLimit": m.output_limit if m.output_limit is not None else 8192,
"supportedGenerationMethods": ["generateContent", "countTokens"], "supportedGenerationMethods": ["generateContent", "countTokens"],
"temperature": 1.0, "temperature": 1.0,
"maxTemperature": 2.0, "maxTemperature": 2.0,
@@ -297,8 +297,8 @@ def _build_gemini_model_response(model_info: ModelInfo) -> dict:
"version": "001", "version": "001",
"displayName": model_info.display_name, "displayName": model_info.display_name,
"description": model_info.description or f"Model {model_info.id}", "description": model_info.description or f"Model {model_info.id}",
"inputTokenLimit": 128000, "inputTokenLimit": model_info.context_limit if model_info.context_limit is not None else 128000,
"outputTokenLimit": 8192, "outputTokenLimit": model_info.output_limit if model_info.output_limit is not None else 8192,
"supportedGenerationMethods": ["generateContent", "countTokens"], "supportedGenerationMethods": ["generateContent", "countTokens"],
"temperature": 1.0, "temperature": 1.0,
"maxTemperature": 2.0, "maxTemperature": 2.0,

View File

@@ -273,16 +273,17 @@ def get_db_url() -> str:
def init_db(): def init_db():
"""初始化数据库""" """初始化数据库
注意:数据库表结构由 Alembic 管理,部署时请运行 ./migrate.sh
"""
logger.info("初始化数据库...") logger.info("初始化数据库...")
# 确保引擎已创建 # 确保引擎已创建
engine = _ensure_engine() _ensure_engine()
# 创建所有表 # 数据库表结构由 Alembic 迁移管理
Base.metadata.create_all(bind=engine) # 首次部署或更新后请运行: ./migrate.sh
# 数据库表已通过SQLAlchemy自动创建
db = _SessionLocal() db = _SessionLocal()
try: try:

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 = {
"supports_vision": "vision",
"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: if global_value is not None:
return global_value 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)

View File

@@ -19,7 +19,7 @@ from ..models.database import User, UserRole
security = HTTPBearer() security = HTTPBearer()
def get_current_user( async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db) credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db)
) -> User: ) -> User:
""" """
@@ -41,7 +41,7 @@ def get_current_user(
try: try:
# 验证Token格式和签名 # 验证Token格式和签名
try: try:
payload = AuthService.verify_token(token) payload = await AuthService.verify_token(token)
except HTTPException as token_error: except HTTPException as token_error:
# 保持原始的HTTP状态码如401 Unauthorized不要转换为403 # 保持原始的HTTP状态码如401 Unauthorized不要转换为403
logger.error(f"Token验证失败: {token_error.status_code}: {token_error.detail}, Token前10位: {token[:10]}...") logger.error(f"Token验证失败: {token_error.status_code}: {token_error.detail}, Token前10位: {token[:10]}...")
@@ -122,7 +122,7 @@ def get_current_user(
) )
def get_current_user_from_header( async def get_current_user_from_header(
authorization: Optional[str] = Header(None), db: Session = Depends(get_db) authorization: Optional[str] = Header(None), db: Session = Depends(get_db)
) -> User: ) -> User:
""" """
@@ -144,7 +144,7 @@ def get_current_user_from_header(
token = authorization.replace("Bearer ", "") token = authorization.replace("Bearer ", "")
try: try:
payload = AuthService.verify_token(token) payload = await AuthService.verify_token(token)
user_id = payload.get("user_id") user_id = payload.get("user_id")
if not user_id: if not user_id: