mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-02 15:52:26 +08:00
feat: add daily model statistics aggregation with stats_daily_model table
This commit is contained in:
@@ -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')
|
||||
@@ -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
|
||||
|
||||
@@ -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 // 阶梯计费配置
|
||||
|
||||
@@ -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('映射配置已保存')
|
||||
|
||||
@@ -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 ? '映射组已更新' : '映射已添加')
|
||||
|
||||
@@ -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('映射组已删除')
|
||||
|
||||
@@ -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: '删除成功(演示模式)' })
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,12 +893,115 @@ 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)
|
||||
# ==================== 模型统计 ====================
|
||||
if is_admin:
|
||||
# 管理员:使用预聚合数据 + 今日实时数据
|
||||
# 历史数据从 stats_daily_model 获取
|
||||
historical_model_stats = (
|
||||
db.query(StatsDailyModel)
|
||||
.filter(and_(StatsDailyModel.date >= start_date, StatsDailyModel.date < today))
|
||||
.all()
|
||||
)
|
||||
|
||||
# 按模型汇总历史数据
|
||||
model_agg: dict = {}
|
||||
daily_breakdown: dict = {}
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""全局统计汇总 - 单行记录,存储截止到昨天的累计数据"""
|
||||
|
||||
|
||||
12
src/services/cache/aware_scheduler.py
vendored
12
src/services/cache/aware_scheduler.py
vendored
@@ -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: 最大候选数
|
||||
|
||||
32
src/services/cache/model_cache.py
vendored
32
src/services/cache/model_cache.py
vendored
@@ -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"),
|
||||
|
||||
@@ -443,7 +443,7 @@ class ModelCostService:
|
||||
|
||||
Args:
|
||||
provider: Provider 对象或提供商名称
|
||||
model: 用户请求的模型名(可能是 GlobalModel.name 或别名)
|
||||
model: 用户请求的模型名(可能是 GlobalModel.name 或映射名)
|
||||
|
||||
Returns:
|
||||
按次计费价格,如果没有配置则返回 None
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user