diff --git a/alembic/versions/20251220_1200_add_stats_daily_model_table.py b/alembic/versions/20251220_1200_add_stats_daily_model_table.py new file mode 100644 index 0000000..c5b3be6 --- /dev/null +++ b/alembic/versions/20251220_1200_add_stats_daily_model_table.py @@ -0,0 +1,86 @@ +"""add stats_daily_model table and rename provider_model_aliases + +Revision ID: a1b2c3d4e5f6 +Revises: f30f9936f6a2 +Create Date: 2025-12-20 12:00:00.000000+00:00 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +# revision identifiers, used by Alembic. +revision = 'a1b2c3d4e5f6' +down_revision = 'f30f9936f6a2' +branch_labels = None +depends_on = None + + +def table_exists(table_name: str) -> bool: + """检查表是否存在""" + bind = op.get_bind() + inspector = inspect(bind) + return table_name in inspector.get_table_names() + + +def column_exists(table_name: str, column_name: str) -> bool: + """检查列是否存在""" + bind = op.get_bind() + inspector = inspect(bind) + columns = [col['name'] for col in inspector.get_columns(table_name)] + return column_name in columns + + +def upgrade() -> None: + """创建 stats_daily_model 表,重命名 provider_model_aliases 为 provider_model_mappings""" + # 1. 创建 stats_daily_model 表 + if not table_exists('stats_daily_model'): + op.create_table( + 'stats_daily_model', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('date', sa.DateTime(timezone=True), nullable=False), + sa.Column('model', sa.String(100), nullable=False), + sa.Column('total_requests', sa.Integer(), nullable=False, default=0), + sa.Column('input_tokens', sa.BigInteger(), nullable=False, default=0), + sa.Column('output_tokens', sa.BigInteger(), nullable=False, default=0), + sa.Column('cache_creation_tokens', sa.BigInteger(), nullable=False, default=0), + sa.Column('cache_read_tokens', sa.BigInteger(), nullable=False, default=0), + sa.Column('total_cost', sa.Float(), nullable=False, default=0.0), + sa.Column('avg_response_time_ms', sa.Float(), nullable=False, default=0.0), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, + server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, + server_default=sa.func.now(), onupdate=sa.func.now()), + sa.UniqueConstraint('date', 'model', name='uq_stats_daily_model'), + ) + + # 创建索引 + op.create_index('idx_stats_daily_model_date', 'stats_daily_model', ['date']) + op.create_index('idx_stats_daily_model_date_model', 'stats_daily_model', ['date', 'model']) + + # 2. 重命名 models 表的 provider_model_aliases 为 provider_model_mappings + if column_exists('models', 'provider_model_aliases') and not column_exists('models', 'provider_model_mappings'): + op.alter_column('models', 'provider_model_aliases', new_column_name='provider_model_mappings') + + +def index_exists(table_name: str, index_name: str) -> bool: + """检查索引是否存在""" + bind = op.get_bind() + inspector = inspect(bind) + indexes = [idx['name'] for idx in inspector.get_indexes(table_name)] + return index_name in indexes + + +def downgrade() -> None: + """删除 stats_daily_model 表,恢复 provider_model_aliases 列名""" + # 恢复列名 + if column_exists('models', 'provider_model_mappings') and not column_exists('models', 'provider_model_aliases'): + op.alter_column('models', 'provider_model_mappings', new_column_name='provider_model_aliases') + + # 删除表 + if table_exists('stats_daily_model'): + if index_exists('stats_daily_model', 'idx_stats_daily_model_date_model'): + op.drop_index('idx_stats_daily_model_date_model', table_name='stats_daily_model') + if index_exists('stats_daily_model', 'idx_stats_daily_model_date'): + op.drop_index('idx_stats_daily_model_date', table_name='stats_daily_model') + op.drop_table('stats_daily_model') diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index c6ef16b..390de45 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -112,7 +112,7 @@ export interface KeyExport { export interface ModelExport { global_model_name: string | null provider_model_name: string - provider_model_aliases?: any + provider_model_mappings?: any price_per_request?: number | null tiered_pricing?: any supports_vision?: boolean | null diff --git a/frontend/src/api/endpoints/types.ts b/frontend/src/api/endpoints/types.ts index 6d373ac..2655fcf 100644 --- a/frontend/src/api/endpoints/types.ts +++ b/frontend/src/api/endpoints/types.ts @@ -244,18 +244,21 @@ export interface ConcurrencyStatus { key_max_concurrent?: number } -export interface ProviderModelAlias { +export interface ProviderModelMapping { name: string priority: number // 优先级(数字越小优先级越高) api_formats?: string[] // 作用域(适用的 API 格式),为空表示对所有格式生效 } +// 保留别名以保持向后兼容 +export type ProviderModelAlias = ProviderModelMapping + export interface Model { id: string provider_id: string global_model_id?: string // 关联的 GlobalModel ID provider_model_name: string // Provider 侧的主模型名称 - provider_model_aliases?: ProviderModelAlias[] | null // 模型名称别名列表(带优先级) + provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级) // 原始配置值(可能为空,为空时使用 GlobalModel 默认值) price_per_request?: number | null // 按次计费价格 tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置 @@ -285,7 +288,7 @@ export interface Model { export interface ModelCreate { provider_model_name: string // Provider 侧的主模型名称 - provider_model_aliases?: ProviderModelAlias[] // 模型名称别名列表(带优先级) + provider_model_mappings?: ProviderModelMapping[] // 模型名称映射列表(带优先级) global_model_id: string // 关联的 GlobalModel ID(必填) // 计费配置(可选,为空时使用 GlobalModel 默认值) price_per_request?: number // 按次计费价格 @@ -302,7 +305,7 @@ export interface ModelCreate { export interface ModelUpdate { provider_model_name?: string - provider_model_aliases?: ProviderModelAlias[] | null // 模型名称别名列表(带优先级) + provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级) global_model_id?: string price_per_request?: number | null // 按次计费价格(null 表示清空/使用默认值) tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置 diff --git a/frontend/src/features/providers/components/ModelAliasDialog.vue b/frontend/src/features/providers/components/ModelAliasDialog.vue index 7815fc6..d5ecf26 100644 --- a/frontend/src/features/providers/components/ModelAliasDialog.vue +++ b/frontend/src/features/providers/components/ModelAliasDialog.vue @@ -18,7 +18,7 @@

- +
@@ -92,7 +92,7 @@
- + (null) // 监听 open 变化 watch(() => props.open, (newOpen) => { if (newOpen && props.model) { - // 加载现有别名配置 - if (props.model.provider_model_aliases && Array.isArray(props.model.provider_model_aliases)) { - aliases.value = JSON.parse(JSON.stringify(props.model.provider_model_aliases)) + // 加载现有映射配置 + if (props.model.provider_model_mappings && Array.isArray(props.model.provider_model_mappings)) { + aliases.value = JSON.parse(JSON.stringify(props.model.provider_model_mappings)) } else { aliases.value = [] } @@ -197,16 +197,16 @@ watch(() => props.open, (newOpen) => { } }) -// 添加别名 +// 添加映射 function addAlias() { - // 新别名优先级为当前最大优先级 + 1,或者默认为 1 + // 新映射优先级为当前最大优先级 + 1,或者默认为 1 const maxPriority = aliases.value.length > 0 ? Math.max(...aliases.value.map(a => a.priority)) : 0 aliases.value.push({ name: '', priority: maxPriority + 1 }) } -// 移除别名 +// 移除映射 function removeAlias(index: number) { aliases.value.splice(index, 1) } @@ -244,7 +244,7 @@ function handleDrop(targetIndex: number) { const items = [...aliases.value] const draggedItem = items[dragIndex] - // 记录每个别名的原始优先级(在修改前) + // 记录每个映射的原始优先级(在修改前) const originalPriorityMap = new Map() items.forEach((alias, idx) => { originalPriorityMap.set(idx, alias.priority) @@ -255,7 +255,7 @@ function handleDrop(targetIndex: number) { items.splice(targetIndex, 0, draggedItem) // 按新顺序为每个组分配新的优先级 - // 同组的别名保持相同的优先级(被拖动的别名单独成组) + // 同组的映射保持相同的优先级(被拖动的映射单独成组) const groupNewPriority = new Map() // 原优先级 -> 新优先级 let currentPriority = 1 @@ -263,12 +263,12 @@ function handleDrop(targetIndex: number) { const draggedOriginalPriority = originalPriorityMap.get(dragIndex)! items.forEach((alias, newIdx) => { - // 找到这个别名在原数组中的索引 + // 找到这个映射在原数组中的索引 const originalIdx = aliases.value.findIndex(a => a === alias) const originalPriority = originalIdx >= 0 ? originalPriorityMap.get(originalIdx)! : alias.priority if (alias === draggedItem) { - // 被拖动的别名是独立的新组,获得当前优先级 + // 被拖动的映射是独立的新组,获得当前优先级 alias.priority = currentPriority currentPriority++ } else { @@ -318,11 +318,11 @@ async function handleSubmit() { submitting.value = true try { - // 过滤掉空的别名 + // 过滤掉空的映射 const validAliases = aliases.value.filter(a => a.name.trim()) await updateModel(props.providerId, props.model.id, { - provider_model_aliases: validAliases.length > 0 ? validAliases : null + provider_model_mappings: validAliases.length > 0 ? validAliases : null }) showSuccess('映射配置已保存') diff --git a/frontend/src/features/providers/components/ModelMappingDialog.vue b/frontend/src/features/providers/components/ModelMappingDialog.vue index fba527a..ce5f84f 100644 --- a/frontend/src/features/providers/components/ModelMappingDialog.vue +++ b/frontend/src/features/providers/components/ModelMappingDialog.vue @@ -419,7 +419,7 @@ const formData = ref<{ aliases: [] }) -// 检查是否有有效的别名 +// 检查是否有有效的映射 const hasValidAliases = computed(() => { return formData.value.aliases.some(a => a.name.trim()) }) @@ -538,7 +538,7 @@ function toggleGroupCollapse(apiFormat: string) { } } -// 添加别名项 +// 添加映射项 function addAliasItem() { const maxPriority = formData.value.aliases.length > 0 ? Math.max(...formData.value.aliases.map(a => a.priority)) @@ -546,7 +546,7 @@ function addAliasItem() { formData.value.aliases.push({ name: '', priority: maxPriority + 1, isEditing: true }) } -// 删除别名项 +// 删除映射项 function removeAliasItem(index: number) { formData.value.aliases.splice(index, 1) } @@ -719,7 +719,7 @@ async function handleSubmit() { return } - const currentAliases = targetModel.provider_model_aliases || [] + const currentAliases = targetModel.provider_model_mappings || [] let newAliases: ProviderModelAlias[] const buildAlias = (a: FormAlias): ProviderModelAlias => ({ @@ -762,7 +762,7 @@ async function handleSubmit() { } await updateModel(props.providerId, targetModel.id, { - provider_model_aliases: newAliases + provider_model_mappings: newAliases }) showSuccess(props.editingGroup ? '映射组已更新' : '映射已添加') diff --git a/frontend/src/features/providers/components/provider-tabs/ModelAliasesTab.vue b/frontend/src/features/providers/components/provider-tabs/ModelAliasesTab.vue index 77206f9..6115b8c 100644 --- a/frontend/src/features/providers/components/provider-tabs/ModelAliasesTab.vue +++ b/frontend/src/features/providers/components/provider-tabs/ModelAliasesTab.vue @@ -101,24 +101,24 @@ - +
- {{ alias.priority }} + {{ mapping.priority }} - + - {{ alias.name }} + {{ mapping.name }}
@@ -222,9 +222,9 @@ const aliasGroups = computed(() => { const groupMap = new Map() for (const model of models.value) { - if (!model.provider_model_aliases || !Array.isArray(model.provider_model_aliases)) continue + if (!model.provider_model_mappings || !Array.isArray(model.provider_model_mappings)) continue - for (const alias of model.provider_model_aliases) { + for (const alias of model.provider_model_mappings) { const apiFormatsKey = getApiFormatsKey(alias.api_formats) const groupKey = `${model.id}|${apiFormatsKey}` @@ -310,7 +310,7 @@ async function confirmDelete() { const { model, aliases, apiFormatsKey } = deletingGroup.value try { - const currentAliases = model.provider_model_aliases || [] + const currentAliases = model.provider_model_mappings || [] const aliasNamesToRemove = new Set(aliases.map(a => a.name)) const newAliases = currentAliases.filter((a: ProviderModelAlias) => { const currentKey = getApiFormatsKey(a.api_formats) @@ -318,7 +318,7 @@ async function confirmDelete() { }) await updateModel(props.provider.id, model.id, { - provider_model_aliases: newAliases.length > 0 ? newAliases : null + provider_model_mappings: newAliases.length > 0 ? newAliases : null }) showSuccess('映射组已删除') diff --git a/frontend/src/mocks/handler.ts b/frontend/src/mocks/handler.ts index 702ec7d..52ac2b4 100644 --- a/frontend/src/mocks/handler.ts +++ b/frontend/src/mocks/handler.ts @@ -403,7 +403,7 @@ function getUsageRecords() { return cachedUsageRecords } -// Mock 别名数据 +// Mock 映射数据 const MOCK_ALIASES = [ { id: 'alias-001', source_model: 'claude-4-sonnet', target_global_model_id: 'gm-001', target_global_model_name: 'claude-sonnet-4-20250514', target_global_model_display_name: 'Claude Sonnet 4', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }, { id: 'alias-002', source_model: 'claude-4-opus', target_global_model_id: 'gm-002', target_global_model_name: 'claude-opus-4-20250514', target_global_model_display_name: 'Claude Opus 4', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }, @@ -1682,7 +1682,7 @@ registerDynamicRoute('GET', '/api/admin/models/mappings/:mappingId', async (_con requireAdmin() const alias = MOCK_ALIASES.find(a => a.id === params.mappingId) if (!alias) { - throw { response: createMockResponse({ detail: '别名不存在' }, 404) } + throw { response: createMockResponse({ detail: '映射不存在' }, 404) } } return createMockResponse(alias) }) @@ -1693,7 +1693,7 @@ registerDynamicRoute('PATCH', '/api/admin/models/mappings/:mappingId', async (co requireAdmin() const alias = MOCK_ALIASES.find(a => a.id === params.mappingId) if (!alias) { - throw { response: createMockResponse({ detail: '别名不存在' }, 404) } + throw { response: createMockResponse({ detail: '映射不存在' }, 404) } } const body = JSON.parse(config.data || '{}') return createMockResponse({ ...alias, ...body, updated_at: new Date().toISOString() }) @@ -1705,7 +1705,7 @@ registerDynamicRoute('DELETE', '/api/admin/models/mappings/:mappingId', async (_ requireAdmin() const alias = MOCK_ALIASES.find(a => a.id === params.mappingId) if (!alias) { - throw { response: createMockResponse({ detail: '别名不存在' }, 404) } + throw { response: createMockResponse({ detail: '映射不存在' }, 404) } } return createMockResponse({ message: '删除成功(演示模式)' }) }) diff --git a/src/api/admin/monitoring/cache.py b/src/api/admin/monitoring/cache.py index 05a9896..6831bfc 100644 --- a/src/api/admin/monitoring/cache.py +++ b/src/api/admin/monitoring/cache.py @@ -1167,14 +1167,14 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter): provider.display_name or provider.name ) continue - # 检查是否在别名列表中 - if model.provider_model_aliases: - alias_names = [ + # 检查是否在映射列表中 + if model.provider_model_mappings: + mapping_list = [ a.get("name") - for a in model.provider_model_aliases + for a in model.provider_model_mappings if isinstance(a, dict) ] - if mapping_name in alias_names: + if mapping_name in mapping_list: provider_names.append( provider.display_name or provider.name ) @@ -1236,19 +1236,19 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter): try: cached_data = json.loads(cached_str) provider_model_name = cached_data.get("provider_model_name") - provider_model_aliases = cached_data.get("provider_model_aliases", []) + provider_model_mappings = cached_data.get("provider_model_mappings", []) # 获取 Provider 和 GlobalModel 信息 provider = provider_map.get(provider_id) global_model = global_model_map.get(global_model_id) if provider and global_model: - # 提取别名名称 - alias_names = [] - if provider_model_aliases: - for alias_entry in provider_model_aliases: - if isinstance(alias_entry, dict) and alias_entry.get("name"): - alias_names.append(alias_entry["name"]) + # 提取映射名称 + mapping_names = [] + if provider_model_mappings: + for mapping_entry in provider_model_mappings: + if isinstance(mapping_entry, dict) and mapping_entry.get("name"): + mapping_names.append(mapping_entry["name"]) # provider_model_name 为空时跳过 if not provider_model_name: @@ -1256,14 +1256,14 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter): # 只显示有实际映射的条目: # 1. 全局模型名 != Provider 模型名(模型名称映射) - # 2. 或者有别名配置 + # 2. 或者有映射配置 has_name_mapping = global_model.name != provider_model_name - has_aliases = len(alias_names) > 0 + has_mappings = len(mapping_names) > 0 - if has_name_mapping or has_aliases: - # 构建用于展示的别名列表 - # 如果只有名称映射没有别名,则用 global_model_name 作为"请求名称" - display_aliases = alias_names if alias_names else [global_model.name] + if has_name_mapping or has_mappings: + # 构建用于展示的映射列表 + # 如果只有名称映射没有额外映射,则用 global_model_name 作为"请求名称" + display_mappings = mapping_names if mapping_names else [global_model.name] provider_model_mappings.append({ "provider_id": provider_id, @@ -1272,7 +1272,7 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter): "global_model_name": global_model.name, "global_model_display_name": global_model.display_name, "provider_model_name": provider_model_name, - "aliases": display_aliases, + "aliases": display_mappings, "ttl": ttl if ttl > 0 else None, "hit_count": hit_count, }) diff --git a/src/api/admin/system.py b/src/api/admin/system.py index 0ae2ae2..fb0427b 100644 --- a/src/api/admin/system.py +++ b/src/api/admin/system.py @@ -436,7 +436,7 @@ class AdminExportConfigAdapter(AdminApiAdapter): { "global_model_name": global_model.name if global_model else None, "provider_model_name": model.provider_model_name, - "provider_model_aliases": model.provider_model_aliases, + "provider_model_mappings": model.provider_model_mappings, "price_per_request": model.price_per_request, "tiered_pricing": model.tiered_pricing, "supports_vision": model.supports_vision, @@ -790,8 +790,8 @@ class AdminImportConfigAdapter(AdminApiAdapter): ) elif merge_mode == "overwrite": existing_model.global_model_id = global_model_id - existing_model.provider_model_aliases = model_data.get( - "provider_model_aliases" + existing_model.provider_model_mappings = model_data.get( + "provider_model_mappings" ) existing_model.price_per_request = model_data.get( "price_per_request" @@ -824,8 +824,8 @@ class AdminImportConfigAdapter(AdminApiAdapter): provider_id=provider_id, global_model_id=global_model_id, provider_model_name=model_data["provider_model_name"], - provider_model_aliases=model_data.get( - "provider_model_aliases" + provider_model_mappings=model_data.get( + "provider_model_mappings" ), price_per_request=model_data.get("price_per_request"), tiered_pricing=model_data.get("tiered_pricing"), diff --git a/src/api/dashboard/routes.py b/src/api/dashboard/routes.py index 32a6c6b..4b043ec 100644 --- a/src/api/dashboard/routes.py +++ b/src/api/dashboard/routes.py @@ -13,7 +13,7 @@ from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.pipeline import ApiRequestPipeline from src.core.enums import UserRole from src.database import get_db -from src.models.database import ApiKey, Provider, RequestCandidate, StatsDaily, Usage +from src.models.database import ApiKey, Provider, RequestCandidate, StatsDaily, StatsDailyModel, Usage from src.models.database import User as DBUser from src.services.system.stats_aggregator import StatsAggregatorService from src.utils.cache_decorator import cache_result @@ -893,69 +893,172 @@ class DashboardDailyStatsAdapter(DashboardAdapter): }) current_date += timedelta(days=1) - # ==================== 模型统计(仍需实时查询)==================== - model_query = db.query(Usage) - if not is_admin: - model_query = model_query.filter(Usage.user_id == user.id) - model_query = model_query.filter( - and_(Usage.created_at >= start_date, Usage.created_at <= end_date) - ) - - model_stats = ( - model_query.with_entities( - Usage.model, - func.count(Usage.id).label("requests"), - func.sum(Usage.total_tokens).label("tokens"), - func.sum(Usage.total_cost_usd).label("cost"), - func.avg(Usage.response_time_ms).label("avg_response_time"), + # ==================== 模型统计 ==================== + if is_admin: + # 管理员:使用预聚合数据 + 今日实时数据 + # 历史数据从 stats_daily_model 获取 + historical_model_stats = ( + db.query(StatsDailyModel) + .filter(and_(StatsDailyModel.date >= start_date, StatsDailyModel.date < today)) + .all() ) - .group_by(Usage.model) - .order_by(func.sum(Usage.total_cost_usd).desc()) - .all() - ) - model_summary = [ - { - "model": stat.model, - "requests": stat.requests or 0, - "tokens": int(stat.tokens or 0), - "cost": float(stat.cost or 0), - "avg_response_time": ( - float(stat.avg_response_time or 0) / 1000.0 if stat.avg_response_time else 0 - ), - "cost_per_request": float(stat.cost or 0) / max(stat.requests or 1, 1), - "tokens_per_request": int(stat.tokens or 0) / max(stat.requests or 1, 1), - } - for stat in model_stats - ] + # 按模型汇总历史数据 + model_agg: dict = {} + daily_breakdown: dict = {} - daily_model_stats = ( - model_query.with_entities( - func.date(Usage.created_at).label("date"), - Usage.model, - func.count(Usage.id).label("requests"), - func.sum(Usage.total_tokens).label("tokens"), - func.sum(Usage.total_cost_usd).label("cost"), + for stat in historical_model_stats: + model = stat.model + if model not in model_agg: + model_agg[model] = { + "requests": 0, "tokens": 0, "cost": 0.0, + "total_response_time": 0.0, "response_count": 0 + } + model_agg[model]["requests"] += stat.total_requests + tokens = (stat.input_tokens + stat.output_tokens + + stat.cache_creation_tokens + stat.cache_read_tokens) + model_agg[model]["tokens"] += tokens + model_agg[model]["cost"] += stat.total_cost + if stat.avg_response_time_ms is not None: + model_agg[model]["total_response_time"] += stat.avg_response_time_ms * stat.total_requests + model_agg[model]["response_count"] += stat.total_requests + + # 按日期分组 + if stat.date.tzinfo is None: + date_utc = stat.date.replace(tzinfo=timezone.utc) + else: + date_utc = stat.date.astimezone(timezone.utc) + date_str = date_utc.astimezone(app_tz).date().isoformat() + + daily_breakdown.setdefault(date_str, []).append({ + "model": model, + "requests": stat.total_requests, + "tokens": tokens, + "cost": stat.total_cost, + }) + + # 今日实时模型统计 + today_model_stats = ( + db.query( + Usage.model, + func.count(Usage.id).label("requests"), + func.sum(Usage.total_tokens).label("tokens"), + func.sum(Usage.total_cost_usd).label("cost"), + func.avg(Usage.response_time_ms).label("avg_response_time"), + ) + .filter(Usage.created_at >= today) + .group_by(Usage.model) + .all() ) - .group_by(func.date(Usage.created_at), Usage.model) - .order_by(func.date(Usage.created_at).desc(), func.sum(Usage.total_cost_usd).desc()) - .all() - ) - breakdown = {} - for stat in daily_model_stats: - date_str = stat.date.isoformat() - breakdown.setdefault(date_str, []).append( + today_str = today_local.date().isoformat() + for stat in today_model_stats: + model = stat.model + if model not in model_agg: + model_agg[model] = { + "requests": 0, "tokens": 0, "cost": 0.0, + "total_response_time": 0.0, "response_count": 0 + } + model_agg[model]["requests"] += stat.requests or 0 + model_agg[model]["tokens"] += int(stat.tokens or 0) + model_agg[model]["cost"] += float(stat.cost or 0) + if stat.avg_response_time is not None: + model_agg[model]["total_response_time"] += float(stat.avg_response_time) * (stat.requests or 0) + model_agg[model]["response_count"] += stat.requests or 0 + + # 今日 breakdown + daily_breakdown.setdefault(today_str, []).append({ + "model": model, + "requests": stat.requests or 0, + "tokens": int(stat.tokens or 0), + "cost": float(stat.cost or 0), + }) + + # 构建 model_summary + model_summary = [] + for model, agg in model_agg.items(): + avg_rt = (agg["total_response_time"] / agg["response_count"] / 1000.0 + if agg["response_count"] > 0 else 0) + model_summary.append({ + "model": model, + "requests": agg["requests"], + "tokens": agg["tokens"], + "cost": agg["cost"], + "avg_response_time": avg_rt, + "cost_per_request": agg["cost"] / max(agg["requests"], 1), + "tokens_per_request": agg["tokens"] / max(agg["requests"], 1), + }) + model_summary.sort(key=lambda x: x["cost"], reverse=True) + + # 填充 model_breakdown + for item in formatted: + item["model_breakdown"] = daily_breakdown.get(item["date"], []) + + else: + # 普通用户:实时查询(数据量较小) + model_query = db.query(Usage).filter( + and_( + Usage.user_id == user.id, + Usage.created_at >= start_date, + Usage.created_at <= end_date + ) + ) + + model_stats = ( + model_query.with_entities( + Usage.model, + func.count(Usage.id).label("requests"), + func.sum(Usage.total_tokens).label("tokens"), + func.sum(Usage.total_cost_usd).label("cost"), + func.avg(Usage.response_time_ms).label("avg_response_time"), + ) + .group_by(Usage.model) + .order_by(func.sum(Usage.total_cost_usd).desc()) + .all() + ) + + model_summary = [ { "model": stat.model, "requests": stat.requests or 0, "tokens": int(stat.tokens or 0), "cost": float(stat.cost or 0), + "avg_response_time": ( + float(stat.avg_response_time or 0) / 1000.0 if stat.avg_response_time else 0 + ), + "cost_per_request": float(stat.cost or 0) / max(stat.requests or 1, 1), + "tokens_per_request": int(stat.tokens or 0) / max(stat.requests or 1, 1), } + for stat in model_stats + ] + + daily_model_stats = ( + model_query.with_entities( + func.date(Usage.created_at).label("date"), + Usage.model, + func.count(Usage.id).label("requests"), + func.sum(Usage.total_tokens).label("tokens"), + func.sum(Usage.total_cost_usd).label("cost"), + ) + .group_by(func.date(Usage.created_at), Usage.model) + .order_by(func.date(Usage.created_at).desc(), func.sum(Usage.total_cost_usd).desc()) + .all() ) - for item in formatted: - item["model_breakdown"] = breakdown.get(item["date"], []) + breakdown = {} + for stat in daily_model_stats: + date_str = stat.date.isoformat() + breakdown.setdefault(date_str, []).append( + { + "model": stat.model, + "requests": stat.requests or 0, + "tokens": int(stat.tokens or 0), + "cost": float(stat.cost or 0), + } + ) + + for item in formatted: + item["model_breakdown"] = breakdown.get(item["date"], []) return { "daily_stats": formatted, diff --git a/src/api/handlers/base/chat_handler_base.py b/src/api/handlers/base/chat_handler_base.py index a6e1f6d..1051d93 100644 --- a/src/api/handlers/base/chat_handler_base.py +++ b/src/api/handlers/base/chat_handler_base.py @@ -260,9 +260,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC): mapping = await mapper.get_mapping(source_model, provider_id) if mapping and mapping.model: - # 使用 select_provider_model_name 支持别名功能 - # 传入 api_key.id 作为 affinity_key,实现相同用户稳定选择同一别名 - # 传入 api_format 用于过滤适用的别名作用域 + # 使用 select_provider_model_name 支持映射功能 + # 传入 api_key.id 作为 affinity_key,实现相同用户稳定选择同一映射 + # 传入 api_format 用于过滤适用的映射作用域 affinity_key = self.api_key.id if self.api_key else None mapped_name = mapping.model.select_provider_model_name( affinity_key, api_format=self.FORMAT_ID diff --git a/src/api/handlers/base/cli_handler_base.py b/src/api/handlers/base/cli_handler_base.py index 0d8ca16..fad2c40 100644 --- a/src/api/handlers/base/cli_handler_base.py +++ b/src/api/handlers/base/cli_handler_base.py @@ -136,7 +136,7 @@ class CliMessageHandlerBase(BaseMessageHandler): 查找逻辑: 1. 直接通过 GlobalModel.name 匹配 2. 查找该 Provider 的 Model 实现 - 3. 使用 provider_model_name / provider_model_aliases 选择最终名称 + 3. 使用 provider_model_name / provider_model_mappings 选择最终名称 Args: source_model: 用户请求的模型名(必须是 GlobalModel.name) @@ -153,9 +153,9 @@ class CliMessageHandlerBase(BaseMessageHandler): logger.debug(f"[CLI] _get_mapped_model: source={source_model}, provider={provider_id[:8]}..., mapping={mapping}") if mapping and mapping.model: - # 使用 select_provider_model_name 支持别名功能 - # 传入 api_key.id 作为 affinity_key,实现相同用户稳定选择同一别名 - # 传入 api_format 用于过滤适用的别名作用域 + # 使用 select_provider_model_name 支持模型映射功能 + # 传入 api_key.id 作为 affinity_key,实现相同用户稳定选择同一映射 + # 传入 api_format 用于过滤适用的映射作用域 affinity_key = self.api_key.id if self.api_key else None mapped_name = mapping.model.select_provider_model_name( affinity_key, api_format=self.FORMAT_ID @@ -400,7 +400,7 @@ class CliMessageHandlerBase(BaseMessageHandler): ctx.provider_api_format = str(endpoint.api_format) if endpoint.api_format else "" ctx.client_api_format = ctx.api_format # 已在 process_stream 中设置 - # 获取模型映射(别名/映射 → 实际模型名) + # 获取模型映射(映射名称 → 实际模型名) mapped_model = await self._get_mapped_model( source_model=ctx.model, provider_id=str(provider.id), @@ -1382,7 +1382,7 @@ class CliMessageHandlerBase(BaseMessageHandler): provider_name = str(provider.name) provider_api_format = str(endpoint.api_format) if endpoint.api_format else "" - # 获取模型映射(别名/映射 → 实际模型名) + # 获取模型映射(映射名称 → 实际模型名) mapped_model = await self._get_mapped_model( source_model=model, provider_id=str(provider.id), diff --git a/src/core/metrics.py b/src/core/metrics.py index 729e186..1fae1d0 100644 --- a/src/core/metrics.py +++ b/src/core/metrics.py @@ -50,7 +50,7 @@ model_mapping_resolution_total = Counter( "model_mapping_resolution_total", "Total number of model mapping resolutions", ["method", "cache_hit"], - # method: direct_match, provider_model_name, alias, not_found + # method: direct_match, provider_model_name, mapping, not_found # cache_hit: true, false ) diff --git a/src/models/api.py b/src/models/api.py index 70c357b..f3fc883 100644 --- a/src/models/api.py +++ b/src/models/api.py @@ -346,9 +346,9 @@ class ModelCreate(BaseModel): provider_model_name: str = Field( ..., min_length=1, max_length=200, description="Provider 侧的主模型名称" ) - provider_model_aliases: Optional[List[dict]] = Field( + provider_model_mappings: Optional[List[dict]] = Field( None, - description="模型名称别名列表,格式: [{'name': 'alias1', 'priority': 1}, ...]", + description="模型名称映射列表,格式: [{'name': 'alias1', 'priority': 1}, ...]", ) global_model_id: str = Field(..., description="关联的 GlobalModel ID(必填)") # 按次计费配置 - 可选,为空时使用 GlobalModel 默认值 @@ -376,9 +376,9 @@ class ModelUpdate(BaseModel): """更新模型请求""" provider_model_name: Optional[str] = Field(None, min_length=1, max_length=200) - provider_model_aliases: Optional[List[dict]] = Field( + provider_model_mappings: Optional[List[dict]] = Field( None, - description="模型名称别名列表,格式: [{'name': 'alias1', 'priority': 1}, ...]", + description="模型名称映射列表,格式: [{'name': 'alias1', 'priority': 1}, ...]", ) global_model_id: Optional[str] = None # 按次计费配置 @@ -404,7 +404,7 @@ class ModelResponse(BaseModel): provider_id: str global_model_id: Optional[str] provider_model_name: str - provider_model_aliases: Optional[List[dict]] = None + provider_model_mappings: Optional[List[dict]] = None # 按次计费配置 price_per_request: Optional[float] = None diff --git a/src/models/database.py b/src/models/database.py index 5317fff..85e4a0b 100644 --- a/src/models/database.py +++ b/src/models/database.py @@ -671,10 +671,10 @@ class Model(Base): # Provider 映射配置 provider_model_name = Column(String(200), nullable=False) # Provider 侧的主模型名称 - # 模型名称别名列表(带优先级),用于同一模型在 Provider 侧有多个名称变体的场景 + # 模型名称映射列表(带优先级),用于同一模型在 Provider 侧有多个名称变体的场景 # 格式: [{"name": "Claude-Sonnet-4.5", "priority": 1}, {"name": "Claude-Sonnet-4-5", "priority": 2}] # 为空时只使用 provider_model_name - provider_model_aliases = Column(JSON, nullable=True, default=None) + provider_model_mappings = Column(JSON, nullable=True, default=None) # 按次计费配置(每次请求的固定费用,美元)- 可为空,为空时使用 GlobalModel 的默认值 price_per_request = Column(Float, nullable=True) # 每次请求固定费用 @@ -820,25 +820,25 @@ class Model(Base): ) -> str: """按优先级选择要使用的 Provider 模型名称 - 如果配置了 provider_model_aliases,按优先级选择(数字越小越优先); - 相同优先级的别名通过哈希分散实现负载均衡(与 Key 调度策略一致); + 如果配置了 provider_model_mappings,按优先级选择(数字越小越优先); + 相同优先级的映射通过哈希分散实现负载均衡(与 Key 调度策略一致); 否则返回 provider_model_name。 Args: - affinity_key: 用于哈希分散的亲和键(如用户 API Key 哈希),确保同一用户稳定选择同一别名 - api_format: 当前请求的 API 格式(如 CLAUDE、OPENAI 等),用于过滤适用的别名 + affinity_key: 用于哈希分散的亲和键(如用户 API Key 哈希),确保同一用户稳定选择同一映射 + api_format: 当前请求的 API 格式(如 CLAUDE、OPENAI 等),用于过滤适用的映射 """ import hashlib - if not self.provider_model_aliases: + if not self.provider_model_mappings: return self.provider_model_name - raw_aliases = self.provider_model_aliases - if not isinstance(raw_aliases, list) or len(raw_aliases) == 0: + raw_mappings = self.provider_model_mappings + if not isinstance(raw_mappings, list) or len(raw_mappings) == 0: return self.provider_model_name - aliases: list[dict] = [] - for raw in raw_aliases: + mappings: list[dict] = [] + for raw in raw_mappings: if not isinstance(raw, dict): continue name = raw.get("name") @@ -846,10 +846,10 @@ class Model(Base): continue # 检查 api_formats 作用域(如果配置了且当前有 api_format) - alias_api_formats = raw.get("api_formats") - if api_format and alias_api_formats: + mapping_api_formats = raw.get("api_formats") + if api_format and mapping_api_formats: # 如果配置了作用域,只有匹配时才生效 - if isinstance(alias_api_formats, list) and api_format not in alias_api_formats: + if isinstance(mapping_api_formats, list) and api_format not in mapping_api_formats: continue raw_priority = raw.get("priority", 1) @@ -860,47 +860,47 @@ class Model(Base): if priority < 1: priority = 1 - aliases.append({"name": name.strip(), "priority": priority}) + mappings.append({"name": name.strip(), "priority": priority}) - if not aliases: + if not mappings: return self.provider_model_name # 按优先级排序(数字越小越优先) - sorted_aliases = sorted(aliases, key=lambda x: x["priority"]) + sorted_mappings = sorted(mappings, key=lambda x: x["priority"]) # 获取最高优先级(最小数字) - highest_priority = sorted_aliases[0]["priority"] + highest_priority = sorted_mappings[0]["priority"] - # 获取所有最高优先级的别名 - top_priority_aliases = [ - alias for alias in sorted_aliases - if alias["priority"] == highest_priority + # 获取所有最高优先级的映射 + top_priority_mappings = [ + mapping for mapping in sorted_mappings + if mapping["priority"] == highest_priority ] - # 如果有多个相同优先级的别名,通过哈希分散选择 - if len(top_priority_aliases) > 1 and affinity_key: - # 为每个别名计算哈希得分,选择得分最小的 - def hash_score(alias: dict) -> int: - combined = f"{affinity_key}:{alias['name']}" + # 如果有多个相同优先级的映射,通过哈希分散选择 + if len(top_priority_mappings) > 1 and affinity_key: + # 为每个映射计算哈希得分,选择得分最小的 + def hash_score(mapping: dict) -> int: + combined = f"{affinity_key}:{mapping['name']}" return int(hashlib.md5(combined.encode()).hexdigest(), 16) - selected = min(top_priority_aliases, key=hash_score) - elif len(top_priority_aliases) > 1: + selected = min(top_priority_mappings, key=hash_score) + elif len(top_priority_mappings) > 1: # 没有 affinity_key 时,使用确定性选择(按名称排序后取第一个) # 避免随机选择导致同一请求重试时选择不同的模型名称 - selected = min(top_priority_aliases, key=lambda x: x["name"]) + selected = min(top_priority_mappings, key=lambda x: x["name"]) else: - selected = top_priority_aliases[0] + selected = top_priority_mappings[0] return selected["name"] def get_all_provider_model_names(self) -> list[str]: - """获取所有可用的 Provider 模型名称(主名称 + 别名)""" + """获取所有可用的 Provider 模型名称(主名称 + 映射名称)""" names = [self.provider_model_name] - if self.provider_model_aliases: - for alias in self.provider_model_aliases: - if isinstance(alias, dict) and alias.get("name"): - names.append(alias["name"]) + if self.provider_model_mappings: + for mapping in self.provider_model_mappings: + if isinstance(mapping, dict) and mapping.get("name"): + names.append(mapping["name"]) return names @@ -1308,6 +1308,53 @@ class StatsDaily(Base): ) +class StatsDailyModel(Base): + """每日模型统计快照 - 用于快速查询每日模型维度数据""" + + __tablename__ = "stats_daily_model" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + + # 统计日期 (UTC) + date = Column(DateTime(timezone=True), nullable=False, index=True) + + # 模型名称 + model = Column(String(100), nullable=False) + + # 请求统计 + total_requests = Column(Integer, default=0, nullable=False) + + # Token 统计 + input_tokens = Column(BigInteger, default=0, nullable=False) + output_tokens = Column(BigInteger, default=0, nullable=False) + cache_creation_tokens = Column(BigInteger, default=0, nullable=False) + cache_read_tokens = Column(BigInteger, default=0, nullable=False) + + # 成本统计 (USD) + total_cost = Column(Float, default=0.0, nullable=False) + + # 性能统计 + avg_response_time_ms = Column(Float, default=0.0, nullable=False) + + # 时间戳 + created_at = Column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False + ) + updated_at = Column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + # 唯一约束:每个模型每天只有一条记录 + __table_args__ = ( + UniqueConstraint("date", "model", name="uq_stats_daily_model"), + Index("idx_stats_daily_model_date", "date"), + Index("idx_stats_daily_model_date_model", "date", "model"), + ) + + class StatsSummary(Base): """全局统计汇总 - 单行记录,存储截止到昨天的累计数据""" diff --git a/src/services/cache/aware_scheduler.py b/src/services/cache/aware_scheduler.py index 4737629..5a3eadc 100644 --- a/src/services/cache/aware_scheduler.py +++ b/src/services/cache/aware_scheduler.py @@ -589,14 +589,14 @@ class CacheAwareScheduler: target_format = normalize_api_format(api_format) - # 0. 解析 model_name 到 GlobalModel(支持直接匹配和别名匹配,使用 ModelCacheService) + # 0. 解析 model_name 到 GlobalModel(支持直接匹配和映射名匹配,使用 ModelCacheService) global_model = await ModelCacheService.resolve_global_model_by_name_or_alias(db, model_name) if not global_model: logger.warning(f"GlobalModel not found: {model_name}") raise ModelNotSupportedException(model=model_name) - # 使用 GlobalModel.id 作为缓存亲和性的模型标识,确保别名和规范名都能命中同一个缓存 + # 使用 GlobalModel.id 作为缓存亲和性的模型标识,确保映射名和规范名都能命中同一个缓存 global_model_id: str = str(global_model.id) requested_model_name = model_name resolved_model_name = str(global_model.name) @@ -751,19 +751,19 @@ class CacheAwareScheduler: 支持两种匹配方式: 1. 直接匹配 GlobalModel.name - 2. 通过 ModelCacheService 匹配别名(全局查找) + 2. 通过 ModelCacheService 匹配映射名(全局查找) Args: db: 数据库会话 provider: Provider 对象 - model_name: 模型名称(可以是 GlobalModel.name 或别名) + model_name: 模型名称(可以是 GlobalModel.name 或映射名) is_stream: 是否是流式请求,如果为 True 则同时检查流式支持 capability_requirements: 能力需求(可选),用于检查模型是否支持所需能力 Returns: (is_supported, skip_reason, supported_capabilities) - 是否支持、跳过原因、模型支持的能力列表 """ - # 使用 ModelCacheService 解析模型名称(支持别名) + # 使用 ModelCacheService 解析模型名称(支持映射名) global_model = await ModelCacheService.resolve_global_model_by_name_or_alias(db, model_name) if not global_model: @@ -914,7 +914,7 @@ class CacheAwareScheduler: db: 数据库会话 providers: Provider 列表 target_format: 目标 API 格式 - model_name: 模型名称(用户请求的名称,可能是别名) + model_name: 模型名称(用户请求的名称,可能是映射名) affinity_key: 亲和性标识符(通常为API Key ID) resolved_model_name: 解析后的 GlobalModel.name(用于 Key.allowed_models 校验) max_candidates: 最大候选数 diff --git a/src/services/cache/model_cache.py b/src/services/cache/model_cache.py index 9ad3cac..a5adde8 100644 --- a/src/services/cache/model_cache.py +++ b/src/services/cache/model_cache.py @@ -198,7 +198,7 @@ class ModelCacheService: provider_id: Optional[str] = None, global_model_id: Optional[str] = None, provider_model_name: Optional[str] = None, - provider_model_aliases: Optional[list] = None, + provider_model_mappings: Optional[list] = None, ) -> None: """清除 Model 缓存 @@ -207,7 +207,7 @@ class ModelCacheService: provider_id: Provider ID(用于清除 provider_global 缓存) global_model_id: GlobalModel ID(用于清除 provider_global 缓存) provider_model_name: provider_model_name(用于清除 resolve 缓存) - provider_model_aliases: 映射名称列表(用于清除 resolve 缓存) + provider_model_mappings: 映射名称列表(用于清除 resolve 缓存) """ # 清除 model:id 缓存 await CacheService.delete(f"model:id:{model_id}") @@ -222,16 +222,16 @@ class ModelCacheService: else: logger.debug(f"Model 缓存已清除: {model_id}") - # 清除 resolve 缓存(provider_model_name 和 aliases 可能都被用作解析 key) + # 清除 resolve 缓存(provider_model_name 和 mappings 可能都被用作解析 key) resolve_keys_to_clear = [] if provider_model_name: resolve_keys_to_clear.append(provider_model_name) - if provider_model_aliases: - for alias_entry in provider_model_aliases: - if isinstance(alias_entry, dict): - alias_name = alias_entry.get("name", "").strip() - if alias_name: - resolve_keys_to_clear.append(alias_name) + if provider_model_mappings: + for mapping_entry in provider_model_mappings: + if isinstance(mapping_entry, dict): + mapping_name = mapping_entry.get("name", "").strip() + if mapping_name: + resolve_keys_to_clear.append(mapping_name) for key in resolve_keys_to_clear: await CacheService.delete(f"global_model:resolve:{key}") @@ -261,8 +261,8 @@ class ModelCacheService: 2. 通过 provider_model_name 匹配(查询 Model 表) 3. 直接匹配 GlobalModel.name(兜底) - 注意:此方法不使用 provider_model_aliases 进行全局解析。 - provider_model_aliases 是 Provider 级别的别名配置,只在特定 Provider 上下文中生效, + 注意:此方法不使用 provider_model_mappings 进行全局解析。 + provider_model_mappings 是 Provider 级别的映射配置,只在特定 Provider 上下文中生效, 由 resolve_provider_model() 处理。 Args: @@ -301,9 +301,9 @@ class ModelCacheService: logger.debug(f"GlobalModel 缓存命中(映射解析): {normalized_name}") return ModelCacheService._dict_to_global_model(cached_data) - # 2. 通过 provider_model_name 匹配(不考虑 provider_model_aliases) - # 重要:provider_model_aliases 是 Provider 级别的别名配置,只在特定 Provider 上下文中生效 - # 全局解析不应该受到某个 Provider 别名配置的影响 + # 2. 通过 provider_model_name 匹配(不考虑 provider_model_mappings) + # 重要:provider_model_mappings 是 Provider 级别的映射配置,只在特定 Provider 上下文中生效 + # 全局解析不应该受到某个 Provider 映射配置的影响 # 例如:Provider A 把 "haiku" 映射到 "sonnet",不应该影响 Provider B 的 "haiku" 解析 from src.models.database import Provider @@ -401,7 +401,7 @@ class ModelCacheService: "provider_id": model.provider_id, "global_model_id": model.global_model_id, "provider_model_name": model.provider_model_name, - "provider_model_aliases": getattr(model, "provider_model_aliases", None), + "provider_model_mappings": getattr(model, "provider_model_mappings", None), "is_active": model.is_active, "is_available": model.is_available if hasattr(model, "is_available") else True, "price_per_request": ( @@ -424,7 +424,7 @@ class ModelCacheService: provider_id=model_dict["provider_id"], global_model_id=model_dict["global_model_id"], provider_model_name=model_dict["provider_model_name"], - provider_model_aliases=model_dict.get("provider_model_aliases"), + provider_model_mappings=model_dict.get("provider_model_mappings"), is_active=model_dict["is_active"], is_available=model_dict.get("is_available", True), price_per_request=model_dict.get("price_per_request"), diff --git a/src/services/model/cost.py b/src/services/model/cost.py index 4413ae7..17c768c 100644 --- a/src/services/model/cost.py +++ b/src/services/model/cost.py @@ -443,7 +443,7 @@ class ModelCostService: Args: provider: Provider 对象或提供商名称 - model: 用户请求的模型名(可能是 GlobalModel.name 或别名) + model: 用户请求的模型名(可能是 GlobalModel.name 或映射名) Returns: 按次计费价格,如果没有配置则返回 None diff --git a/src/services/model/mapper.py b/src/services/model/mapper.py index a224510..c7ec092 100644 --- a/src/services/model/mapper.py +++ b/src/services/model/mapper.py @@ -84,11 +84,11 @@ class ModelMapperMiddleware: 获取模型映射 简化后的逻辑: - 1. 通过 GlobalModel.name 或别名解析 GlobalModel + 1. 通过 GlobalModel.name 或映射名解析 GlobalModel 2. 找到 GlobalModel 后,查找该 Provider 的 Model 实现 Args: - source_model: 用户请求的模型名(可以是 GlobalModel.name 或别名) + source_model: 用户请求的模型名(可以是 GlobalModel.name 或映射名) provider_id: 提供商ID (UUID) Returns: @@ -101,7 +101,7 @@ class ModelMapperMiddleware: mapping = None - # 步骤 1: 解析 GlobalModel(支持别名) + # 步骤 1: 解析 GlobalModel(支持映射名) global_model = await ModelCacheService.resolve_global_model_by_name_or_alias( self.db, source_model ) diff --git a/src/services/model/service.py b/src/services/model/service.py index 764701c..717df6a 100644 --- a/src/services/model/service.py +++ b/src/services/model/service.py @@ -51,7 +51,7 @@ class ModelService: provider_id=provider_id, global_model_id=model_data.global_model_id, provider_model_name=model_data.provider_model_name, - provider_model_aliases=model_data.provider_model_aliases, + provider_model_mappings=model_data.provider_model_mappings, price_per_request=model_data.price_per_request, tiered_pricing=model_data.tiered_pricing, supports_vision=model_data.supports_vision, @@ -153,9 +153,9 @@ class ModelService: if not model: raise NotFoundException(f"模型 {model_id} 不存在") - # 保存旧的别名,用于清除缓存 + # 保存旧的映射,用于清除缓存 old_provider_model_name = model.provider_model_name - old_provider_model_aliases = model.provider_model_aliases + old_provider_model_mappings = model.provider_model_mappings # 更新字段 update_data = model_data.model_dump(exclude_unset=True) @@ -174,26 +174,26 @@ class ModelService: db.refresh(model) # 清除 Redis 缓存(异步执行,不阻塞返回) - # 先清除旧的别名缓存 + # 先清除旧的映射缓存 asyncio.create_task( ModelCacheService.invalidate_model_cache( model_id=model.id, provider_id=model.provider_id, global_model_id=model.global_model_id, provider_model_name=old_provider_model_name, - provider_model_aliases=old_provider_model_aliases, + provider_model_mappings=old_provider_model_mappings, ) ) - # 再清除新的别名缓存(如果有变化) + # 再清除新的映射缓存(如果有变化) if (model.provider_model_name != old_provider_model_name or - model.provider_model_aliases != old_provider_model_aliases): + model.provider_model_mappings != old_provider_model_mappings): asyncio.create_task( ModelCacheService.invalidate_model_cache( model_id=model.id, provider_id=model.provider_id, global_model_id=model.global_model_id, provider_model_name=model.provider_model_name, - provider_model_aliases=model.provider_model_aliases, + provider_model_mappings=model.provider_model_mappings, ) ) @@ -246,7 +246,7 @@ class ModelService: "provider_id": model.provider_id, "global_model_id": model.global_model_id, "provider_model_name": model.provider_model_name, - "provider_model_aliases": model.provider_model_aliases, + "provider_model_mappings": model.provider_model_mappings, } try: @@ -260,7 +260,7 @@ class ModelService: provider_id=cache_info["provider_id"], global_model_id=cache_info["global_model_id"], provider_model_name=cache_info["provider_model_name"], - provider_model_aliases=cache_info["provider_model_aliases"], + provider_model_mappings=cache_info["provider_model_mappings"], ) ) @@ -297,7 +297,7 @@ class ModelService: provider_id=model.provider_id, global_model_id=model.global_model_id, provider_model_name=model.provider_model_name, - provider_model_aliases=model.provider_model_aliases, + provider_model_mappings=model.provider_model_mappings, ) ) @@ -390,7 +390,7 @@ class ModelService: provider_id=model.provider_id, global_model_id=model.global_model_id, provider_model_name=model.provider_model_name, - provider_model_aliases=model.provider_model_aliases, + provider_model_mappings=model.provider_model_mappings, # 原始配置值(可能为空) price_per_request=model.price_per_request, tiered_pricing=model.tiered_pricing, diff --git a/src/services/system/cleanup_scheduler.py b/src/services/system/cleanup_scheduler.py index 647f429..4588b19 100644 --- a/src/services/system/cleanup_scheduler.py +++ b/src/services/system/cleanup_scheduler.py @@ -259,6 +259,9 @@ class CleanupScheduler: StatsAggregatorService.aggregate_daily_stats( db, current_date_local ) + StatsAggregatorService.aggregate_daily_model_stats( + db, current_date_local + ) for (user_id,) in users: try: StatsAggregatorService.aggregate_user_daily_stats( @@ -291,6 +294,7 @@ class CleanupScheduler: yesterday_local = today_local - timedelta(days=1) StatsAggregatorService.aggregate_daily_stats(db, yesterday_local) + StatsAggregatorService.aggregate_daily_model_stats(db, yesterday_local) users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all() for (user_id,) in users: diff --git a/src/services/system/stats_aggregator.py b/src/services/system/stats_aggregator.py index 3115a31..bc9415e 100644 --- a/src/services/system/stats_aggregator.py +++ b/src/services/system/stats_aggregator.py @@ -16,6 +16,7 @@ from src.models.database import ( ApiKey, RequestCandidate, StatsDaily, + StatsDailyModel, StatsSummary, StatsUserDaily, Usage, @@ -219,6 +220,120 @@ class StatsAggregatorService: logger.info(f"[StatsAggregator] 聚合日期 {date.date()} 完成: {computed['total_requests']} 请求") return stats + @staticmethod + def aggregate_daily_model_stats(db: Session, date: datetime) -> list[StatsDailyModel]: + """聚合指定日期的模型维度统计数据 + + Args: + db: 数据库会话 + date: 要聚合的业务日期 + + Returns: + StatsDailyModel 记录列表 + """ + day_start, day_end = _get_business_day_range(date) + + # 按模型分组统计 + model_stats = ( + db.query( + Usage.model, + func.count(Usage.id).label("total_requests"), + func.sum(Usage.input_tokens).label("input_tokens"), + func.sum(Usage.output_tokens).label("output_tokens"), + func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"), + func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"), + func.sum(Usage.total_cost_usd).label("total_cost"), + func.avg(Usage.response_time_ms).label("avg_response_time"), + ) + .filter(and_(Usage.created_at >= day_start, Usage.created_at < day_end)) + .group_by(Usage.model) + .all() + ) + + results = [] + for stat in model_stats: + if not stat.model: + continue + + existing = ( + db.query(StatsDailyModel) + .filter(and_(StatsDailyModel.date == day_start, StatsDailyModel.model == stat.model)) + .first() + ) + + if existing: + record = existing + else: + record = StatsDailyModel( + id=str(uuid.uuid4()), date=day_start, model=stat.model + ) + + record.total_requests = stat.total_requests or 0 + record.input_tokens = int(stat.input_tokens or 0) + record.output_tokens = int(stat.output_tokens or 0) + record.cache_creation_tokens = int(stat.cache_creation_tokens or 0) + record.cache_read_tokens = int(stat.cache_read_tokens or 0) + record.total_cost = float(stat.total_cost or 0) + record.avg_response_time_ms = float(stat.avg_response_time or 0) + + if not existing: + db.add(record) + results.append(record) + + db.commit() + logger.info( + f"[StatsAggregator] 聚合日期 {date.date()} 模型统计完成: {len(results)} 个模型" + ) + return results + + @staticmethod + def get_daily_model_stats(db: Session, start_date: datetime, end_date: datetime) -> list[dict]: + """获取日期范围内的模型统计数据(优先使用预聚合) + + Args: + db: 数据库会话 + start_date: 开始日期 (UTC) + end_date: 结束日期 (UTC) + + Returns: + 模型统计数据列表 + """ + from zoneinfo import ZoneInfo + + app_tz = ZoneInfo(APP_TIMEZONE) + + # 从预聚合表获取历史数据 + stats = ( + db.query(StatsDailyModel) + .filter(and_(StatsDailyModel.date >= start_date, StatsDailyModel.date < end_date)) + .order_by(StatsDailyModel.date.asc(), StatsDailyModel.total_cost.desc()) + .all() + ) + + # 转换为字典格式,按日期分组 + result = [] + for stat in stats: + # 转换日期为业务时区 + if stat.date.tzinfo is None: + date_utc = stat.date.replace(tzinfo=timezone.utc) + else: + date_utc = stat.date.astimezone(timezone.utc) + date_str = date_utc.astimezone(app_tz).date().isoformat() + + result.append({ + "date": date_str, + "model": stat.model, + "requests": stat.total_requests, + "tokens": ( + stat.input_tokens + stat.output_tokens + + stat.cache_creation_tokens + stat.cache_read_tokens + ), + "cost": stat.total_cost, + "avg_response_time": stat.avg_response_time_ms / 1000.0 if stat.avg_response_time_ms else 0, + }) + + return result + @staticmethod def aggregate_user_daily_stats( db: Session, user_id: str, date: datetime @@ -497,6 +612,7 @@ class StatsAggregatorService: current_date = start_date while current_date < today_local: StatsAggregatorService.aggregate_daily_stats(db, current_date) + StatsAggregatorService.aggregate_daily_model_stats(db, current_date) count += 1 current_date += timedelta(days=1)