feat: add daily model statistics aggregation with stats_daily_model table

This commit is contained in:
fawney19
2025-12-20 02:39:10 +08:00
parent e2e7996a54
commit 4e1aed9976
22 changed files with 561 additions and 202 deletions

View File

@@ -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')

View File

@@ -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

View File

@@ -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 // 阶梯计费配置

View File

@@ -18,7 +18,7 @@
</p>
</div>
<!-- 别名列表 -->
<!-- 映射列表 -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<Label class="text-sm font-medium">名称映射</Label>
@@ -92,7 +92,7 @@
</div>
</div>
<!-- 别名输入框 -->
<!-- 映射输入框 -->
<Input
v-model="alias.name"
placeholder="映射名称,如 Claude-Sonnet-4.5"
@@ -184,9 +184,9 @@ const editingPriorityIndex = ref<number | null>(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<number, number>()
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<number, number>() // 原优先级 -> 新优先级
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('映射配置已保存')

View File

@@ -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 ? '映射组已更新' : '映射已添加')

View File

@@ -101,24 +101,24 @@
</div>
</div>
<!-- 展开的别名列表 -->
<!-- 展开的映射列表 -->
<div
v-show="expandedAliasGroups.has(`${group.model.id}-${group.apiFormatsKey}`)"
class="bg-muted/30 border-t border-border/30"
>
<div class="px-4 py-2 space-y-1">
<div
v-for="alias in group.aliases"
:key="alias.name"
v-for="mapping in group.aliases"
:key="mapping.name"
class="flex items-center gap-2 py-1"
>
<!-- 优先级标签 -->
<span class="inline-flex items-center justify-center w-5 h-5 rounded bg-background border text-xs font-medium shrink-0">
{{ alias.priority }}
{{ mapping.priority }}
</span>
<!-- 别名名称 -->
<!-- 映射名称 -->
<span class="font-mono text-sm truncate">
{{ alias.name }}
{{ mapping.name }}
</span>
</div>
</div>
@@ -222,9 +222,9 @@ const aliasGroups = computed<AliasGroup[]>(() => {
const groupMap = new Map<string, AliasGroup>()
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('映射组已删除')

View File

@@ -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: '删除成功(演示模式)' })
})

View File

@@ -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,
})

View File

@@ -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"),

View File

@@ -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,

View File

@@ -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

View File

@@ -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),

View File

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

View File

@@ -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

View File

@@ -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):
"""全局统计汇总 - 单行记录,存储截止到昨天的累计数据"""

View File

@@ -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: 最大候选数

View File

@@ -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"),

View File

@@ -443,7 +443,7 @@ class ModelCostService:
Args:
provider: Provider 对象或提供商名称
model: 用户请求的模型名(可能是 GlobalModel.name 或名)
model: 用户请求的模型名(可能是 GlobalModel.name 或映射名)
Returns:
按次计费价格,如果没有配置则返回 None

View File

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

View File

@@ -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,

View File

@@ -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:

View File

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