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 @@
-
+
-
+
(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)