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 { export interface ModelExport {
global_model_name: string | null global_model_name: string | null
provider_model_name: string provider_model_name: string
provider_model_aliases?: any provider_model_mappings?: any
price_per_request?: number | null price_per_request?: number | null
tiered_pricing?: any tiered_pricing?: any
supports_vision?: boolean | null supports_vision?: boolean | null

View File

@@ -244,18 +244,21 @@ export interface ConcurrencyStatus {
key_max_concurrent?: number key_max_concurrent?: number
} }
export interface ProviderModelAlias { export interface ProviderModelMapping {
name: string name: string
priority: number // 优先级(数字越小优先级越高) priority: number // 优先级(数字越小优先级越高)
api_formats?: string[] // 作用域(适用的 API 格式),为空表示对所有格式生效 api_formats?: string[] // 作用域(适用的 API 格式),为空表示对所有格式生效
} }
// 保留别名以保持向后兼容
export type ProviderModelAlias = ProviderModelMapping
export interface Model { export interface Model {
id: string id: string
provider_id: string provider_id: string
global_model_id?: string // 关联的 GlobalModel ID global_model_id?: string // 关联的 GlobalModel ID
provider_model_name: string // Provider 侧的主模型名称 provider_model_name: string // Provider 侧的主模型名称
provider_model_aliases?: ProviderModelAlias[] | null // 模型名称别名列表(带优先级) provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
// 原始配置值(可能为空,为空时使用 GlobalModel 默认值) // 原始配置值(可能为空,为空时使用 GlobalModel 默认值)
price_per_request?: number | null // 按次计费价格 price_per_request?: number | null // 按次计费价格
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置 tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
@@ -285,7 +288,7 @@ export interface Model {
export interface ModelCreate { export interface ModelCreate {
provider_model_name: string // Provider 侧的主模型名称 provider_model_name: string // Provider 侧的主模型名称
provider_model_aliases?: ProviderModelAlias[] // 模型名称别名列表(带优先级) provider_model_mappings?: ProviderModelMapping[] // 模型名称映射列表(带优先级)
global_model_id: string // 关联的 GlobalModel ID必填 global_model_id: string // 关联的 GlobalModel ID必填
// 计费配置(可选,为空时使用 GlobalModel 默认值) // 计费配置(可选,为空时使用 GlobalModel 默认值)
price_per_request?: number // 按次计费价格 price_per_request?: number // 按次计费价格
@@ -302,7 +305,7 @@ export interface ModelCreate {
export interface ModelUpdate { export interface ModelUpdate {
provider_model_name?: string provider_model_name?: string
provider_model_aliases?: ProviderModelAlias[] | null // 模型名称别名列表(带优先级) provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
global_model_id?: string global_model_id?: string
price_per_request?: number | null // 按次计费价格null 表示清空/使用默认值) price_per_request?: number | null // 按次计费价格null 表示清空/使用默认值)
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置 tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置

View File

@@ -18,7 +18,7 @@
</p> </p>
</div> </div>
<!-- 别名列表 --> <!-- 映射列表 -->
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Label class="text-sm font-medium">名称映射</Label> <Label class="text-sm font-medium">名称映射</Label>
@@ -92,7 +92,7 @@
</div> </div>
</div> </div>
<!-- 别名输入框 --> <!-- 映射输入框 -->
<Input <Input
v-model="alias.name" v-model="alias.name"
placeholder="映射名称,如 Claude-Sonnet-4.5" placeholder="映射名称,如 Claude-Sonnet-4.5"
@@ -184,9 +184,9 @@ const editingPriorityIndex = ref<number | null>(null)
// 监听 open 变化 // 监听 open 变化
watch(() => props.open, (newOpen) => { watch(() => props.open, (newOpen) => {
if (newOpen && props.model) { if (newOpen && props.model) {
// 加载现有别名配置 // 加载现有映射配置
if (props.model.provider_model_aliases && Array.isArray(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_aliases)) aliases.value = JSON.parse(JSON.stringify(props.model.provider_model_mappings))
} else { } else {
aliases.value = [] aliases.value = []
} }
@@ -197,16 +197,16 @@ watch(() => props.open, (newOpen) => {
} }
}) })
// 添加别名 // 添加映射
function addAlias() { function addAlias() {
// 新别名优先级为当前最大优先级 + 1或者默认为 1 // 新映射优先级为当前最大优先级 + 1或者默认为 1
const maxPriority = aliases.value.length > 0 const maxPriority = aliases.value.length > 0
? Math.max(...aliases.value.map(a => a.priority)) ? Math.max(...aliases.value.map(a => a.priority))
: 0 : 0
aliases.value.push({ name: '', priority: maxPriority + 1 }) aliases.value.push({ name: '', priority: maxPriority + 1 })
} }
// 移除别名 // 移除映射
function removeAlias(index: number) { function removeAlias(index: number) {
aliases.value.splice(index, 1) aliases.value.splice(index, 1)
} }
@@ -244,7 +244,7 @@ function handleDrop(targetIndex: number) {
const items = [...aliases.value] const items = [...aliases.value]
const draggedItem = items[dragIndex] const draggedItem = items[dragIndex]
// 记录每个别名的原始优先级(在修改前) // 记录每个映射的原始优先级(在修改前)
const originalPriorityMap = new Map<number, number>() const originalPriorityMap = new Map<number, number>()
items.forEach((alias, idx) => { items.forEach((alias, idx) => {
originalPriorityMap.set(idx, alias.priority) originalPriorityMap.set(idx, alias.priority)
@@ -255,7 +255,7 @@ function handleDrop(targetIndex: number) {
items.splice(targetIndex, 0, draggedItem) items.splice(targetIndex, 0, draggedItem)
// 按新顺序为每个组分配新的优先级 // 按新顺序为每个组分配新的优先级
// 同组的别名保持相同的优先级(被拖动的别名单独成组) // 同组的映射保持相同的优先级(被拖动的映射单独成组)
const groupNewPriority = new Map<number, number>() // 原优先级 -> 新优先级 const groupNewPriority = new Map<number, number>() // 原优先级 -> 新优先级
let currentPriority = 1 let currentPriority = 1
@@ -263,12 +263,12 @@ function handleDrop(targetIndex: number) {
const draggedOriginalPriority = originalPriorityMap.get(dragIndex)! const draggedOriginalPriority = originalPriorityMap.get(dragIndex)!
items.forEach((alias, newIdx) => { items.forEach((alias, newIdx) => {
// 找到这个别名在原数组中的索引 // 找到这个映射在原数组中的索引
const originalIdx = aliases.value.findIndex(a => a === alias) const originalIdx = aliases.value.findIndex(a => a === alias)
const originalPriority = originalIdx >= 0 ? originalPriorityMap.get(originalIdx)! : alias.priority const originalPriority = originalIdx >= 0 ? originalPriorityMap.get(originalIdx)! : alias.priority
if (alias === draggedItem) { if (alias === draggedItem) {
// 被拖动的别名是独立的新组,获得当前优先级 // 被拖动的映射是独立的新组,获得当前优先级
alias.priority = currentPriority alias.priority = currentPriority
currentPriority++ currentPriority++
} else { } else {
@@ -318,11 +318,11 @@ async function handleSubmit() {
submitting.value = true submitting.value = true
try { try {
// 过滤掉空的别名 // 过滤掉空的映射
const validAliases = aliases.value.filter(a => a.name.trim()) const validAliases = aliases.value.filter(a => a.name.trim())
await updateModel(props.providerId, props.model.id, { await updateModel(props.providerId, props.model.id, {
provider_model_aliases: validAliases.length > 0 ? validAliases : null provider_model_mappings: validAliases.length > 0 ? validAliases : null
}) })
showSuccess('映射配置已保存') showSuccess('映射配置已保存')

View File

@@ -419,7 +419,7 @@ const formData = ref<{
aliases: [] aliases: []
}) })
// 检查是否有有效的别名 // 检查是否有有效的映射
const hasValidAliases = computed(() => { const hasValidAliases = computed(() => {
return formData.value.aliases.some(a => a.name.trim()) return formData.value.aliases.some(a => a.name.trim())
}) })
@@ -538,7 +538,7 @@ function toggleGroupCollapse(apiFormat: string) {
} }
} }
// 添加别名 // 添加映射
function addAliasItem() { function addAliasItem() {
const maxPriority = formData.value.aliases.length > 0 const maxPriority = formData.value.aliases.length > 0
? Math.max(...formData.value.aliases.map(a => a.priority)) ? 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 }) formData.value.aliases.push({ name: '', priority: maxPriority + 1, isEditing: true })
} }
// 删除别名 // 删除映射
function removeAliasItem(index: number) { function removeAliasItem(index: number) {
formData.value.aliases.splice(index, 1) formData.value.aliases.splice(index, 1)
} }
@@ -719,7 +719,7 @@ async function handleSubmit() {
return return
} }
const currentAliases = targetModel.provider_model_aliases || [] const currentAliases = targetModel.provider_model_mappings || []
let newAliases: ProviderModelAlias[] let newAliases: ProviderModelAlias[]
const buildAlias = (a: FormAlias): ProviderModelAlias => ({ const buildAlias = (a: FormAlias): ProviderModelAlias => ({
@@ -762,7 +762,7 @@ async function handleSubmit() {
} }
await updateModel(props.providerId, targetModel.id, { await updateModel(props.providerId, targetModel.id, {
provider_model_aliases: newAliases provider_model_mappings: newAliases
}) })
showSuccess(props.editingGroup ? '映射组已更新' : '映射已添加') showSuccess(props.editingGroup ? '映射组已更新' : '映射已添加')

View File

@@ -101,24 +101,24 @@
</div> </div>
</div> </div>
<!-- 展开的别名列表 --> <!-- 展开的映射列表 -->
<div <div
v-show="expandedAliasGroups.has(`${group.model.id}-${group.apiFormatsKey}`)" v-show="expandedAliasGroups.has(`${group.model.id}-${group.apiFormatsKey}`)"
class="bg-muted/30 border-t border-border/30" class="bg-muted/30 border-t border-border/30"
> >
<div class="px-4 py-2 space-y-1"> <div class="px-4 py-2 space-y-1">
<div <div
v-for="alias in group.aliases" v-for="mapping in group.aliases"
:key="alias.name" :key="mapping.name"
class="flex items-center gap-2 py-1" 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"> <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>
<!-- 别名名称 --> <!-- 映射名称 -->
<span class="font-mono text-sm truncate"> <span class="font-mono text-sm truncate">
{{ alias.name }} {{ mapping.name }}
</span> </span>
</div> </div>
</div> </div>
@@ -222,9 +222,9 @@ const aliasGroups = computed<AliasGroup[]>(() => {
const groupMap = new Map<string, AliasGroup>() const groupMap = new Map<string, AliasGroup>()
for (const model of models.value) { 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 apiFormatsKey = getApiFormatsKey(alias.api_formats)
const groupKey = `${model.id}|${apiFormatsKey}` const groupKey = `${model.id}|${apiFormatsKey}`
@@ -310,7 +310,7 @@ async function confirmDelete() {
const { model, aliases, apiFormatsKey } = deletingGroup.value const { model, aliases, apiFormatsKey } = deletingGroup.value
try { try {
const currentAliases = model.provider_model_aliases || [] const currentAliases = model.provider_model_mappings || []
const aliasNamesToRemove = new Set(aliases.map(a => a.name)) const aliasNamesToRemove = new Set(aliases.map(a => a.name))
const newAliases = currentAliases.filter((a: ProviderModelAlias) => { const newAliases = currentAliases.filter((a: ProviderModelAlias) => {
const currentKey = getApiFormatsKey(a.api_formats) const currentKey = getApiFormatsKey(a.api_formats)
@@ -318,7 +318,7 @@ async function confirmDelete() {
}) })
await updateModel(props.provider.id, model.id, { await updateModel(props.provider.id, model.id, {
provider_model_aliases: newAliases.length > 0 ? newAliases : null provider_model_mappings: newAliases.length > 0 ? newAliases : null
}) })
showSuccess('映射组已删除') showSuccess('映射组已删除')

View File

@@ -403,7 +403,7 @@ function getUsageRecords() {
return cachedUsageRecords return cachedUsageRecords
} }
// Mock 别名数据 // Mock 映射数据
const MOCK_ALIASES = [ 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-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' }, { 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() requireAdmin()
const alias = MOCK_ALIASES.find(a => a.id === params.mappingId) const alias = MOCK_ALIASES.find(a => a.id === params.mappingId)
if (!alias) { if (!alias) {
throw { response: createMockResponse({ detail: '别名不存在' }, 404) } throw { response: createMockResponse({ detail: '映射不存在' }, 404) }
} }
return createMockResponse(alias) return createMockResponse(alias)
}) })
@@ -1693,7 +1693,7 @@ registerDynamicRoute('PATCH', '/api/admin/models/mappings/:mappingId', async (co
requireAdmin() requireAdmin()
const alias = MOCK_ALIASES.find(a => a.id === params.mappingId) const alias = MOCK_ALIASES.find(a => a.id === params.mappingId)
if (!alias) { if (!alias) {
throw { response: createMockResponse({ detail: '别名不存在' }, 404) } throw { response: createMockResponse({ detail: '映射不存在' }, 404) }
} }
const body = JSON.parse(config.data || '{}') const body = JSON.parse(config.data || '{}')
return createMockResponse({ ...alias, ...body, updated_at: new Date().toISOString() }) return createMockResponse({ ...alias, ...body, updated_at: new Date().toISOString() })
@@ -1705,7 +1705,7 @@ registerDynamicRoute('DELETE', '/api/admin/models/mappings/:mappingId', async (_
requireAdmin() requireAdmin()
const alias = MOCK_ALIASES.find(a => a.id === params.mappingId) const alias = MOCK_ALIASES.find(a => a.id === params.mappingId)
if (!alias) { if (!alias) {
throw { response: createMockResponse({ detail: '别名不存在' }, 404) } throw { response: createMockResponse({ detail: '映射不存在' }, 404) }
} }
return createMockResponse({ message: '删除成功(演示模式)' }) return createMockResponse({ message: '删除成功(演示模式)' })
}) })

View File

@@ -1167,14 +1167,14 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
provider.display_name or provider.name provider.display_name or provider.name
) )
continue continue
# 检查是否在别名列表中 # 检查是否在映射列表中
if model.provider_model_aliases: if model.provider_model_mappings:
alias_names = [ mapping_list = [
a.get("name") a.get("name")
for a in model.provider_model_aliases for a in model.provider_model_mappings
if isinstance(a, dict) if isinstance(a, dict)
] ]
if mapping_name in alias_names: if mapping_name in mapping_list:
provider_names.append( provider_names.append(
provider.display_name or provider.name provider.display_name or provider.name
) )
@@ -1236,19 +1236,19 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
try: try:
cached_data = json.loads(cached_str) cached_data = json.loads(cached_str)
provider_model_name = cached_data.get("provider_model_name") 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 和 GlobalModel 信息
provider = provider_map.get(provider_id) provider = provider_map.get(provider_id)
global_model = global_model_map.get(global_model_id) global_model = global_model_map.get(global_model_id)
if provider and global_model: if provider and global_model:
# 提取别名名称 # 提取映射名称
alias_names = [] mapping_names = []
if provider_model_aliases: if provider_model_mappings:
for alias_entry in provider_model_aliases: for mapping_entry in provider_model_mappings:
if isinstance(alias_entry, dict) and alias_entry.get("name"): if isinstance(mapping_entry, dict) and mapping_entry.get("name"):
alias_names.append(alias_entry["name"]) mapping_names.append(mapping_entry["name"])
# provider_model_name 为空时跳过 # provider_model_name 为空时跳过
if not provider_model_name: if not provider_model_name:
@@ -1256,14 +1256,14 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
# 只显示有实际映射的条目: # 只显示有实际映射的条目:
# 1. 全局模型名 != Provider 模型名(模型名称映射) # 1. 全局模型名 != Provider 模型名(模型名称映射)
# 2. 或者有别名配置 # 2. 或者有映射配置
has_name_mapping = global_model.name != provider_model_name 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: if has_name_mapping or has_mappings:
# 构建用于展示的别名列表 # 构建用于展示的映射列表
# 如果只有名称映射没有别名,则用 global_model_name 作为"请求名称" # 如果只有名称映射没有额外映射,则用 global_model_name 作为"请求名称"
display_aliases = alias_names if alias_names else [global_model.name] display_mappings = mapping_names if mapping_names else [global_model.name]
provider_model_mappings.append({ provider_model_mappings.append({
"provider_id": provider_id, "provider_id": provider_id,
@@ -1272,7 +1272,7 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
"global_model_name": global_model.name, "global_model_name": global_model.name,
"global_model_display_name": global_model.display_name, "global_model_display_name": global_model.display_name,
"provider_model_name": provider_model_name, "provider_model_name": provider_model_name,
"aliases": display_aliases, "aliases": display_mappings,
"ttl": ttl if ttl > 0 else None, "ttl": ttl if ttl > 0 else None,
"hit_count": hit_count, "hit_count": hit_count,
}) })

View File

@@ -436,7 +436,7 @@ class AdminExportConfigAdapter(AdminApiAdapter):
{ {
"global_model_name": global_model.name if global_model else None, "global_model_name": global_model.name if global_model else None,
"provider_model_name": model.provider_model_name, "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, "price_per_request": model.price_per_request,
"tiered_pricing": model.tiered_pricing, "tiered_pricing": model.tiered_pricing,
"supports_vision": model.supports_vision, "supports_vision": model.supports_vision,
@@ -790,8 +790,8 @@ class AdminImportConfigAdapter(AdminApiAdapter):
) )
elif merge_mode == "overwrite": elif merge_mode == "overwrite":
existing_model.global_model_id = global_model_id existing_model.global_model_id = global_model_id
existing_model.provider_model_aliases = model_data.get( existing_model.provider_model_mappings = model_data.get(
"provider_model_aliases" "provider_model_mappings"
) )
existing_model.price_per_request = model_data.get( existing_model.price_per_request = model_data.get(
"price_per_request" "price_per_request"
@@ -824,8 +824,8 @@ class AdminImportConfigAdapter(AdminApiAdapter):
provider_id=provider_id, provider_id=provider_id,
global_model_id=global_model_id, global_model_id=global_model_id,
provider_model_name=model_data["provider_model_name"], provider_model_name=model_data["provider_model_name"],
provider_model_aliases=model_data.get( provider_model_mappings=model_data.get(
"provider_model_aliases" "provider_model_mappings"
), ),
price_per_request=model_data.get("price_per_request"), price_per_request=model_data.get("price_per_request"),
tiered_pricing=model_data.get("tiered_pricing"), 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.api.base.pipeline import ApiRequestPipeline
from src.core.enums import UserRole from src.core.enums import UserRole
from src.database import get_db 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.models.database import User as DBUser
from src.services.system.stats_aggregator import StatsAggregatorService from src.services.system.stats_aggregator import StatsAggregatorService
from src.utils.cache_decorator import cache_result from src.utils.cache_decorator import cache_result
@@ -893,69 +893,172 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
}) })
current_date += timedelta(days=1) current_date += timedelta(days=1)
# ==================== 模型统计(仍需实时查询)==================== # ==================== 模型统计 ====================
model_query = db.query(Usage) if is_admin:
if not is_admin: # 管理员:使用预聚合数据 + 今日实时数据
model_query = model_query.filter(Usage.user_id == user.id) # 历史数据从 stats_daily_model 获取
model_query = model_query.filter( historical_model_stats = (
and_(Usage.created_at >= start_date, Usage.created_at <= end_date) db.query(StatsDailyModel)
) .filter(and_(StatsDailyModel.date >= start_date, StatsDailyModel.date < today))
.all()
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_agg: dict = {}
"model": stat.model, daily_breakdown: dict = {}
"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 = ( for stat in historical_model_stats:
model_query.with_entities( model = stat.model
func.date(Usage.created_at).label("date"), if model not in model_agg:
Usage.model, model_agg[model] = {
func.count(Usage.id).label("requests"), "requests": 0, "tokens": 0, "cost": 0.0,
func.sum(Usage.total_tokens).label("tokens"), "total_response_time": 0.0, "response_count": 0
func.sum(Usage.total_cost_usd).label("cost"), }
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 = {} today_str = today_local.date().isoformat()
for stat in daily_model_stats: for stat in today_model_stats:
date_str = stat.date.isoformat() model = stat.model
breakdown.setdefault(date_str, []).append( 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, "model": stat.model,
"requests": stat.requests or 0, "requests": stat.requests or 0,
"tokens": int(stat.tokens or 0), "tokens": int(stat.tokens or 0),
"cost": float(stat.cost 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: breakdown = {}
item["model_breakdown"] = breakdown.get(item["date"], []) 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 { return {
"daily_stats": formatted, "daily_stats": formatted,

View File

@@ -260,9 +260,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
mapping = await mapper.get_mapping(source_model, provider_id) mapping = await mapper.get_mapping(source_model, provider_id)
if mapping and mapping.model: if mapping and mapping.model:
# 使用 select_provider_model_name 支持别名功能 # 使用 select_provider_model_name 支持映射功能
# 传入 api_key.id 作为 affinity_key实现相同用户稳定选择同一别名 # 传入 api_key.id 作为 affinity_key实现相同用户稳定选择同一映射
# 传入 api_format 用于过滤适用的别名作用域 # 传入 api_format 用于过滤适用的映射作用域
affinity_key = self.api_key.id if self.api_key else None affinity_key = self.api_key.id if self.api_key else None
mapped_name = mapping.model.select_provider_model_name( mapped_name = mapping.model.select_provider_model_name(
affinity_key, api_format=self.FORMAT_ID affinity_key, api_format=self.FORMAT_ID

View File

@@ -136,7 +136,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
查找逻辑: 查找逻辑:
1. 直接通过 GlobalModel.name 匹配 1. 直接通过 GlobalModel.name 匹配
2. 查找该 Provider 的 Model 实现 2. 查找该 Provider 的 Model 实现
3. 使用 provider_model_name / provider_model_aliases 选择最终名称 3. 使用 provider_model_name / provider_model_mappings 选择最终名称
Args: Args:
source_model: 用户请求的模型名(必须是 GlobalModel.name 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}") logger.debug(f"[CLI] _get_mapped_model: source={source_model}, provider={provider_id[:8]}..., mapping={mapping}")
if mapping and mapping.model: if mapping and mapping.model:
# 使用 select_provider_model_name 支持别名功能 # 使用 select_provider_model_name 支持模型映射功能
# 传入 api_key.id 作为 affinity_key实现相同用户稳定选择同一别名 # 传入 api_key.id 作为 affinity_key实现相同用户稳定选择同一映射
# 传入 api_format 用于过滤适用的别名作用域 # 传入 api_format 用于过滤适用的映射作用域
affinity_key = self.api_key.id if self.api_key else None affinity_key = self.api_key.id if self.api_key else None
mapped_name = mapping.model.select_provider_model_name( mapped_name = mapping.model.select_provider_model_name(
affinity_key, api_format=self.FORMAT_ID 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.provider_api_format = str(endpoint.api_format) if endpoint.api_format else ""
ctx.client_api_format = ctx.api_format # 已在 process_stream 中设置 ctx.client_api_format = ctx.api_format # 已在 process_stream 中设置
# 获取模型映射(别名/映射 → 实际模型名) # 获取模型映射(映射名称 → 实际模型名)
mapped_model = await self._get_mapped_model( mapped_model = await self._get_mapped_model(
source_model=ctx.model, source_model=ctx.model,
provider_id=str(provider.id), provider_id=str(provider.id),
@@ -1382,7 +1382,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
provider_name = str(provider.name) provider_name = str(provider.name)
provider_api_format = str(endpoint.api_format) if endpoint.api_format else "" provider_api_format = str(endpoint.api_format) if endpoint.api_format else ""
# 获取模型映射(别名/映射 → 实际模型名) # 获取模型映射(映射名称 → 实际模型名)
mapped_model = await self._get_mapped_model( mapped_model = await self._get_mapped_model(
source_model=model, source_model=model,
provider_id=str(provider.id), provider_id=str(provider.id),

View File

@@ -50,7 +50,7 @@ model_mapping_resolution_total = Counter(
"model_mapping_resolution_total", "model_mapping_resolution_total",
"Total number of model mapping resolutions", "Total number of model mapping resolutions",
["method", "cache_hit"], ["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 # cache_hit: true, false
) )

View File

@@ -346,9 +346,9 @@ class ModelCreate(BaseModel):
provider_model_name: str = Field( provider_model_name: str = Field(
..., min_length=1, max_length=200, description="Provider 侧的主模型名称" ..., min_length=1, max_length=200, description="Provider 侧的主模型名称"
) )
provider_model_aliases: Optional[List[dict]] = Field( provider_model_mappings: Optional[List[dict]] = Field(
None, None,
description="模型名称别名列表,格式: [{'name': 'alias1', 'priority': 1}, ...]", description="模型名称映射列表,格式: [{'name': 'alias1', 'priority': 1}, ...]",
) )
global_model_id: str = Field(..., description="关联的 GlobalModel ID必填") global_model_id: str = Field(..., description="关联的 GlobalModel ID必填")
# 按次计费配置 - 可选,为空时使用 GlobalModel 默认值 # 按次计费配置 - 可选,为空时使用 GlobalModel 默认值
@@ -376,9 +376,9 @@ class ModelUpdate(BaseModel):
"""更新模型请求""" """更新模型请求"""
provider_model_name: Optional[str] = Field(None, min_length=1, max_length=200) 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, None,
description="模型名称别名列表,格式: [{'name': 'alias1', 'priority': 1}, ...]", description="模型名称映射列表,格式: [{'name': 'alias1', 'priority': 1}, ...]",
) )
global_model_id: Optional[str] = None global_model_id: Optional[str] = None
# 按次计费配置 # 按次计费配置
@@ -404,7 +404,7 @@ class ModelResponse(BaseModel):
provider_id: str provider_id: str
global_model_id: Optional[str] global_model_id: Optional[str]
provider_model_name: 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 price_per_request: Optional[float] = None

View File

@@ -671,10 +671,10 @@ class Model(Base):
# Provider 映射配置 # Provider 映射配置
provider_model_name = Column(String(200), nullable=False) # 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}] # 格式: [{"name": "Claude-Sonnet-4.5", "priority": 1}, {"name": "Claude-Sonnet-4-5", "priority": 2}]
# 为空时只使用 provider_model_name # 为空时只使用 provider_model_name
provider_model_aliases = Column(JSON, nullable=True, default=None) provider_model_mappings = Column(JSON, nullable=True, default=None)
# 按次计费配置(每次请求的固定费用,美元)- 可为空,为空时使用 GlobalModel 的默认值 # 按次计费配置(每次请求的固定费用,美元)- 可为空,为空时使用 GlobalModel 的默认值
price_per_request = Column(Float, nullable=True) # 每次请求固定费用 price_per_request = Column(Float, nullable=True) # 每次请求固定费用
@@ -820,25 +820,25 @@ class Model(Base):
) -> str: ) -> str:
"""按优先级选择要使用的 Provider 模型名称 """按优先级选择要使用的 Provider 模型名称
如果配置了 provider_model_aliases按优先级选择数字越小越优先 如果配置了 provider_model_mappings按优先级选择数字越小越优先
相同优先级的别名通过哈希分散实现负载均衡(与 Key 调度策略一致); 相同优先级的映射通过哈希分散实现负载均衡(与 Key 调度策略一致);
否则返回 provider_model_name。 否则返回 provider_model_name。
Args: Args:
affinity_key: 用于哈希分散的亲和键(如用户 API Key 哈希),确保同一用户稳定选择同一别名 affinity_key: 用于哈希分散的亲和键(如用户 API Key 哈希),确保同一用户稳定选择同一映射
api_format: 当前请求的 API 格式(如 CLAUDE、OPENAI 等),用于过滤适用的别名 api_format: 当前请求的 API 格式(如 CLAUDE、OPENAI 等),用于过滤适用的映射
""" """
import hashlib import hashlib
if not self.provider_model_aliases: if not self.provider_model_mappings:
return self.provider_model_name return self.provider_model_name
raw_aliases = self.provider_model_aliases raw_mappings = self.provider_model_mappings
if not isinstance(raw_aliases, list) or len(raw_aliases) == 0: if not isinstance(raw_mappings, list) or len(raw_mappings) == 0:
return self.provider_model_name return self.provider_model_name
aliases: list[dict] = [] mappings: list[dict] = []
for raw in raw_aliases: for raw in raw_mappings:
if not isinstance(raw, dict): if not isinstance(raw, dict):
continue continue
name = raw.get("name") name = raw.get("name")
@@ -846,10 +846,10 @@ class Model(Base):
continue continue
# 检查 api_formats 作用域(如果配置了且当前有 api_format # 检查 api_formats 作用域(如果配置了且当前有 api_format
alias_api_formats = raw.get("api_formats") mapping_api_formats = raw.get("api_formats")
if api_format and alias_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 continue
raw_priority = raw.get("priority", 1) raw_priority = raw.get("priority", 1)
@@ -860,47 +860,47 @@ class Model(Base):
if priority < 1: if priority < 1:
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 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 = [ top_priority_mappings = [
alias for alias in sorted_aliases mapping for mapping in sorted_mappings
if alias["priority"] == highest_priority if mapping["priority"] == highest_priority
] ]
# 如果有多个相同优先级的别名,通过哈希分散选择 # 如果有多个相同优先级的映射,通过哈希分散选择
if len(top_priority_aliases) > 1 and affinity_key: if len(top_priority_mappings) > 1 and affinity_key:
# 为每个别名计算哈希得分,选择得分最小的 # 为每个映射计算哈希得分,选择得分最小的
def hash_score(alias: dict) -> int: def hash_score(mapping: dict) -> int:
combined = f"{affinity_key}:{alias['name']}" combined = f"{affinity_key}:{mapping['name']}"
return int(hashlib.md5(combined.encode()).hexdigest(), 16) return int(hashlib.md5(combined.encode()).hexdigest(), 16)
selected = min(top_priority_aliases, key=hash_score) selected = min(top_priority_mappings, key=hash_score)
elif len(top_priority_aliases) > 1: elif len(top_priority_mappings) > 1:
# 没有 affinity_key 时,使用确定性选择(按名称排序后取第一个) # 没有 affinity_key 时,使用确定性选择(按名称排序后取第一个)
# 避免随机选择导致同一请求重试时选择不同的模型名称 # 避免随机选择导致同一请求重试时选择不同的模型名称
selected = min(top_priority_aliases, key=lambda x: x["name"]) selected = min(top_priority_mappings, key=lambda x: x["name"])
else: else:
selected = top_priority_aliases[0] selected = top_priority_mappings[0]
return selected["name"] return selected["name"]
def get_all_provider_model_names(self) -> list[str]: def get_all_provider_model_names(self) -> list[str]:
"""获取所有可用的 Provider 模型名称(主名称 + 别名""" """获取所有可用的 Provider 模型名称(主名称 + 映射名称"""
names = [self.provider_model_name] names = [self.provider_model_name]
if self.provider_model_aliases: if self.provider_model_mappings:
for alias in self.provider_model_aliases: for mapping in self.provider_model_mappings:
if isinstance(alias, dict) and alias.get("name"): if isinstance(mapping, dict) and mapping.get("name"):
names.append(alias["name"]) names.append(mapping["name"])
return names 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): class StatsSummary(Base):
"""全局统计汇总 - 单行记录,存储截止到昨天的累计数据""" """全局统计汇总 - 单行记录,存储截止到昨天的累计数据"""

View File

@@ -589,14 +589,14 @@ class CacheAwareScheduler:
target_format = normalize_api_format(api_format) 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) global_model = await ModelCacheService.resolve_global_model_by_name_or_alias(db, model_name)
if not global_model: if not global_model:
logger.warning(f"GlobalModel not found: {model_name}") logger.warning(f"GlobalModel not found: {model_name}")
raise ModelNotSupportedException(model=model_name) raise ModelNotSupportedException(model=model_name)
# 使用 GlobalModel.id 作为缓存亲和性的模型标识,确保名和规范名都能命中同一个缓存 # 使用 GlobalModel.id 作为缓存亲和性的模型标识,确保映射名和规范名都能命中同一个缓存
global_model_id: str = str(global_model.id) global_model_id: str = str(global_model.id)
requested_model_name = model_name requested_model_name = model_name
resolved_model_name = str(global_model.name) resolved_model_name = str(global_model.name)
@@ -751,19 +751,19 @@ class CacheAwareScheduler:
支持两种匹配方式: 支持两种匹配方式:
1. 直接匹配 GlobalModel.name 1. 直接匹配 GlobalModel.name
2. 通过 ModelCacheService 匹配名(全局查找) 2. 通过 ModelCacheService 匹配映射名(全局查找)
Args: Args:
db: 数据库会话 db: 数据库会话
provider: Provider 对象 provider: Provider 对象
model_name: 模型名称(可以是 GlobalModel.name 或名) model_name: 模型名称(可以是 GlobalModel.name 或映射名)
is_stream: 是否是流式请求,如果为 True 则同时检查流式支持 is_stream: 是否是流式请求,如果为 True 则同时检查流式支持
capability_requirements: 能力需求(可选),用于检查模型是否支持所需能力 capability_requirements: 能力需求(可选),用于检查模型是否支持所需能力
Returns: Returns:
(is_supported, skip_reason, supported_capabilities) - 是否支持、跳过原因、模型支持的能力列表 (is_supported, skip_reason, supported_capabilities) - 是否支持、跳过原因、模型支持的能力列表
""" """
# 使用 ModelCacheService 解析模型名称(支持名) # 使用 ModelCacheService 解析模型名称(支持映射名)
global_model = await ModelCacheService.resolve_global_model_by_name_or_alias(db, model_name) global_model = await ModelCacheService.resolve_global_model_by_name_or_alias(db, model_name)
if not global_model: if not global_model:
@@ -914,7 +914,7 @@ class CacheAwareScheduler:
db: 数据库会话 db: 数据库会话
providers: Provider 列表 providers: Provider 列表
target_format: 目标 API 格式 target_format: 目标 API 格式
model_name: 模型名称(用户请求的名称,可能是名) model_name: 模型名称(用户请求的名称,可能是映射名)
affinity_key: 亲和性标识符通常为API Key ID affinity_key: 亲和性标识符通常为API Key ID
resolved_model_name: 解析后的 GlobalModel.name用于 Key.allowed_models 校验) resolved_model_name: 解析后的 GlobalModel.name用于 Key.allowed_models 校验)
max_candidates: 最大候选数 max_candidates: 最大候选数

View File

@@ -198,7 +198,7 @@ class ModelCacheService:
provider_id: Optional[str] = None, provider_id: Optional[str] = None,
global_model_id: Optional[str] = None, global_model_id: Optional[str] = None,
provider_model_name: Optional[str] = None, provider_model_name: Optional[str] = None,
provider_model_aliases: Optional[list] = None, provider_model_mappings: Optional[list] = None,
) -> None: ) -> None:
"""清除 Model 缓存 """清除 Model 缓存
@@ -207,7 +207,7 @@ class ModelCacheService:
provider_id: Provider ID用于清除 provider_global 缓存) provider_id: Provider ID用于清除 provider_global 缓存)
global_model_id: GlobalModel ID用于清除 provider_global 缓存) global_model_id: GlobalModel ID用于清除 provider_global 缓存)
provider_model_name: provider_model_name用于清除 resolve 缓存) provider_model_name: provider_model_name用于清除 resolve 缓存)
provider_model_aliases: 映射名称列表(用于清除 resolve 缓存) provider_model_mappings: 映射名称列表(用于清除 resolve 缓存)
""" """
# 清除 model:id 缓存 # 清除 model:id 缓存
await CacheService.delete(f"model:id:{model_id}") await CacheService.delete(f"model:id:{model_id}")
@@ -222,16 +222,16 @@ class ModelCacheService:
else: else:
logger.debug(f"Model 缓存已清除: {model_id}") logger.debug(f"Model 缓存已清除: {model_id}")
# 清除 resolve 缓存provider_model_name 和 aliases 可能都被用作解析 key # 清除 resolve 缓存provider_model_name 和 mappings 可能都被用作解析 key
resolve_keys_to_clear = [] resolve_keys_to_clear = []
if provider_model_name: if provider_model_name:
resolve_keys_to_clear.append(provider_model_name) resolve_keys_to_clear.append(provider_model_name)
if provider_model_aliases: if provider_model_mappings:
for alias_entry in provider_model_aliases: for mapping_entry in provider_model_mappings:
if isinstance(alias_entry, dict): if isinstance(mapping_entry, dict):
alias_name = alias_entry.get("name", "").strip() mapping_name = mapping_entry.get("name", "").strip()
if alias_name: if mapping_name:
resolve_keys_to_clear.append(alias_name) resolve_keys_to_clear.append(mapping_name)
for key in resolve_keys_to_clear: for key in resolve_keys_to_clear:
await CacheService.delete(f"global_model:resolve:{key}") await CacheService.delete(f"global_model:resolve:{key}")
@@ -261,8 +261,8 @@ class ModelCacheService:
2. 通过 provider_model_name 匹配(查询 Model 表) 2. 通过 provider_model_name 匹配(查询 Model 表)
3. 直接匹配 GlobalModel.name兜底 3. 直接匹配 GlobalModel.name兜底
注意:此方法不使用 provider_model_aliases 进行全局解析。 注意:此方法不使用 provider_model_mappings 进行全局解析。
provider_model_aliases 是 Provider 级别的别名配置,只在特定 Provider 上下文中生效, provider_model_mappings 是 Provider 级别的映射配置,只在特定 Provider 上下文中生效,
由 resolve_provider_model() 处理。 由 resolve_provider_model() 处理。
Args: Args:
@@ -301,9 +301,9 @@ class ModelCacheService:
logger.debug(f"GlobalModel 缓存命中(映射解析): {normalized_name}") logger.debug(f"GlobalModel 缓存命中(映射解析): {normalized_name}")
return ModelCacheService._dict_to_global_model(cached_data) return ModelCacheService._dict_to_global_model(cached_data)
# 2. 通过 provider_model_name 匹配(不考虑 provider_model_aliases # 2. 通过 provider_model_name 匹配(不考虑 provider_model_mappings
# 重要provider_model_aliases 是 Provider 级别的别名配置,只在特定 Provider 上下文中生效 # 重要provider_model_mappings 是 Provider 级别的映射配置,只在特定 Provider 上下文中生效
# 全局解析不应该受到某个 Provider 别名配置的影响 # 全局解析不应该受到某个 Provider 映射配置的影响
# 例如Provider A 把 "haiku" 映射到 "sonnet",不应该影响 Provider B 的 "haiku" 解析 # 例如Provider A 把 "haiku" 映射到 "sonnet",不应该影响 Provider B 的 "haiku" 解析
from src.models.database import Provider from src.models.database import Provider
@@ -401,7 +401,7 @@ class ModelCacheService:
"provider_id": model.provider_id, "provider_id": model.provider_id,
"global_model_id": model.global_model_id, "global_model_id": model.global_model_id,
"provider_model_name": model.provider_model_name, "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_active": model.is_active,
"is_available": model.is_available if hasattr(model, "is_available") else True, "is_available": model.is_available if hasattr(model, "is_available") else True,
"price_per_request": ( "price_per_request": (
@@ -424,7 +424,7 @@ class ModelCacheService:
provider_id=model_dict["provider_id"], provider_id=model_dict["provider_id"],
global_model_id=model_dict["global_model_id"], global_model_id=model_dict["global_model_id"],
provider_model_name=model_dict["provider_model_name"], 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_active=model_dict["is_active"],
is_available=model_dict.get("is_available", True), is_available=model_dict.get("is_available", True),
price_per_request=model_dict.get("price_per_request"), price_per_request=model_dict.get("price_per_request"),

View File

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

View File

@@ -84,11 +84,11 @@ class ModelMapperMiddleware:
获取模型映射 获取模型映射
简化后的逻辑: 简化后的逻辑:
1. 通过 GlobalModel.name 或名解析 GlobalModel 1. 通过 GlobalModel.name 或映射名解析 GlobalModel
2. 找到 GlobalModel 后,查找该 Provider 的 Model 实现 2. 找到 GlobalModel 后,查找该 Provider 的 Model 实现
Args: Args:
source_model: 用户请求的模型名(可以是 GlobalModel.name 或名) source_model: 用户请求的模型名(可以是 GlobalModel.name 或映射名)
provider_id: 提供商ID (UUID) provider_id: 提供商ID (UUID)
Returns: Returns:
@@ -101,7 +101,7 @@ class ModelMapperMiddleware:
mapping = None mapping = None
# 步骤 1: 解析 GlobalModel支持名) # 步骤 1: 解析 GlobalModel支持映射名)
global_model = await ModelCacheService.resolve_global_model_by_name_or_alias( global_model = await ModelCacheService.resolve_global_model_by_name_or_alias(
self.db, source_model self.db, source_model
) )

View File

@@ -51,7 +51,7 @@ class ModelService:
provider_id=provider_id, provider_id=provider_id,
global_model_id=model_data.global_model_id, global_model_id=model_data.global_model_id,
provider_model_name=model_data.provider_model_name, 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, price_per_request=model_data.price_per_request,
tiered_pricing=model_data.tiered_pricing, tiered_pricing=model_data.tiered_pricing,
supports_vision=model_data.supports_vision, supports_vision=model_data.supports_vision,
@@ -153,9 +153,9 @@ class ModelService:
if not model: if not model:
raise NotFoundException(f"模型 {model_id} 不存在") raise NotFoundException(f"模型 {model_id} 不存在")
# 保存旧的别名,用于清除缓存 # 保存旧的映射,用于清除缓存
old_provider_model_name = model.provider_model_name 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) update_data = model_data.model_dump(exclude_unset=True)
@@ -174,26 +174,26 @@ class ModelService:
db.refresh(model) db.refresh(model)
# 清除 Redis 缓存(异步执行,不阻塞返回) # 清除 Redis 缓存(异步执行,不阻塞返回)
# 先清除旧的别名缓存 # 先清除旧的映射缓存
asyncio.create_task( asyncio.create_task(
ModelCacheService.invalidate_model_cache( ModelCacheService.invalidate_model_cache(
model_id=model.id, model_id=model.id,
provider_id=model.provider_id, provider_id=model.provider_id,
global_model_id=model.global_model_id, global_model_id=model.global_model_id,
provider_model_name=old_provider_model_name, 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 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( asyncio.create_task(
ModelCacheService.invalidate_model_cache( ModelCacheService.invalidate_model_cache(
model_id=model.id, model_id=model.id,
provider_id=model.provider_id, provider_id=model.provider_id,
global_model_id=model.global_model_id, global_model_id=model.global_model_id,
provider_model_name=model.provider_model_name, 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, "provider_id": model.provider_id,
"global_model_id": model.global_model_id, "global_model_id": model.global_model_id,
"provider_model_name": model.provider_model_name, "provider_model_name": model.provider_model_name,
"provider_model_aliases": model.provider_model_aliases, "provider_model_mappings": model.provider_model_mappings,
} }
try: try:
@@ -260,7 +260,7 @@ class ModelService:
provider_id=cache_info["provider_id"], provider_id=cache_info["provider_id"],
global_model_id=cache_info["global_model_id"], global_model_id=cache_info["global_model_id"],
provider_model_name=cache_info["provider_model_name"], 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, provider_id=model.provider_id,
global_model_id=model.global_model_id, global_model_id=model.global_model_id,
provider_model_name=model.provider_model_name, 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, provider_id=model.provider_id,
global_model_id=model.global_model_id, global_model_id=model.global_model_id,
provider_model_name=model.provider_model_name, 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, price_per_request=model.price_per_request,
tiered_pricing=model.tiered_pricing, tiered_pricing=model.tiered_pricing,

View File

@@ -259,6 +259,9 @@ class CleanupScheduler:
StatsAggregatorService.aggregate_daily_stats( StatsAggregatorService.aggregate_daily_stats(
db, current_date_local db, current_date_local
) )
StatsAggregatorService.aggregate_daily_model_stats(
db, current_date_local
)
for (user_id,) in users: for (user_id,) in users:
try: try:
StatsAggregatorService.aggregate_user_daily_stats( StatsAggregatorService.aggregate_user_daily_stats(
@@ -291,6 +294,7 @@ class CleanupScheduler:
yesterday_local = today_local - timedelta(days=1) yesterday_local = today_local - timedelta(days=1)
StatsAggregatorService.aggregate_daily_stats(db, yesterday_local) 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() users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all()
for (user_id,) in users: for (user_id,) in users:

View File

@@ -16,6 +16,7 @@ from src.models.database import (
ApiKey, ApiKey,
RequestCandidate, RequestCandidate,
StatsDaily, StatsDaily,
StatsDailyModel,
StatsSummary, StatsSummary,
StatsUserDaily, StatsUserDaily,
Usage, Usage,
@@ -219,6 +220,120 @@ class StatsAggregatorService:
logger.info(f"[StatsAggregator] 聚合日期 {date.date()} 完成: {computed['total_requests']} 请求") logger.info(f"[StatsAggregator] 聚合日期 {date.date()} 完成: {computed['total_requests']} 请求")
return stats 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 @staticmethod
def aggregate_user_daily_stats( def aggregate_user_daily_stats(
db: Session, user_id: str, date: datetime db: Session, user_id: str, date: datetime
@@ -497,6 +612,7 @@ class StatsAggregatorService:
current_date = start_date current_date = start_date
while current_date < today_local: while current_date < today_local:
StatsAggregatorService.aggregate_daily_stats(db, current_date) StatsAggregatorService.aggregate_daily_stats(db, current_date)
StatsAggregatorService.aggregate_daily_model_stats(db, current_date)
count += 1 count += 1
current_date += timedelta(days=1) current_date += timedelta(days=1)