mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: 视频计费增强与影子计费系统
This commit is contained in:
29
.env.example
29
.env.example
@@ -49,14 +49,23 @@ ADMIN_PASSWORD=admin123456
|
|||||||
#
|
#
|
||||||
# required 维度缺失时是否拒绝请求/标记任务失败(默认 false:cost=0 + 标记 incomplete)
|
# required 维度缺失时是否拒绝请求/标记任务失败(默认 false:cost=0 + 标记 incomplete)
|
||||||
# BILLING_STRICT_MODE=false
|
# BILLING_STRICT_MODE=false
|
||||||
|
|
||||||
# ==================== 格式转换(可选) ====================
|
|
||||||
# FORMAT_CONVERSION_ENABLED: 总开关(默认 true)
|
|
||||||
# - false: 禁止任何跨格式转换(即使端点 format_acceptance_config 已开启也不生效)
|
|
||||||
# - true: 允许跨格式转换(是否允许仍取决于:全局覆盖/提供商覆盖/端点策略)
|
|
||||||
# FORMAT_CONVERSION_ENABLED=true
|
|
||||||
#
|
#
|
||||||
# KEEP_PRIORITY_ON_CONVERSION: 跨格式转换时是否保持优先级(默认 false)
|
# 计费引擎切换(迁移期/影子对账/灰度)
|
||||||
# - false: needs_conversion 的候选会整体降级到 exact 候选之后(更安全)
|
# - legacy: 仅旧系统(默认)
|
||||||
# - true: 所有候选保持原优先级(用于强制走某些 provider)
|
# - shadow: 旧系统为真值 + 新系统影子计算(写入 request_metadata.billing_shadow)
|
||||||
# KEEP_PRIORITY_ON_CONVERSION=false
|
# - new_with_fallback: 新系统为真值,差异过大时回退旧系统
|
||||||
|
# - new: 仅新系统
|
||||||
|
# BILLING_ENGINE=new
|
||||||
|
#
|
||||||
|
# 按 provider/model 粒度覆盖(JSON 字符串)
|
||||||
|
# 示例: {"anthropic/*": "shadow", "openai/gpt-4*": "new"}
|
||||||
|
# BILLING_ENGINE_OVERRIDES={}
|
||||||
|
#
|
||||||
|
# 影子对账差异阈值(美元)
|
||||||
|
# BILLING_DIFF_THRESHOLD_USD=0.0001
|
||||||
|
#
|
||||||
|
# 影子差异日志级别(DEBUG/INFO/WARNING/ERROR)
|
||||||
|
# BILLING_SHADOW_LOG_LEVEL=INFO
|
||||||
|
#
|
||||||
|
# 是否启用差异告警(预留扩展)
|
||||||
|
# BILLING_DIFF_ALERT_ENABLED=false
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Add video_duration_seconds to video_tasks and body_rules to provider_endpoints
|
||||||
|
|
||||||
|
Revision ID: b3c4d5e6f7a8
|
||||||
|
Revises: a2f1b3c4d5e6
|
||||||
|
Create Date: 2026-02-03 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "b3c4d5e6f7a8"
|
||||||
|
down_revision: Union[str, None] = "a2f1b3c4d5e6"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
"""Check if a column exists in a table."""
|
||||||
|
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:
|
||||||
|
# 1. Add video_duration_seconds to video_tasks
|
||||||
|
if not _column_exists("video_tasks", "video_duration_seconds"):
|
||||||
|
op.add_column(
|
||||||
|
"video_tasks",
|
||||||
|
sa.Column("video_duration_seconds", sa.Float(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Add body_rules to provider_endpoints
|
||||||
|
# 请求体规则支持三种操作:
|
||||||
|
# - set: 设置/覆盖字段 {"action": "set", "path": "metadata", "value": {"custom": "val"}}
|
||||||
|
# - drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
|
||||||
|
# - rename: 重命名字段 {"action": "rename", "from": "old_key", "to": "new_key"}
|
||||||
|
if not _column_exists("provider_endpoints", "body_rules"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_endpoints",
|
||||||
|
sa.Column("body_rules", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove body_rules from provider_endpoints
|
||||||
|
if _column_exists("provider_endpoints", "body_rules"):
|
||||||
|
op.drop_column("provider_endpoints", "body_rules")
|
||||||
|
|
||||||
|
# Remove video_duration_seconds from video_tasks
|
||||||
|
if _column_exists("video_tasks", "video_duration_seconds"):
|
||||||
|
op.drop_column("video_tasks", "video_duration_seconds")
|
||||||
43
alembic/versions/20260203_1500_add_video_duration_seconds.py
Normal file
43
alembic/versions/20260203_1500_add_video_duration_seconds.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""add video_duration_seconds to video_tasks
|
||||||
|
|
||||||
|
Revision ID: b3c4d5e6f7a8
|
||||||
|
Revises: a2f1b3c4d5e6
|
||||||
|
Create Date: 2026-02-03 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "b3c4d5e6f7a8"
|
||||||
|
down_revision: Union[str, None] = "a2f1b3c4d5e6"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
"""Check if a column exists in a table."""
|
||||||
|
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:
|
||||||
|
"""Add video_duration_seconds column to video_tasks table."""
|
||||||
|
if not _column_exists("video_tasks", "video_duration_seconds"):
|
||||||
|
op.add_column(
|
||||||
|
"video_tasks",
|
||||||
|
sa.Column("video_duration_seconds", sa.Float(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Remove video_duration_seconds column from video_tasks table."""
|
||||||
|
if _column_exists("video_tasks", "video_duration_seconds"):
|
||||||
|
op.drop_column("video_tasks", "video_duration_seconds")
|
||||||
@@ -616,5 +616,5 @@ export const adminApi = {
|
|||||||
async testLdapConnection(config: LdapConfigUpdateRequest): Promise<LdapTestResponse> {
|
async testLdapConnection(config: LdapConfigUpdateRequest): Promise<LdapTestResponse> {
|
||||||
const response = await apiClient.post<LdapTestResponse>('/api/admin/ldap/test', config)
|
const response = await apiClient.post<LdapTestResponse>('/api/admin/ldap/test', config)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export interface AsyncTaskDetail extends AsyncTaskItem {
|
|||||||
video_urls: string[] | null
|
video_urls: string[] | null
|
||||||
thumbnail_url: string | null
|
thumbnail_url: string | null
|
||||||
video_size_bytes: number | null
|
video_size_bytes: number | null
|
||||||
|
video_duration_seconds: number | null // 实际视频时长(秒)
|
||||||
video_expires_at: string | null
|
video_expires_at: string | null
|
||||||
stored_video_path: string | null
|
stored_video_path: string | null
|
||||||
storage_provider: string | null
|
storage_provider: string | null
|
||||||
|
|||||||
@@ -99,6 +99,19 @@ export interface ProviderStatusResponse {
|
|||||||
providers: ProviderStatus[]
|
providers: ProviderStatus[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 视频/图像/音频计费信息
|
||||||
|
export interface VideoBilling {
|
||||||
|
task_type: 'video' | 'image' | 'audio'
|
||||||
|
duration_seconds?: number // 视频时长(秒)
|
||||||
|
resolution?: string // 分辨率
|
||||||
|
video_price_per_second?: number // 每秒单价
|
||||||
|
video_cost?: number // 视频费用
|
||||||
|
cost?: number // 总费用
|
||||||
|
rule_name?: string // 计费规则名称
|
||||||
|
expression?: string // 计费公式
|
||||||
|
status?: string // 计费状态
|
||||||
|
}
|
||||||
|
|
||||||
export interface RequestDetail {
|
export interface RequestDetail {
|
||||||
id: string // UUID
|
id: string // UUID
|
||||||
request_id: string
|
request_id: string
|
||||||
@@ -187,6 +200,8 @@ export interface RequestDetail {
|
|||||||
}>
|
}>
|
||||||
}>
|
}>
|
||||||
} | null
|
} | null
|
||||||
|
// 视频/图像/音频计费信息
|
||||||
|
video_billing?: VideoBilling | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelBreakdown {
|
export interface ModelBreakdown {
|
||||||
|
|||||||
@@ -13,23 +13,23 @@ export const API_FORMATS = {
|
|||||||
|
|
||||||
export type APIFormat = typeof API_FORMATS[keyof typeof API_FORMATS]
|
export type APIFormat = typeof API_FORMATS[keyof typeof API_FORMATS]
|
||||||
|
|
||||||
// API 格式显示名称映射(按品牌分组:API 在前,CLI 在后)
|
// API 格式显示名称映射(按品牌分组:Chat 在前,CLI/Video 在后)
|
||||||
export const API_FORMAT_LABELS: Record<string, string> = {
|
export const API_FORMAT_LABELS: Record<string, string> = {
|
||||||
[API_FORMATS.CLAUDE]: 'Claude',
|
[API_FORMATS.CLAUDE]: 'Claude Chat',
|
||||||
[API_FORMATS.CLAUDE_CLI]: 'Claude CLI',
|
[API_FORMATS.CLAUDE_CLI]: 'Claude CLI',
|
||||||
[API_FORMATS.OPENAI]: 'OpenAI',
|
[API_FORMATS.OPENAI]: 'OpenAI Chat',
|
||||||
[API_FORMATS.OPENAI_CLI]: 'OpenAI CLI',
|
[API_FORMATS.OPENAI_CLI]: 'OpenAI CLI',
|
||||||
[API_FORMATS.OPENAI_VIDEO]: 'OpenAI Video',
|
[API_FORMATS.OPENAI_VIDEO]: 'OpenAI Video',
|
||||||
[API_FORMATS.GEMINI]: 'Gemini',
|
[API_FORMATS.GEMINI]: 'Gemini Chat',
|
||||||
[API_FORMATS.GEMINI_CLI]: 'Gemini CLI',
|
[API_FORMATS.GEMINI_CLI]: 'Gemini CLI',
|
||||||
[API_FORMATS.GEMINI_VIDEO]: 'Gemini Video',
|
[API_FORMATS.GEMINI_VIDEO]: 'Gemini Video',
|
||||||
// legacy 兼容(仅用于展示历史数据)
|
// legacy 兼容(仅用于展示历史数据)
|
||||||
CLAUDE: 'Claude',
|
CLAUDE: 'Claude Chat',
|
||||||
CLAUDE_CLI: 'Claude CLI',
|
CLAUDE_CLI: 'Claude CLI',
|
||||||
OPENAI: 'OpenAI',
|
OPENAI: 'OpenAI Chat',
|
||||||
OPENAI_CLI: 'OpenAI CLI',
|
OPENAI_CLI: 'OpenAI CLI',
|
||||||
OPENAI_VIDEO: 'OpenAI Video',
|
OPENAI_VIDEO: 'OpenAI Video',
|
||||||
GEMINI: 'Gemini',
|
GEMINI: 'Gemini Chat',
|
||||||
GEMINI_CLI: 'Gemini CLI',
|
GEMINI_CLI: 'Gemini CLI',
|
||||||
GEMINI_VIDEO: 'Gemini Video',
|
GEMINI_VIDEO: 'Gemini Video',
|
||||||
}
|
}
|
||||||
@@ -418,6 +418,7 @@ export interface Model {
|
|||||||
global_model_id?: string // 关联的 GlobalModel ID
|
global_model_id?: string // 关联的 GlobalModel ID
|
||||||
provider_model_name: string // Provider 侧的主模型名称
|
provider_model_name: string // Provider 侧的主模型名称
|
||||||
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
|
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
|
||||||
|
config?: Record<string, any> | null // 额外配置(如 billing/video 等)
|
||||||
// 原始配置值(可能为空,为空时使用 GlobalModel 默认值)
|
// 原始配置值(可能为空,为空时使用 GlobalModel 默认值)
|
||||||
price_per_request?: number | null // 按次计费价格
|
price_per_request?: number | null // 按次计费价格
|
||||||
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
|
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
|
||||||
@@ -443,6 +444,8 @@ export interface Model {
|
|||||||
// GlobalModel 信息(从后端 join 获取)
|
// GlobalModel 信息(从后端 join 获取)
|
||||||
global_model_name?: string
|
global_model_name?: string
|
||||||
global_model_display_name?: string
|
global_model_display_name?: string
|
||||||
|
// 有效配置(合并 Model 和 GlobalModel 的 config)
|
||||||
|
effective_config?: Record<string, any> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelCreate {
|
export interface ModelCreate {
|
||||||
@@ -475,6 +478,7 @@ export interface ModelUpdate {
|
|||||||
supports_image_generation?: boolean
|
supports_image_generation?: boolean
|
||||||
is_active?: boolean
|
is_active?: boolean
|
||||||
is_available?: boolean
|
is_available?: boolean
|
||||||
|
config?: Record<string, any> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelCapabilities {
|
export interface ModelCapabilities {
|
||||||
|
|||||||
27
frontend/src/components/ui/popover/Popover.vue
Normal file
27
frontend/src/components/ui/popover/Popover.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { PopoverRoot } from 'radix-vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
defaultOpen?: boolean
|
||||||
|
open?: boolean
|
||||||
|
modal?: boolean
|
||||||
|
}>(), {
|
||||||
|
defaultOpen: false,
|
||||||
|
modal: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [value: boolean]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PopoverRoot
|
||||||
|
:default-open="props.defaultOpen"
|
||||||
|
:open="props.open"
|
||||||
|
:modal="props.modal"
|
||||||
|
@update:open="emit('update:open', $event)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</PopoverRoot>
|
||||||
|
</template>
|
||||||
39
frontend/src/components/ui/popover/PopoverContent.vue
Normal file
39
frontend/src/components/ui/popover/PopoverContent.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { PopoverContent, PopoverPortal } from 'radix-vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
class?: string
|
||||||
|
align?: 'start' | 'center' | 'end'
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||||
|
sideOffset?: number
|
||||||
|
alignOffset?: number
|
||||||
|
}>(), {
|
||||||
|
align: 'center',
|
||||||
|
side: 'bottom',
|
||||||
|
sideOffset: 4,
|
||||||
|
alignOffset: 0,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PopoverPortal>
|
||||||
|
<PopoverContent
|
||||||
|
:class="cn(
|
||||||
|
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||||
|
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
props.class
|
||||||
|
)"
|
||||||
|
:align="props.align"
|
||||||
|
:side="props.side"
|
||||||
|
:side-offset="props.sideOffset"
|
||||||
|
:align-offset="props.alignOffset"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</PopoverContent>
|
||||||
|
</PopoverPortal>
|
||||||
|
</template>
|
||||||
13
frontend/src/components/ui/popover/PopoverTrigger.vue
Normal file
13
frontend/src/components/ui/popover/PopoverTrigger.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { PopoverTrigger } from 'radix-vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
asChild?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PopoverTrigger :as-child="asChild">
|
||||||
|
<slot />
|
||||||
|
</PopoverTrigger>
|
||||||
|
</template>
|
||||||
3
frontend/src/components/ui/popover/index.ts
Normal file
3
frontend/src/components/ui/popover/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as Popover } from './Popover.vue'
|
||||||
|
export { default as PopoverTrigger } from './PopoverTrigger.vue'
|
||||||
|
export { default as PopoverContent } from './PopoverContent.vue'
|
||||||
@@ -111,26 +111,26 @@
|
|||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label
|
<Label
|
||||||
for="model-name"
|
for="model-display-name"
|
||||||
class="text-xs"
|
class="text-xs"
|
||||||
>模型名称 *</Label>
|
>名称 *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="model-name"
|
id="model-display-name"
|
||||||
v-model="form.name"
|
v-model="form.display_name"
|
||||||
placeholder="claude-3-5-sonnet-20241022"
|
placeholder="Claude 3.5 Sonnet"
|
||||||
:disabled="isEditMode"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label
|
<Label
|
||||||
for="model-display-name"
|
for="model-name"
|
||||||
class="text-xs"
|
class="text-xs"
|
||||||
>显示名称 *</Label>
|
>模型ID *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="model-display-name"
|
id="model-name"
|
||||||
v-model="form.display_name"
|
v-model="form.name"
|
||||||
placeholder="Claude 3.5 Sonnet"
|
placeholder="claude-3-5-sonnet-20241022"
|
||||||
|
:disabled="isEditMode"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,105 +147,6 @@
|
|||||||
@update:model-value="(v) => setConfigField('description', v || undefined)"
|
@update:model-value="(v) => setConfigField('description', v || undefined)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-3 gap-3">
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label
|
|
||||||
for="model-family"
|
|
||||||
class="text-xs"
|
|
||||||
>模型系列</Label>
|
|
||||||
<Input
|
|
||||||
id="model-family"
|
|
||||||
:model-value="form.config?.family || ''"
|
|
||||||
placeholder="如 GPT-4、Claude 3"
|
|
||||||
@update:model-value="(v) => setConfigField('family', v || undefined)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label
|
|
||||||
for="model-context-limit"
|
|
||||||
class="text-xs"
|
|
||||||
>上下文限制</Label>
|
|
||||||
<Input
|
|
||||||
id="model-context-limit"
|
|
||||||
type="number"
|
|
||||||
:model-value="form.config?.context_limit ?? ''"
|
|
||||||
placeholder="如 128000"
|
|
||||||
@update:model-value="(v) => setConfigField('context_limit', v ? Number(v) : undefined)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label
|
|
||||||
for="model-output-limit"
|
|
||||||
class="text-xs"
|
|
||||||
>输出限制</Label>
|
|
||||||
<Input
|
|
||||||
id="model-output-limit"
|
|
||||||
type="number"
|
|
||||||
:model-value="form.config?.output_limit ?? ''"
|
|
||||||
placeholder="如 8192"
|
|
||||||
@update:model-value="(v) => setConfigField('output_limit', v ? Number(v) : undefined)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 能力配置 -->
|
|
||||||
<section class="space-y-2">
|
|
||||||
<h4 class="font-medium text-sm">
|
|
||||||
默认能力
|
|
||||||
</h4>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="form.config?.streaming !== false"
|
|
||||||
class="rounded"
|
|
||||||
@change="setConfigField('streaming', ($event.target as HTMLInputElement).checked)"
|
|
||||||
>
|
|
||||||
<Zap class="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
<span>流式</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="form.config?.vision === true"
|
|
||||||
class="rounded"
|
|
||||||
@change="setConfigField('vision', ($event.target as HTMLInputElement).checked)"
|
|
||||||
>
|
|
||||||
<Eye class="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
<span>视觉</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="form.config?.function_calling === true"
|
|
||||||
class="rounded"
|
|
||||||
@change="setConfigField('function_calling', ($event.target as HTMLInputElement).checked)"
|
|
||||||
>
|
|
||||||
<Wrench class="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
<span>工具</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="form.config?.extended_thinking === true"
|
|
||||||
class="rounded"
|
|
||||||
@change="setConfigField('extended_thinking', ($event.target as HTMLInputElement).checked)"
|
|
||||||
>
|
|
||||||
<Brain class="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
<span>思考</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="form.config?.image_generation === true"
|
|
||||||
class="rounded"
|
|
||||||
@change="setConfigField('image_generation', ($event.target as HTMLInputElement).checked)"
|
|
||||||
>
|
|
||||||
<Image class="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
<span>生图</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Key 能力配置 -->
|
<!-- Key 能力配置 -->
|
||||||
@@ -254,7 +155,7 @@
|
|||||||
class="space-y-2"
|
class="space-y-2"
|
||||||
>
|
>
|
||||||
<h4 class="font-medium text-sm">
|
<h4 class="font-medium text-sm">
|
||||||
Key 能力支持
|
模型偏好
|
||||||
</h4>
|
</h4>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<label
|
<label
|
||||||
@@ -296,6 +197,94 @@
|
|||||||
/>
|
/>
|
||||||
<span class="text-xs text-muted-foreground">可与 Token 计费叠加</span>
|
<span class="text-xs text-muted-foreground">可与 Token 计费叠加</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 视频计费(分辨率 × 时长) -->
|
||||||
|
<div class="pt-3 border-t space-y-2">
|
||||||
|
<div class="text-sm font-medium">视频计费(分辨率 × 时长)</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 text-xs"
|
||||||
|
@click="fillVideoResolutionPricePreset('common')"
|
||||||
|
>
|
||||||
|
通用
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 text-xs"
|
||||||
|
@click="fillVideoResolutionPricePreset('sora')"
|
||||||
|
>
|
||||||
|
Sora
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 text-xs"
|
||||||
|
@click="fillVideoResolutionPricePreset('veo')"
|
||||||
|
>
|
||||||
|
Veo
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 text-xs"
|
||||||
|
@click="addVideoResolutionPriceRow"
|
||||||
|
>
|
||||||
|
<Plus class="w-3.5 h-3.5 mr-0.5" />
|
||||||
|
自定义
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="videoResolutionPrices.length > 0"
|
||||||
|
class="rounded-lg border border-border overflow-hidden"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
|
||||||
|
<span>分辨率</span>
|
||||||
|
<span>单价($/秒)</span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-border">
|
||||||
|
<div
|
||||||
|
v-for="(row, idx) in videoResolutionPrices"
|
||||||
|
:key="idx"
|
||||||
|
class="grid grid-cols-[1fr_1fr_32px] gap-2 items-center px-3 py-1.5"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
v-model="row.resolution"
|
||||||
|
class="h-7 text-sm"
|
||||||
|
placeholder="如 720p"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
:model-value="row.price_per_second ?? ''"
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
min="0"
|
||||||
|
class="h-7 text-sm"
|
||||||
|
placeholder="0"
|
||||||
|
@update:model-value="(v) => row.price_per_second = parseNumberInput(v, { allowFloat: true })"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7"
|
||||||
|
title="删除"
|
||||||
|
@click="removeVideoResolutionPriceRow(idx)"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -332,15 +321,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import {
|
import {
|
||||||
Eye, Wrench, Brain, Zap, Image, Loader2, Layers, SquarePen,
|
Loader2, Layers, SquarePen,
|
||||||
Search, ChevronRight
|
Search, ChevronRight, Plus, Trash2
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { Dialog, Button, Input, Label } from '@/components/ui'
|
import { Dialog, Button, Input, Label } from '@/components/ui'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useFormDialog } from '@/composables/useFormDialog'
|
import { useFormDialog } from '@/composables/useFormDialog'
|
||||||
import { parseNumberInput } from '@/utils/form'
|
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
import TieredPricingEditor from './TieredPricingEditor.vue'
|
import TieredPricingEditor from './TieredPricingEditor.vue'
|
||||||
import {
|
import {
|
||||||
@@ -455,6 +444,31 @@ function toggleProvider(providerId: string) {
|
|||||||
// 阶梯计费配置
|
// 阶梯计费配置
|
||||||
const tieredPricing = ref<TieredPricingConfig | null>(null)
|
const tieredPricing = ref<TieredPricingConfig | null>(null)
|
||||||
|
|
||||||
|
type VideoResolutionPriceRow = { resolution: string; price_per_second: number | undefined }
|
||||||
|
|
||||||
|
const videoResolutionPrices = ref<VideoResolutionPriceRow[]>([])
|
||||||
|
|
||||||
|
const VIDEO_RESOLUTION_PRICE_PRESETS: Record<
|
||||||
|
'common' | 'sora' | 'veo',
|
||||||
|
VideoResolutionPriceRow[]
|
||||||
|
> = {
|
||||||
|
common: [
|
||||||
|
{ resolution: '480p', price_per_second: 0 },
|
||||||
|
{ resolution: '720p', price_per_second: 0 },
|
||||||
|
{ resolution: '1080p', price_per_second: 0 },
|
||||||
|
{ resolution: '4k', price_per_second: 0 },
|
||||||
|
],
|
||||||
|
sora: [
|
||||||
|
{ resolution: '720x1080', price_per_second: 0 },
|
||||||
|
{ resolution: '1024x1792', price_per_second: 0 },
|
||||||
|
],
|
||||||
|
veo: [
|
||||||
|
{ resolution: '720p', price_per_second: 0 },
|
||||||
|
{ resolution: '1080p', price_per_second: 0 },
|
||||||
|
{ resolution: '4k', price_per_second: 0 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
interface FormData {
|
interface FormData {
|
||||||
name: string
|
name: string
|
||||||
display_name: string
|
display_name: string
|
||||||
@@ -489,29 +503,137 @@ function setConfigField(key: string, value: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Key 能力选项
|
function getNested(obj: any, path: string): any {
|
||||||
const availableCapabilities = ref<CapabilityDefinition[]>([])
|
if (!obj || typeof obj !== 'object') return undefined
|
||||||
|
const parts = path.split('.').filter(Boolean)
|
||||||
|
let cur: any = obj
|
||||||
|
for (const p of parts) {
|
||||||
|
if (!cur || typeof cur !== 'object') return undefined
|
||||||
|
cur = cur[p]
|
||||||
|
}
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
|
||||||
// 加载模型列表
|
function setNested(obj: any, path: string, value: any) {
|
||||||
async function loadModels() {
|
if (!obj || typeof obj !== 'object') return
|
||||||
if (allModelsCache.value.length > 0) return
|
const parts = path.split('.').filter(Boolean)
|
||||||
loading.value = true
|
if (parts.length === 0) return
|
||||||
try {
|
let cur: any = obj
|
||||||
// 只加载一次全部模型,过滤在 computed 中完成
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
allModelsCache.value = await getModelsDevList(false)
|
const p = parts[i]
|
||||||
} catch (err) {
|
if (!cur[p] || typeof cur[p] !== 'object') {
|
||||||
log.error('Failed to load models:', err)
|
cur[p] = {}
|
||||||
} finally {
|
}
|
||||||
loading.value = false
|
cur = cur[p]
|
||||||
|
}
|
||||||
|
cur[parts[parts.length - 1]] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteNested(obj: any, path: string) {
|
||||||
|
if (!obj || typeof obj !== 'object') return
|
||||||
|
const parts = path.split('.').filter(Boolean)
|
||||||
|
if (parts.length === 0) return
|
||||||
|
let cur: any = obj
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const p = parts[i]
|
||||||
|
if (!cur[p] || typeof cur[p] !== 'object') return
|
||||||
|
cur = cur[p]
|
||||||
|
}
|
||||||
|
delete cur[parts[parts.length - 1]]
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneEmptyBillingConfig() {
|
||||||
|
const cfg = form.value.config
|
||||||
|
if (!cfg || typeof cfg !== 'object') return
|
||||||
|
const billing = cfg.billing
|
||||||
|
if (!billing || typeof billing !== 'object') return
|
||||||
|
const video = billing.video
|
||||||
|
if (video && typeof video === 'object' && Object.keys(video).length === 0) {
|
||||||
|
delete billing.video
|
||||||
|
}
|
||||||
|
if (Object.keys(billing).length === 0) {
|
||||||
|
delete cfg.billing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开对话框时加载数据
|
/**
|
||||||
watch(() => props.open, (isOpen) => {
|
* Normalize resolution key:
|
||||||
if (isOpen && !props.model) {
|
* - lowercase, remove spaces, × → x
|
||||||
loadModels()
|
* - For WxH format, sort dimensions so smaller comes first (720x1080 = 1080x720)
|
||||||
|
*/
|
||||||
|
function normalizeResolutionKey(raw: string): string {
|
||||||
|
let k = (raw || '').trim().toLowerCase().replace(/\s+/g, '').replace(/×/g, 'x')
|
||||||
|
// Check if it's WxH format (e.g., 1080x720)
|
||||||
|
const match = k.match(/^(\d+)x(\d+)$/)
|
||||||
|
if (match) {
|
||||||
|
const a = parseInt(match[1], 10)
|
||||||
|
const b = parseInt(match[2], 10)
|
||||||
|
// Sort: smaller dimension first
|
||||||
|
k = a <= b ? `${a}x${b}` : `${b}x${a}`
|
||||||
}
|
}
|
||||||
})
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadVideoPricingFromConfig() {
|
||||||
|
const cfg = form.value.config || {}
|
||||||
|
const raw = getNested(cfg, 'billing.video.price_per_second_by_resolution')
|
||||||
|
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||||
|
// 按分辨率从低到高排序
|
||||||
|
const sortedEntries = sortResolutionEntries(Object.entries(raw))
|
||||||
|
videoResolutionPrices.value = sortedEntries.map(([k, v]) => ({
|
||||||
|
resolution: String(k),
|
||||||
|
price_per_second: typeof v === 'number' ? v : undefined,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
videoResolutionPrices.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVideoPricingToConfig() {
|
||||||
|
if (!form.value.config) {
|
||||||
|
form.value.config = {}
|
||||||
|
}
|
||||||
|
const cfg = form.value.config
|
||||||
|
|
||||||
|
// Clean legacy keys
|
||||||
|
deleteNested(cfg, 'billing.video.price_per_second')
|
||||||
|
deleteNested(cfg, 'billing.video.resolution_multipliers')
|
||||||
|
|
||||||
|
// resolution/size prices (normalized: 1080x720 → 720x1080)
|
||||||
|
const map: Record<string, number> = {}
|
||||||
|
for (const row of videoResolutionPrices.value) {
|
||||||
|
const k = normalizeResolutionKey(row.resolution || '')
|
||||||
|
const v = row.price_per_second
|
||||||
|
if (!k) continue
|
||||||
|
if (typeof v !== 'number' || Number.isNaN(v)) continue
|
||||||
|
map[k] = v
|
||||||
|
}
|
||||||
|
if (Object.keys(map).length > 0) {
|
||||||
|
setNested(cfg, 'billing.video.price_per_second_by_resolution', map)
|
||||||
|
} else {
|
||||||
|
deleteNested(cfg, 'billing.video.price_per_second_by_resolution')
|
||||||
|
}
|
||||||
|
|
||||||
|
pruneEmptyBillingConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addVideoResolutionPriceRow() {
|
||||||
|
videoResolutionPrices.value.push({ resolution: '', price_per_second: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeVideoResolutionPriceRow(idx: number) {
|
||||||
|
videoResolutionPrices.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillVideoResolutionPricePreset(preset: 'common' | 'sora' | 'veo') {
|
||||||
|
videoResolutionPrices.value = VIDEO_RESOLUTION_PRICE_PRESETS[preset].map(r => ({
|
||||||
|
resolution: r.resolution,
|
||||||
|
price_per_second: r.price_per_second,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key 能力选项
|
||||||
|
const availableCapabilities = ref<CapabilityDefinition[]>([])
|
||||||
|
|
||||||
// 加载可用能力列表
|
// 加载可用能力列表
|
||||||
async function loadCapabilities() {
|
async function loadCapabilities() {
|
||||||
@@ -539,6 +661,27 @@ onMounted(() => {
|
|||||||
loadCapabilities()
|
loadCapabilities()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 加载模型列表
|
||||||
|
async function loadModels() {
|
||||||
|
if (allModelsCache.value.length > 0) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// 只加载一次全部模型,过滤在 computed 中完成
|
||||||
|
allModelsCache.value = await getModelsDevList(false)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Failed to load models:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开对话框时加载数据
|
||||||
|
watch(() => props.open, (isOpen) => {
|
||||||
|
if (isOpen && !props.model) {
|
||||||
|
loadModels()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 选择模型并填充表单
|
// 选择模型并填充表单
|
||||||
function selectModel(model: ModelsDevModelItem) {
|
function selectModel(model: ModelsDevModelItem) {
|
||||||
selectedModel.value = model
|
selectedModel.value = model
|
||||||
@@ -565,6 +708,7 @@ function selectModel(model: ModelsDevModelItem) {
|
|||||||
if (model.inputModalities?.length) config.input_modalities = model.inputModalities
|
if (model.inputModalities?.length) config.input_modalities = model.inputModalities
|
||||||
if (model.outputModalities?.length) config.output_modalities = model.outputModalities
|
if (model.outputModalities?.length) config.output_modalities = model.outputModalities
|
||||||
form.value.config = config
|
form.value.config = config
|
||||||
|
loadVideoPricingFromConfig()
|
||||||
|
|
||||||
if (model.inputPrice !== undefined || model.outputPrice !== undefined) {
|
if (model.inputPrice !== undefined || model.outputPrice !== undefined) {
|
||||||
tieredPricing.value = {
|
tieredPricing.value = {
|
||||||
@@ -596,6 +740,7 @@ function handleLogoError(event: Event) {
|
|||||||
function resetForm() {
|
function resetForm() {
|
||||||
form.value = defaultForm()
|
form.value = defaultForm()
|
||||||
tieredPricing.value = null
|
tieredPricing.value = null
|
||||||
|
videoResolutionPrices.value = []
|
||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
selectedModel.value = null
|
selectedModel.value = null
|
||||||
expandedProvider.value = null
|
expandedProvider.value = null
|
||||||
@@ -621,6 +766,7 @@ function loadModelData() {
|
|||||||
tieredPricing.value = props.model.default_tiered_pricing
|
tieredPricing.value = props.model.default_tiered_pricing
|
||||||
? JSON.parse(JSON.stringify(props.model.default_tiered_pricing))
|
? JSON.parse(JSON.stringify(props.model.default_tiered_pricing))
|
||||||
: null
|
: null
|
||||||
|
loadVideoPricingFromConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 useFormDialog 统一处理对话框逻辑
|
// 使用 useFormDialog 统一处理对话框逻辑
|
||||||
@@ -635,7 +781,7 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
|||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!form.value.name || !form.value.display_name) {
|
if (!form.value.name || !form.value.display_name) {
|
||||||
showError('请填写模型名称和显示名称')
|
showError('请填写模型ID和名称')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,6 +793,9 @@ async function handleSubmit() {
|
|||||||
const finalTiers = tieredPricingEditorRef.value?.getFinalTiers()
|
const finalTiers = tieredPricingEditorRef.value?.getFinalTiers()
|
||||||
const finalTieredPricing = finalTiers ? { tiers: finalTiers } : tieredPricing.value
|
const finalTieredPricing = finalTiers ? { tiers: finalTiers } : tieredPricing.value
|
||||||
|
|
||||||
|
// Apply billing (video) pricing into config before cleaning/submitting.
|
||||||
|
applyVideoPricingToConfig()
|
||||||
|
|
||||||
// 清理空的 config
|
// 清理空的 config
|
||||||
const cleanConfig = form.value.config && Object.keys(form.value.config).length > 0
|
const cleanConfig = form.value.config && Object.keys(form.value.config).length > 0
|
||||||
? form.value.config
|
? form.value.config
|
||||||
|
|||||||
@@ -139,100 +139,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模型能力 -->
|
|
||||||
<div class="space-y-3">
|
|
||||||
<h4 class="font-semibold text-sm">
|
|
||||||
模型能力
|
|
||||||
</h4>
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
||||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
|
||||||
<Zap class="w-5 h-5 text-muted-foreground" />
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-sm font-medium">
|
|
||||||
Streaming
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
流式输出
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
:variant="model.config?.streaming !== false ? 'default' : 'secondary'"
|
|
||||||
class="text-xs"
|
|
||||||
>
|
|
||||||
{{ model.config?.streaming !== false ? '支持' : '不支持' }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
|
||||||
<Image class="w-5 h-5 text-muted-foreground" />
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-sm font-medium">
|
|
||||||
Image Generation
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
图像生成
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
:variant="model.config?.image_generation === true ? 'default' : 'secondary'"
|
|
||||||
class="text-xs"
|
|
||||||
>
|
|
||||||
{{ model.config?.image_generation === true ? '支持' : '不支持' }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
|
||||||
<Eye class="w-5 h-5 text-muted-foreground" />
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-sm font-medium">
|
|
||||||
Vision
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
视觉理解
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
:variant="model.config?.vision === true ? 'default' : 'secondary'"
|
|
||||||
class="text-xs"
|
|
||||||
>
|
|
||||||
{{ model.config?.vision === true ? '支持' : '不支持' }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
|
||||||
<Wrench class="w-5 h-5 text-muted-foreground" />
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-sm font-medium">
|
|
||||||
Tool Use
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
工具调用
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
:variant="model.config?.function_calling === true ? 'default' : 'secondary'"
|
|
||||||
class="text-xs"
|
|
||||||
>
|
|
||||||
{{ model.config?.function_calling === true ? '支持' : '不支持' }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
|
||||||
<Brain class="w-5 h-5 text-muted-foreground" />
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-sm font-medium">
|
|
||||||
Extended Thinking
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
深度思考
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
:variant="model.config?.extended_thinking === true ? 'default' : 'secondary'"
|
|
||||||
class="text-xs"
|
|
||||||
>
|
|
||||||
{{ model.config?.extended_thinking === true ? '支持' : '不支持' }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 模型偏好 -->
|
<!-- 模型偏好 -->
|
||||||
<div
|
<div
|
||||||
v-if="model.supported_capabilities && model.supported_capabilities.length > 0"
|
v-if="model.supported_capabilities && model.supported_capabilities.length > 0"
|
||||||
@@ -307,6 +213,44 @@
|
|||||||
<Label class="text-xs text-muted-foreground whitespace-nowrap">按次计费</Label>
|
<Label class="text-xs text-muted-foreground whitespace-nowrap">按次计费</Label>
|
||||||
<span class="text-sm font-mono">${{ model.default_price_per_request.toFixed(3) }}/次</span>
|
<span class="text-sm font-mono">${{ model.default_price_per_request.toFixed(3) }}/次</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 视频分辨率计费 -->
|
||||||
|
<div
|
||||||
|
v-if="hasVideoPricing"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Video class="w-4 h-4" />
|
||||||
|
<span>视频分辨率计费 ({{ videoPricingEntries.length }} 种)</span>
|
||||||
|
</div>
|
||||||
|
<div class="border rounded-lg overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow class="bg-muted/30">
|
||||||
|
<TableHead class="text-xs h-9">
|
||||||
|
分辨率
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="text-xs h-9 text-right">
|
||||||
|
单价 ($/秒)
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow
|
||||||
|
v-for="[res, price] in videoPricingEntries"
|
||||||
|
:key="res"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
<TableCell class="py-2">
|
||||||
|
{{ res }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="py-2 text-right font-mono">
|
||||||
|
${{ (price as number).toFixed(4) }}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 多阶梯计费展示 -->
|
<!-- 多阶梯计费展示 -->
|
||||||
@@ -389,6 +333,44 @@
|
|||||||
<Label class="text-xs text-muted-foreground whitespace-nowrap">按次计费</Label>
|
<Label class="text-xs text-muted-foreground whitespace-nowrap">按次计费</Label>
|
||||||
<span class="text-sm font-mono">${{ model.default_price_per_request.toFixed(3) }}/次</span>
|
<span class="text-sm font-mono">${{ model.default_price_per_request.toFixed(3) }}/次</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 视频分辨率计费(多阶梯时也显示) -->
|
||||||
|
<div
|
||||||
|
v-if="hasVideoPricing"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Video class="w-4 h-4" />
|
||||||
|
<span>视频分辨率计费 ({{ videoPricingEntries.length }} 种)</span>
|
||||||
|
</div>
|
||||||
|
<div class="border rounded-lg overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow class="bg-muted/30">
|
||||||
|
<TableHead class="text-xs h-9">
|
||||||
|
分辨率
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="text-xs h-9 text-right">
|
||||||
|
单价 ($/秒)
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow
|
||||||
|
v-for="[res, price] in videoPricingEntries"
|
||||||
|
:key="res"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
<TableCell class="py-2">
|
||||||
|
{{ res }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="py-2 text-right font-mono">
|
||||||
|
${{ (price as number).toFixed(4) }}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -454,20 +436,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch, computed } from 'vue'
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
Eye,
|
|
||||||
Wrench,
|
|
||||||
Brain,
|
|
||||||
Zap,
|
|
||||||
Image,
|
|
||||||
Building2,
|
Building2,
|
||||||
Edit,
|
Edit,
|
||||||
Power,
|
Power,
|
||||||
Copy,
|
Copy,
|
||||||
Layers,
|
Layers,
|
||||||
BarChart3
|
BarChart3,
|
||||||
|
Video
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
@@ -483,6 +461,7 @@ import TableHead from '@/components/ui/table-head.vue'
|
|||||||
import TableCell from '@/components/ui/table-cell.vue'
|
import TableCell from '@/components/ui/table-cell.vue'
|
||||||
import RoutingTab from './RoutingTab.vue'
|
import RoutingTab from './RoutingTab.vue'
|
||||||
import ModelMappingsTab from './ModelMappingsTab.vue'
|
import ModelMappingsTab from './ModelMappingsTab.vue'
|
||||||
|
import { sortResolutionEntries } from '@/utils/form'
|
||||||
|
|
||||||
// 使用外部类型定义
|
// 使用外部类型定义
|
||||||
import type { GlobalModelResponse } from '@/api/global-models'
|
import type { GlobalModelResponse } from '@/api/global-models'
|
||||||
@@ -570,6 +549,19 @@ function getCapabilityDisplayName(capName: string): string {
|
|||||||
return cap?.display_name || capName
|
return cap?.display_name || capName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检测是否有视频分辨率计费配置
|
||||||
|
const hasVideoPricing = computed(() => {
|
||||||
|
const priceByResolution = props.model?.config?.billing?.video?.price_per_second_by_resolution
|
||||||
|
return priceByResolution && typeof priceByResolution === 'object' && Object.keys(priceByResolution).length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取视频分辨率计费条目(按分辨率从低到高排序)
|
||||||
|
const videoPricingEntries = computed(() => {
|
||||||
|
const priceByResolution = props.model?.config?.billing?.video?.price_per_second_by_resolution
|
||||||
|
if (!priceByResolution || typeof priceByResolution !== 'object') return []
|
||||||
|
return sortResolutionEntries(Object.entries(priceByResolution))
|
||||||
|
})
|
||||||
|
|
||||||
const detailTab = ref('basic')
|
const detailTab = ref('basic')
|
||||||
|
|
||||||
// 处理背景点击
|
// 处理背景点击
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<Dialog
|
<Dialog
|
||||||
:model-value="open"
|
:model-value="open"
|
||||||
:title="isEditing ? '编辑模型配置' : '添加模型'"
|
:title="isEditing ? '编辑模型配置' : '添加模型'"
|
||||||
:description="isEditing ? '修改模型价格和能力配置' : '为此 Provider 添加模型实现'"
|
:description="isEditing ? '修改模型价格配置' : '为此 Provider 添加模型实现'"
|
||||||
:icon="isEditing ? SquarePen : Layers"
|
:icon="isEditing ? SquarePen : Layers"
|
||||||
size="xl"
|
size="xl"
|
||||||
@update:model-value="handleClose"
|
@update:model-value="handleClose"
|
||||||
@@ -86,67 +86,97 @@
|
|||||||
/>
|
/>
|
||||||
<span class="text-xs text-muted-foreground">每次请求固定费用,留空使用全局模型默认值</span>
|
<span class="text-xs text-muted-foreground">每次请求固定费用,留空使用全局模型默认值</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 能力配置 -->
|
<!-- 视频计费(可选覆盖) -->
|
||||||
<div class="space-y-4">
|
<div class="pt-3 border-t space-y-2">
|
||||||
<h4 class="font-semibold text-sm border-b pb-2">
|
<div class="text-sm font-medium">视频计费(可选覆盖)</div>
|
||||||
能力配置
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="flex items-center gap-1.5 flex-wrap">
|
||||||
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
|
<Button
|
||||||
<input
|
type="button"
|
||||||
v-model="form.supports_streaming"
|
variant="outline"
|
||||||
type="checkbox"
|
size="sm"
|
||||||
:indeterminate="form.supports_streaming === undefined"
|
class="h-7 text-xs"
|
||||||
class="rounded"
|
@click="() => { fillVideoResolutionPricePreset('common'); configTouched = true }"
|
||||||
>
|
>
|
||||||
<Zap class="w-4 h-4 text-muted-foreground shrink-0" />
|
通用
|
||||||
<span class="text-sm font-medium">流式输出</span>
|
</Button>
|
||||||
</label>
|
<Button
|
||||||
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
|
type="button"
|
||||||
<input
|
variant="outline"
|
||||||
v-model="form.supports_image_generation"
|
size="sm"
|
||||||
type="checkbox"
|
class="h-7 text-xs"
|
||||||
:indeterminate="form.supports_image_generation === undefined"
|
@click="() => { fillVideoResolutionPricePreset('sora'); configTouched = true }"
|
||||||
class="rounded"
|
|
||||||
>
|
>
|
||||||
<Image class="w-4 h-4 text-muted-foreground shrink-0" />
|
Sora
|
||||||
<span class="text-sm font-medium">图像生成</span>
|
</Button>
|
||||||
</label>
|
<Button
|
||||||
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
|
type="button"
|
||||||
<input
|
variant="outline"
|
||||||
v-model="form.supports_vision"
|
size="sm"
|
||||||
type="checkbox"
|
class="h-7 text-xs"
|
||||||
:indeterminate="form.supports_vision === undefined"
|
@click="() => { fillVideoResolutionPricePreset('veo'); configTouched = true }"
|
||||||
class="rounded"
|
|
||||||
>
|
>
|
||||||
<Eye class="w-4 h-4 text-muted-foreground shrink-0" />
|
Veo
|
||||||
<span class="text-sm font-medium">视觉理解</span>
|
</Button>
|
||||||
</label>
|
<Button
|
||||||
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
|
type="button"
|
||||||
<input
|
variant="outline"
|
||||||
v-model="form.supports_function_calling"
|
size="sm"
|
||||||
type="checkbox"
|
class="h-7 text-xs"
|
||||||
:indeterminate="form.supports_function_calling === undefined"
|
@click="() => { addVideoResolutionPriceRow(); configTouched = true }"
|
||||||
class="rounded"
|
|
||||||
>
|
>
|
||||||
<Wrench class="w-4 h-4 text-muted-foreground shrink-0" />
|
<Plus class="w-3.5 h-3.5 mr-0.5" />
|
||||||
<span class="text-sm font-medium">工具调用</span>
|
自定义
|
||||||
</label>
|
</Button>
|
||||||
<label class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer hover:bg-muted/50">
|
</div>
|
||||||
<input
|
|
||||||
v-model="form.supports_extended_thinking"
|
<div
|
||||||
type="checkbox"
|
v-if="videoResolutionPrices.length > 0"
|
||||||
:indeterminate="form.supports_extended_thinking === undefined"
|
class="rounded-lg border border-border overflow-hidden"
|
||||||
class="rounded"
|
>
|
||||||
>
|
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
|
||||||
<Brain class="w-4 h-4 text-muted-foreground shrink-0" />
|
<span>分辨率</span>
|
||||||
<span class="text-sm font-medium">深度思考</span>
|
<span>单价($/秒)</span>
|
||||||
</label>
|
<span></span>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-border">
|
||||||
|
<div
|
||||||
|
v-for="(row, idx) in videoResolutionPrices"
|
||||||
|
:key="idx"
|
||||||
|
class="grid grid-cols-[1fr_1fr_32px] gap-2 items-center px-3 py-1.5"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
v-model="row.resolution"
|
||||||
|
class="h-7 text-sm"
|
||||||
|
placeholder="如 720p"
|
||||||
|
@update:model-value="() => { configTouched = true }"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
:model-value="row.price_per_second ?? ''"
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
min="0"
|
||||||
|
class="h-7 text-sm"
|
||||||
|
placeholder="0"
|
||||||
|
@update:model-value="(v) => { row.price_per_second = parseNumberInput(v, { allowFloat: true }); configTouched = true }"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7"
|
||||||
|
title="删除"
|
||||||
|
@click="() => { removeVideoResolutionPriceRow(idx); configTouched = true }"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -172,7 +202,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { Eye, Wrench, Brain, Zap, Loader2, Image, Layers, SquarePen } from 'lucide-vue-next'
|
import { Loader2, Layers, SquarePen, Plus, Trash2 } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
Button,
|
Button,
|
||||||
@@ -185,7 +215,7 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { parseNumberInput } from '@/utils/form'
|
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
|
||||||
import { createModel, updateModel, getProviderModels } from '@/api/endpoints/models'
|
import { createModel, updateModel, getProviderModels } from '@/api/endpoints/models'
|
||||||
import { listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
|
import { listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
|
||||||
import TieredPricingEditor from '@/features/models/components/TieredPricingEditor.vue'
|
import TieredPricingEditor from '@/features/models/components/TieredPricingEditor.vue'
|
||||||
@@ -240,9 +270,36 @@ const tieredPricingModified = ref(false)
|
|||||||
// 保存原始配置用于比较
|
// 保存原始配置用于比较
|
||||||
const originalTieredPricing = ref<string>('')
|
const originalTieredPricing = ref<string>('')
|
||||||
|
|
||||||
|
type VideoResolutionPriceRow = { resolution: string; price_per_second: number | undefined }
|
||||||
|
|
||||||
|
const configTouched = ref(false)
|
||||||
|
const videoResolutionPrices = ref<VideoResolutionPriceRow[]>([])
|
||||||
|
|
||||||
|
const VIDEO_RESOLUTION_PRICE_PRESETS: Record<
|
||||||
|
'common' | 'sora' | 'veo',
|
||||||
|
VideoResolutionPriceRow[]
|
||||||
|
> = {
|
||||||
|
common: [
|
||||||
|
{ resolution: '480p', price_per_second: 0 },
|
||||||
|
{ resolution: '720p', price_per_second: 0 },
|
||||||
|
{ resolution: '1080p', price_per_second: 0 },
|
||||||
|
{ resolution: '4k', price_per_second: 0 },
|
||||||
|
],
|
||||||
|
sora: [
|
||||||
|
{ resolution: '720x1080', price_per_second: 0 },
|
||||||
|
{ resolution: '1024x1792', price_per_second: 0 },
|
||||||
|
],
|
||||||
|
veo: [
|
||||||
|
{ resolution: '720p', price_per_second: 0 },
|
||||||
|
{ resolution: '1080p', price_per_second: 0 },
|
||||||
|
{ resolution: '4k', price_per_second: 0 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
global_model_id: '',
|
global_model_id: '',
|
||||||
price_per_request: undefined as number | undefined,
|
price_per_request: undefined as number | undefined,
|
||||||
|
config: {} as Record<string, any>,
|
||||||
// 能力配置
|
// 能力配置
|
||||||
supports_vision: undefined as boolean | undefined,
|
supports_vision: undefined as boolean | undefined,
|
||||||
supports_function_calling: undefined as boolean | undefined,
|
supports_function_calling: undefined as boolean | undefined,
|
||||||
@@ -258,9 +315,13 @@ watch(() => props.open, async (newOpen) => {
|
|||||||
resetForm()
|
resetForm()
|
||||||
if (props.editingModel) {
|
if (props.editingModel) {
|
||||||
// 编辑模式:填充表单
|
// 编辑模式:填充表单
|
||||||
|
// 使用有效配置(合并全局模型的默认值)供用户查看和编辑
|
||||||
|
const effectiveConfig = props.editingModel.effective_config || props.editingModel.config || {}
|
||||||
form.value = {
|
form.value = {
|
||||||
global_model_id: props.editingModel.global_model_id || '',
|
global_model_id: props.editingModel.global_model_id || '',
|
||||||
price_per_request: props.editingModel.price_per_request ?? undefined,
|
// 显示有效的按次计费价格(继承自全局模型)
|
||||||
|
price_per_request: props.editingModel.effective_price_per_request ?? props.editingModel.price_per_request ?? undefined,
|
||||||
|
config: effectiveConfig ? JSON.parse(JSON.stringify(effectiveConfig)) : {},
|
||||||
supports_vision: props.editingModel.supports_vision ?? undefined,
|
supports_vision: props.editingModel.supports_vision ?? undefined,
|
||||||
supports_function_calling: props.editingModel.supports_function_calling ?? undefined,
|
supports_function_calling: props.editingModel.supports_function_calling ?? undefined,
|
||||||
supports_streaming: props.editingModel.supports_streaming ?? undefined,
|
supports_streaming: props.editingModel.supports_streaming ?? undefined,
|
||||||
@@ -268,6 +329,8 @@ watch(() => props.open, async (newOpen) => {
|
|||||||
supports_image_generation: props.editingModel.supports_image_generation ?? undefined,
|
supports_image_generation: props.editingModel.supports_image_generation ?? undefined,
|
||||||
is_active: props.editingModel.is_active
|
is_active: props.editingModel.is_active
|
||||||
}
|
}
|
||||||
|
// 从有效配置中加载视频费用
|
||||||
|
loadVideoPricingFromConfig(effectiveConfig)
|
||||||
// 加载阶梯计费配置:优先使用 Provider 自定义配置,否则使用有效配置(继承自全局模型)
|
// 加载阶梯计费配置:优先使用 Provider 自定义配置,否则使用有效配置(继承自全局模型)
|
||||||
const pricing = props.editingModel.tiered_pricing || props.editingModel.effective_tiered_pricing
|
const pricing = props.editingModel.tiered_pricing || props.editingModel.effective_tiered_pricing
|
||||||
if (pricing) {
|
if (pricing) {
|
||||||
@@ -314,6 +377,7 @@ function resetForm() {
|
|||||||
form.value = {
|
form.value = {
|
||||||
global_model_id: '',
|
global_model_id: '',
|
||||||
price_per_request: undefined,
|
price_per_request: undefined,
|
||||||
|
config: {},
|
||||||
supports_vision: undefined,
|
supports_vision: undefined,
|
||||||
supports_function_calling: undefined,
|
supports_function_calling: undefined,
|
||||||
supports_streaming: undefined,
|
supports_streaming: undefined,
|
||||||
@@ -321,12 +385,149 @@ function resetForm() {
|
|||||||
supports_image_generation: undefined,
|
supports_image_generation: undefined,
|
||||||
is_active: true
|
is_active: true
|
||||||
}
|
}
|
||||||
|
configTouched.value = false
|
||||||
|
videoResolutionPrices.value = []
|
||||||
tieredPricing.value = null
|
tieredPricing.value = null
|
||||||
tieredPricingModified.value = false
|
tieredPricingModified.value = false
|
||||||
originalTieredPricing.value = ''
|
originalTieredPricing.value = ''
|
||||||
availableGlobalModels.value = []
|
availableGlobalModels.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNested(obj: any, path: string): any {
|
||||||
|
if (!obj || typeof obj !== 'object') return undefined
|
||||||
|
const parts = path.split('.').filter(Boolean)
|
||||||
|
let cur: any = obj
|
||||||
|
for (const p of parts) {
|
||||||
|
if (!cur || typeof cur !== 'object') return undefined
|
||||||
|
cur = cur[p]
|
||||||
|
}
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNested(obj: any, path: string, value: any) {
|
||||||
|
if (!obj || typeof obj !== 'object') return
|
||||||
|
const parts = path.split('.').filter(Boolean)
|
||||||
|
if (parts.length === 0) return
|
||||||
|
let cur: any = obj
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const p = parts[i]
|
||||||
|
if (!cur[p] || typeof cur[p] !== 'object') {
|
||||||
|
cur[p] = {}
|
||||||
|
}
|
||||||
|
cur = cur[p]
|
||||||
|
}
|
||||||
|
cur[parts[parts.length - 1]] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteNested(obj: any, path: string) {
|
||||||
|
if (!obj || typeof obj !== 'object') return
|
||||||
|
const parts = path.split('.').filter(Boolean)
|
||||||
|
if (parts.length === 0) return
|
||||||
|
let cur: any = obj
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const p = parts[i]
|
||||||
|
if (!cur[p] || typeof cur[p] !== 'object') return
|
||||||
|
cur = cur[p]
|
||||||
|
}
|
||||||
|
delete cur[parts[parts.length - 1]]
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneEmptyBillingConfig(cfg: Record<string, any>) {
|
||||||
|
const billing = cfg.billing
|
||||||
|
if (!billing || typeof billing !== 'object') return
|
||||||
|
const video = billing.video
|
||||||
|
if (video && typeof video === 'object' && Object.keys(video).length === 0) {
|
||||||
|
delete billing.video
|
||||||
|
}
|
||||||
|
if (Object.keys(billing).length === 0) {
|
||||||
|
delete cfg.billing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize resolution key:
|
||||||
|
* - lowercase, remove spaces, × → x
|
||||||
|
* - For WxH format, sort dimensions so smaller comes first (720x1080 = 1080x720)
|
||||||
|
*/
|
||||||
|
function normalizeResolutionKey(raw: string): string {
|
||||||
|
let k = (raw || '').trim().toLowerCase().replace(/\s+/g, '').replace(/×/g, 'x')
|
||||||
|
// Check if it's WxH format (e.g., 1080x720)
|
||||||
|
const match = k.match(/^(\d+)x(\d+)$/)
|
||||||
|
if (match) {
|
||||||
|
const a = parseInt(match[1], 10)
|
||||||
|
const b = parseInt(match[2], 10)
|
||||||
|
// Sort: smaller dimension first
|
||||||
|
k = a <= b ? `${a}x${b}` : `${b}x${a}`
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadVideoPricingFromConfig(cfg: Record<string, any>) {
|
||||||
|
const raw = getNested(cfg, 'billing.video.price_per_second_by_resolution')
|
||||||
|
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||||
|
// 按分辨率从低到高排序
|
||||||
|
const sortedEntries = sortResolutionEntries(Object.entries(raw))
|
||||||
|
videoResolutionPrices.value = sortedEntries.map(([k, v]) => ({
|
||||||
|
resolution: String(k),
|
||||||
|
price_per_second: typeof v === 'number' ? v : undefined,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
videoResolutionPrices.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVideoPricingToConfig(cfg: Record<string, any>) {
|
||||||
|
// Clean legacy keys
|
||||||
|
deleteNested(cfg, 'billing.video.price_per_second')
|
||||||
|
deleteNested(cfg, 'billing.video.resolution_multipliers')
|
||||||
|
|
||||||
|
// resolution/size prices (normalized: 1080x720 → 720x1080)
|
||||||
|
const map: Record<string, number> = {}
|
||||||
|
for (const row of videoResolutionPrices.value) {
|
||||||
|
const k = normalizeResolutionKey(row.resolution || '')
|
||||||
|
const v = row.price_per_second
|
||||||
|
if (!k) continue
|
||||||
|
if (typeof v !== 'number' || Number.isNaN(v)) continue
|
||||||
|
map[k] = v
|
||||||
|
}
|
||||||
|
if (Object.keys(map).length > 0) {
|
||||||
|
setNested(cfg, 'billing.video.price_per_second_by_resolution', map)
|
||||||
|
} else {
|
||||||
|
deleteNested(cfg, 'billing.video.price_per_second_by_resolution')
|
||||||
|
}
|
||||||
|
pruneEmptyBillingConfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addVideoResolutionPriceRow() {
|
||||||
|
videoResolutionPrices.value.push({ resolution: '', price_per_second: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeVideoResolutionPriceRow(idx: number) {
|
||||||
|
videoResolutionPrices.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillVideoResolutionPricePreset(preset: 'common' | 'sora' | 'veo') {
|
||||||
|
videoResolutionPrices.value = VIDEO_RESOLUTION_PRICE_PRESETS[preset].map(r => ({
|
||||||
|
resolution: r.resolution,
|
||||||
|
price_per_second: r.price_per_second,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyVideoPricingFromSelectedGlobal() {
|
||||||
|
const gm = availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
|
||||||
|
const cfg = gm?.config || {}
|
||||||
|
if (cfg && typeof cfg === 'object') {
|
||||||
|
const raw = getNested(cfg, 'billing.video.price_per_second_by_resolution')
|
||||||
|
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||||
|
videoResolutionPrices.value = Object.entries(raw).map(([k, v]) => ({
|
||||||
|
resolution: String(k),
|
||||||
|
price_per_second: typeof v === 'number' ? v : undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configTouched.value = true
|
||||||
|
}
|
||||||
|
|
||||||
// 加载可用的全局模型(排除已添加的)
|
// 加载可用的全局模型(排除已添加的)
|
||||||
async function loadAvailableGlobalModels() {
|
async function loadAvailableGlobalModels() {
|
||||||
loadingGlobalModels.value = true
|
loadingGlobalModels.value = true
|
||||||
@@ -370,12 +571,19 @@ async function handleSubmit() {
|
|||||||
const finalTiers = tieredPricingEditorRef.value?.getFinalTiers()
|
const finalTiers = tieredPricingEditorRef.value?.getFinalTiers()
|
||||||
const finalTieredPricing = finalTiers ? { tiers: finalTiers } : tieredPricing.value
|
const finalTieredPricing = finalTiers ? { tiers: finalTiers } : tieredPricing.value
|
||||||
|
|
||||||
|
// Apply billing (video) pricing into config.
|
||||||
|
applyVideoPricingToConfig(form.value.config)
|
||||||
|
const cleanConfig = form.value.config && Object.keys(form.value.config).length > 0
|
||||||
|
? form.value.config
|
||||||
|
: undefined
|
||||||
|
|
||||||
if (isEditing.value && props.editingModel) {
|
if (isEditing.value && props.editingModel) {
|
||||||
// 编辑模式
|
// 编辑模式
|
||||||
// 注意:使用 null 而不是 undefined 来显式清空字段(undefined 会被 JSON 序列化忽略)
|
// 注意:使用 null 而不是 undefined 来显式清空字段(undefined 会被 JSON 序列化忽略)
|
||||||
await updateModel(props.providerId, props.editingModel.id, {
|
await updateModel(props.providerId, props.editingModel.id, {
|
||||||
tiered_pricing: finalTieredPricing,
|
tiered_pricing: finalTieredPricing,
|
||||||
price_per_request: form.value.price_per_request ?? null,
|
price_per_request: form.value.price_per_request ?? null,
|
||||||
|
config: cleanConfig || null,
|
||||||
supports_vision: form.value.supports_vision,
|
supports_vision: form.value.supports_vision,
|
||||||
supports_function_calling: form.value.supports_function_calling,
|
supports_function_calling: form.value.supports_function_calling,
|
||||||
supports_streaming: form.value.supports_streaming,
|
supports_streaming: form.value.supports_streaming,
|
||||||
@@ -393,6 +601,7 @@ async function handleSubmit() {
|
|||||||
// 只有修改了才提交,否则传 undefined 让后端继承 GlobalModel 配置
|
// 只有修改了才提交,否则传 undefined 让后端继承 GlobalModel 配置
|
||||||
tiered_pricing: tieredPricingModified.value ? finalTieredPricing : undefined,
|
tiered_pricing: tieredPricingModified.value ? finalTieredPricing : undefined,
|
||||||
price_per_request: form.value.price_per_request,
|
price_per_request: form.value.price_per_request,
|
||||||
|
config: configTouched.value ? cleanConfig : undefined,
|
||||||
supports_vision: form.value.supports_vision,
|
supports_vision: form.value.supports_vision,
|
||||||
supports_function_calling: form.value.supports_function_calling,
|
supports_function_calling: form.value.supports_function_calling,
|
||||||
supports_streaming: form.value.supports_streaming,
|
supports_streaming: form.value.supports_streaming,
|
||||||
|
|||||||
@@ -103,8 +103,18 @@
|
|||||||
${{ formatPrice(model.effective_price_per_request ?? model.price_per_request) }}/次
|
${{ formatPrice(model.effective_price_per_request ?? model.price_per_request) }}/次
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- 视频费用计费 -->
|
||||||
|
<template v-if="hasVideoPricing(model)">
|
||||||
|
<span class="text-muted-foreground text-right">视频:</span>
|
||||||
|
<span
|
||||||
|
class="font-mono font-semibold"
|
||||||
|
:title="getVideoPricingTooltip(model)"
|
||||||
|
>
|
||||||
|
{{ getVideoPricingDisplay(model) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
<!-- 无计费配置 -->
|
<!-- 无计费配置 -->
|
||||||
<template v-if="!hasTokenPricing(model) && !hasRequestPricing(model)">
|
<template v-if="!hasTokenPricing(model) && !hasRequestPricing(model) && !hasVideoPricing(model)">
|
||||||
<span class="text-muted-foreground">—</span>
|
<span class="text-muted-foreground">—</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,6 +227,7 @@ import {
|
|||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
|
import { sortResolutionEntries } from '@/utils/form'
|
||||||
import {
|
import {
|
||||||
getProviderModels,
|
getProviderModels,
|
||||||
getProviderMappingPreview,
|
getProviderMappingPreview,
|
||||||
@@ -345,6 +356,38 @@ function hasRequestPricing(model: Model): boolean {
|
|||||||
return requestPrice != null && requestPrice > 0
|
return requestPrice != null && requestPrice > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否有视频分辨率计费配置
|
||||||
|
function hasVideoPricing(model: Model): boolean {
|
||||||
|
const priceByResolution = model.effective_config?.billing?.video?.price_per_second_by_resolution
|
||||||
|
|| model.config?.billing?.video?.price_per_second_by_resolution
|
||||||
|
return priceByResolution && typeof priceByResolution === 'object' && Object.keys(priceByResolution).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取视频计费的显示文本
|
||||||
|
function getVideoPricingDisplay(model: Model): string {
|
||||||
|
const priceByResolution = model.effective_config?.billing?.video?.price_per_second_by_resolution
|
||||||
|
|| model.config?.billing?.video?.price_per_second_by_resolution
|
||||||
|
if (!priceByResolution || typeof priceByResolution !== 'object') return ''
|
||||||
|
const entries = sortResolutionEntries(Object.entries(priceByResolution))
|
||||||
|
if (entries.length === 0) return ''
|
||||||
|
// 获取最低分辨率和价格
|
||||||
|
const [firstRes, firstPrice] = entries[0]
|
||||||
|
const priceStr = `${firstRes} $${(firstPrice as number).toFixed(2)}/s`
|
||||||
|
if (entries.length > 1) {
|
||||||
|
return `${priceStr} [${entries.length}种]`
|
||||||
|
}
|
||||||
|
return priceStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取视频计费详情的 tooltip
|
||||||
|
function getVideoPricingTooltip(model: Model): string {
|
||||||
|
const priceByResolution = model.effective_config?.billing?.video?.price_per_second_by_resolution
|
||||||
|
|| model.config?.billing?.video?.price_per_second_by_resolution
|
||||||
|
if (!priceByResolution || typeof priceByResolution !== 'object') return ''
|
||||||
|
const entries = sortResolutionEntries(Object.entries(priceByResolution))
|
||||||
|
return entries.map(([res, price]) => `${res}: $${(price as number).toFixed(4)}/s`).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
// 获取状态指示灯样式
|
// 获取状态指示灯样式
|
||||||
function getStatusIndicatorClass(model: Model): string {
|
function getStatusIndicatorClass(model: Model): string {
|
||||||
if (!model.is_active) {
|
if (!model.is_active) {
|
||||||
|
|||||||
@@ -160,24 +160,40 @@
|
|||||||
<!-- 分隔线 -->
|
<!-- 分隔线 -->
|
||||||
<Separator class="mb-4" />
|
<Separator class="mb-4" />
|
||||||
|
|
||||||
<!-- 统一使用阶梯计费展示方式 -->
|
<!-- ========== 1. 费用聚合计算 ========== -->
|
||||||
<!-- 单价信息行 -->
|
|
||||||
<div class="text-xs text-muted-foreground mb-3 flex items-center gap-2 flex-wrap">
|
<div class="text-xs text-muted-foreground mb-3 flex items-center gap-2 flex-wrap">
|
||||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground/70">{{ priceSourceLabel }}</span>
|
<span class="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground/70">{{ priceSourceLabel }}</span>
|
||||||
<span class="text-foreground">|</span>
|
<span class="text-foreground">|</span>
|
||||||
<span>总输入上下文: <span class="font-mono font-medium text-foreground">{{ formatNumber(totalInputContext) }}</span></span>
|
<span class="font-mono text-foreground">
|
||||||
<span class="text-muted-foreground/60">(输入 {{ formatNumber(detail.tokens?.input || detail.input_tokens || 0) }} + 缓存创建 {{ formatNumber(detail.cache_creation_input_tokens || 0) }} + 缓存读取 {{ formatNumber(detail.cache_read_input_tokens || 0) }})</span>
|
总费用 = Token费用 <span class="font-medium">${{ tokenCostTotal.toFixed(6) }}</span>
|
||||||
<Badge
|
<template v-if="perRequestCost > 0">
|
||||||
v-if="displayTiers.length > 1"
|
+ 按次费用 <span class="font-medium">${{ perRequestCost.toFixed(6) }}</span>
|
||||||
variant="outline"
|
</template>
|
||||||
class="text-[10px] px-1.5 py-0 h-4"
|
<template v-if="videoCostTotal > 0">
|
||||||
>
|
+ {{ detail.video_billing?.task_type === 'image' ? '图像' : detail.video_billing?.task_type === 'audio' ? '音频' : '视频' }}费用 <span class="font-medium">${{ videoCostTotal.toFixed(6) }}</span>
|
||||||
命中第 {{ currentTierIndex + 1 }} 阶
|
</template>
|
||||||
</Badge>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 统一使用阶梯展示格式 -->
|
<!-- ========== 2. Token分阶段成本 ========== -->
|
||||||
<div class="space-y-2">
|
<div
|
||||||
|
v-if="hasTokenCost"
|
||||||
|
class="space-y-2 mb-3"
|
||||||
|
>
|
||||||
|
<!-- 阶梯标题 -->
|
||||||
|
<div class="text-xs text-muted-foreground flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="font-medium text-foreground">Token 计费</span>
|
||||||
|
<span class="text-muted-foreground/60">(输入 {{ formatNumber(detail.tokens?.input || detail.input_tokens || 0) }} + 缓存创建 {{ formatNumber(detail.cache_creation_input_tokens || 0) }} + 缓存读取 {{ formatNumber(detail.cache_read_input_tokens || 0) }})</span>
|
||||||
|
<Badge
|
||||||
|
v-if="displayTiers.length > 1"
|
||||||
|
variant="outline"
|
||||||
|
class="text-[10px] px-1.5 py-0 h-4"
|
||||||
|
>
|
||||||
|
命中第 {{ currentTierIndex + 1 }} 阶
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 阶梯展示 -->
|
||||||
<div
|
<div
|
||||||
v-for="(tier, index) in displayTiers"
|
v-for="(tier, index) in displayTiers"
|
||||||
:key="index"
|
:key="index"
|
||||||
@@ -238,7 +254,7 @@
|
|||||||
<span class="text-xs font-mono">${{ (detail.cost?.output || detail.output_cost || 0).toFixed(6) }}</span>
|
<span class="text-xs font-mono">${{ (detail.cost?.output || detail.output_cost || 0).toFixed(6) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 缓存创建 缓存读取(始终显示) -->
|
<!-- 缓存创建 缓存读取 -->
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex items-center flex-1">
|
<div class="flex items-center flex-1">
|
||||||
<span class="text-xs text-muted-foreground w-[56px]">缓存创建</span>
|
<span class="text-xs text-muted-foreground w-[56px]">缓存创建</span>
|
||||||
@@ -255,29 +271,90 @@
|
|||||||
<span class="text-xs font-mono">${{ (detail.cache_read_cost || 0).toFixed(6) }}</span>
|
<span class="text-xs font-mono">${{ (detail.cache_read_cost || 0).toFixed(6) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 按次计费 -->
|
|
||||||
<div
|
|
||||||
v-if="detail.request_cost"
|
|
||||||
class="flex items-center"
|
|
||||||
>
|
|
||||||
<div class="flex items-center flex-1">
|
|
||||||
<span class="text-xs text-muted-foreground w-[56px]">按次计费</span>
|
|
||||||
<span class="text-sm font-semibold font-mono flex-1 text-center" />
|
|
||||||
<span class="text-xs font-mono">${{ detail.request_cost.toFixed(6) }}</span>
|
|
||||||
</div>
|
|
||||||
<Separator
|
|
||||||
orientation="vertical"
|
|
||||||
class="h-4 mx-4 invisible"
|
|
||||||
/>
|
|
||||||
<div class="flex items-center flex-1 invisible">
|
|
||||||
<span class="text-xs text-muted-foreground w-[56px]">占位</span>
|
|
||||||
<span class="text-sm font-semibold font-mono flex-1 text-center">0</span>
|
|
||||||
<span class="text-xs font-mono">$0.000000</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ========== 3. 按次计费(独立隔离) ========== -->
|
||||||
|
<div
|
||||||
|
v-if="perRequestCost > 0 && !detail.video_billing"
|
||||||
|
class="rounded-lg p-3 bg-amber-500/5 border border-amber-500/30 mb-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between text-xs mb-2">
|
||||||
|
<span class="font-medium text-amber-600 dark:text-amber-400">按次计费</span>
|
||||||
|
<span
|
||||||
|
v-if="detail.price_per_request"
|
||||||
|
class="text-muted-foreground"
|
||||||
|
>
|
||||||
|
单价 ${{ detail.price_per_request.toFixed(6) }}/次
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex items-center flex-1">
|
||||||
|
<span class="text-xs text-muted-foreground w-[56px]">请求次数</span>
|
||||||
|
<span class="text-sm font-semibold font-mono flex-1 text-center">1</span>
|
||||||
|
<span class="text-xs font-mono font-medium">${{ perRequestCost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========== 4. 视频/图像/音频计费(独立隔离,与Token计费风格一致) ========== -->
|
||||||
|
<div
|
||||||
|
v-if="detail.video_billing"
|
||||||
|
class="rounded-lg p-3 space-y-2 bg-primary/5 border border-primary/30"
|
||||||
|
>
|
||||||
|
<!-- 标题行(与阶梯标题行风格一致) -->
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2 text-xs">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium text-primary">
|
||||||
|
{{ getTaskTypeLabel(detail.video_billing.task_type) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="detail.video_billing.resolution"
|
||||||
|
class="text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ detail.video_billing.resolution }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- 费用计算公式 -->
|
||||||
|
<div class="text-muted-foreground flex items-center gap-2 flex-wrap">
|
||||||
|
<span
|
||||||
|
v-if="detail.video_billing.duration_seconds && detail.video_billing.video_price_per_second"
|
||||||
|
class="font-mono"
|
||||||
|
>
|
||||||
|
{{ detail.video_billing.duration_seconds.toFixed(1) }}s × ${{ detail.video_billing.video_price_per_second.toFixed(4) }}/s = ${{ videoCostTotal.toFixed(6) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="detail.video_billing.video_price_per_second"
|
||||||
|
class="font-mono"
|
||||||
|
>
|
||||||
|
${{ detail.video_billing.video_price_per_second.toFixed(4) }}/秒
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 费用详情(与Token详情行风格一致) -->
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex items-center flex-1">
|
||||||
|
<span class="text-xs text-muted-foreground w-[56px]">
|
||||||
|
{{ detail.video_billing.task_type === 'video' ? '时长' : detail.video_billing.task_type === 'audio' ? '时长' : '数量' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-semibold font-mono flex-1 text-center">
|
||||||
|
{{ detail.video_billing.duration_seconds ? formatDuration(detail.video_billing.duration_seconds) : '1' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs font-mono">${{ videoCostTotal.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
<Separator
|
||||||
|
orientation="vertical"
|
||||||
|
class="h-4 mx-4 invisible"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center flex-1 invisible">
|
||||||
|
<span class="text-xs text-muted-foreground w-[56px]">占位</span>
|
||||||
|
<span class="text-sm font-semibold font-mono flex-1 text-center">0</span>
|
||||||
|
<span class="text-xs font-mono">$0.000000</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -698,6 +775,43 @@ const totalInputContext = computed(() => {
|
|||||||
return input + cacheCreation + cacheRead
|
return input + cacheCreation + cacheRead
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Token 费用总计
|
||||||
|
const tokenCostTotal = computed(() => {
|
||||||
|
if (!detail.value) return 0
|
||||||
|
const inputCost = detail.value.cost?.input || detail.value.input_cost || 0
|
||||||
|
const outputCost = detail.value.cost?.output || detail.value.output_cost || 0
|
||||||
|
const cacheCreationCost = detail.value.cache_creation_cost || 0
|
||||||
|
const cacheReadCost = detail.value.cache_read_cost || 0
|
||||||
|
return inputCost + outputCost + cacheCreationCost + cacheReadCost
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按次计费费用(非视频任务时)
|
||||||
|
const perRequestCost = computed(() => {
|
||||||
|
if (!detail.value) return 0
|
||||||
|
// 视频任务的 request_cost 实际上是视频费用,不算按次
|
||||||
|
if (detail.value.video_billing) return 0
|
||||||
|
return detail.value.request_cost || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 视频/图像/音频费用
|
||||||
|
const videoCostTotal = computed(() => {
|
||||||
|
if (!detail.value?.video_billing) return 0
|
||||||
|
return detail.value.video_billing.video_cost
|
||||||
|
|| detail.value.video_billing.cost
|
||||||
|
|| detail.value.request_cost
|
||||||
|
|| 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 是否有 Token 费用(用于决定是否显示 Token 计费区块)
|
||||||
|
const hasTokenCost = computed(() => {
|
||||||
|
if (!detail.value) return false
|
||||||
|
const inputTokens = detail.value.tokens?.input || detail.value.input_tokens || 0
|
||||||
|
const outputTokens = detail.value.tokens?.output || detail.value.output_tokens || 0
|
||||||
|
const cacheCreation = detail.value.cache_creation_input_tokens || 0
|
||||||
|
const cacheRead = detail.value.cache_read_input_tokens || 0
|
||||||
|
return (inputTokens + outputTokens + cacheCreation + cacheRead) > 0 || tokenCostTotal.value > 0
|
||||||
|
})
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ name: 'request-headers', label: '请求头' },
|
{ name: 'request-headers', label: '请求头' },
|
||||||
{ name: 'request-body', label: '请求体' },
|
{ name: 'request-body', label: '请求体' },
|
||||||
@@ -817,6 +931,35 @@ function formatDateTime(dateStr: string | null | undefined): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 格式化视频/音频时长
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${seconds.toFixed(1)}s`
|
||||||
|
}
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
if (mins < 60) {
|
||||||
|
return `${mins}m ${secs.toFixed(0)}s`
|
||||||
|
}
|
||||||
|
const hours = Math.floor(mins / 60)
|
||||||
|
const remainMins = mins % 60
|
||||||
|
return `${hours}h ${remainMins}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取任务类型标签
|
||||||
|
function getTaskTypeLabel(taskType: string): string {
|
||||||
|
switch (taskType) {
|
||||||
|
case 'video':
|
||||||
|
return '视频生成'
|
||||||
|
case 'image':
|
||||||
|
return '图像生成'
|
||||||
|
case 'audio':
|
||||||
|
return '音频生成'
|
||||||
|
default:
|
||||||
|
return taskType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatApiFormat(format: string | null | undefined): string {
|
function formatApiFormat(format: string | null | undefined): string {
|
||||||
if (!format) return '-'
|
if (!format) return '-'
|
||||||
const raw = (format || '').trim()
|
const raw = (format || '').trim()
|
||||||
|
|||||||
@@ -865,12 +865,12 @@ export const MOCK_SYSTEM_CONFIGS = [
|
|||||||
|
|
||||||
export const MOCK_API_FORMATS = {
|
export const MOCK_API_FORMATS = {
|
||||||
formats: [
|
formats: [
|
||||||
{ value: 'claude:chat', label: 'Claude', default_path: '/v1/messages', aliases: [] },
|
{ value: 'claude:chat', label: 'Claude Chat', default_path: '/v1/messages', aliases: [] },
|
||||||
{ value: 'claude:cli', label: 'Claude CLI', default_path: '/v1/messages', aliases: [] },
|
{ value: 'claude:cli', label: 'Claude CLI', default_path: '/v1/messages', aliases: [] },
|
||||||
{ value: 'openai:chat', label: 'OpenAI', default_path: '/v1/chat/completions', aliases: [] },
|
{ value: 'openai:chat', label: 'OpenAI Chat', default_path: '/v1/chat/completions', aliases: [] },
|
||||||
{ value: 'openai:cli', label: 'OpenAI CLI', default_path: '/responses', aliases: [] },
|
{ value: 'openai:cli', label: 'OpenAI CLI', default_path: '/responses', aliases: [] },
|
||||||
{ value: 'openai:video', label: 'OpenAI Video', default_path: '/v1/videos', aliases: [] },
|
{ value: 'openai:video', label: 'OpenAI Video', default_path: '/v1/videos', aliases: [] },
|
||||||
{ value: 'gemini:chat', label: 'Gemini', default_path: '/v1beta/models/{model}:{action}', aliases: [] },
|
{ value: 'gemini:chat', label: 'Gemini Chat', default_path: '/v1beta/models/{model}:{action}', aliases: [] },
|
||||||
{ value: 'gemini:cli', label: 'Gemini CLI', default_path: '/v1beta/models/{model}:{action}', aliases: [] },
|
{ value: 'gemini:cli', label: 'Gemini CLI', default_path: '/v1beta/models/{model}:{action}', aliases: [] },
|
||||||
{ value: 'gemini:video', label: 'Gemini Video', default_path: '/v1beta/models/{model}:predictLongRunning', aliases: [] }
|
{ value: 'gemini:video', label: 'Gemini Video', default_path: '/v1beta/models/{model}:predictLongRunning', aliases: [] }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -130,3 +130,50 @@ export function createNumberInputHandler<T extends Record<string, any>>(
|
|||||||
(obj as any)[field] = parseNumberInput(value, options)
|
(obj as any)[field] = parseNumberInput(value, options)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分辨率的排序权重(用于从低到高排序)
|
||||||
|
* 支持的格式:
|
||||||
|
* - NNNp 格式:480p, 720p, 1080p, 2160p
|
||||||
|
* - 4k/8k 格式:4k -> 2160, 8k -> 4320
|
||||||
|
* - WxH 格式:720x1080 -> 按像素总数排序
|
||||||
|
*
|
||||||
|
* @param resolution - 分辨率字符串
|
||||||
|
* @returns 排序权重(数字越大分辨率越高)
|
||||||
|
*/
|
||||||
|
export function getResolutionSortWeight(resolution: string): number {
|
||||||
|
const normalized = (resolution || '').trim().toLowerCase()
|
||||||
|
|
||||||
|
// 4k/8k 格式
|
||||||
|
if (normalized === '4k') return 2160 * 2160
|
||||||
|
if (normalized === '8k') return 4320 * 4320
|
||||||
|
|
||||||
|
// NNNp 格式(如 480p, 720p, 1080p)
|
||||||
|
const pMatch = normalized.match(/^(\d+)p$/)
|
||||||
|
if (pMatch) {
|
||||||
|
const height = parseInt(pMatch[1], 10)
|
||||||
|
// 假设 16:9 宽高比计算像素数
|
||||||
|
return height * height * (16 / 9)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WxH 格式(如 720x1080, 1024x1792)
|
||||||
|
const wxhMatch = normalized.replace(/×/g, 'x').match(/^(\d+)x(\d+)$/)
|
||||||
|
if (wxhMatch) {
|
||||||
|
const w = parseInt(wxhMatch[1], 10)
|
||||||
|
const h = parseInt(wxhMatch[2], 10)
|
||||||
|
return w * h
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无法识别的格式,放到最后
|
||||||
|
return Infinity
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对分辨率价格条目进行排序(从低分辨率到高分辨率)
|
||||||
|
*
|
||||||
|
* @param entries - 分辨率价格条目数组 [[resolution, price], ...]
|
||||||
|
* @returns 排序后的数组
|
||||||
|
*/
|
||||||
|
export function sortResolutionEntries<T>(entries: [string, T][]): [string, T][] {
|
||||||
|
return [...entries].sort((a, b) => getResolutionSortWeight(a[0]) - getResolutionSortWeight(b[0]))
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -26,58 +26,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
|
||||||
|
|
||||||
<!-- 能力筛选 -->
|
|
||||||
<div class="flex items-center border rounded-md border-border/60 h-8 overflow-hidden">
|
|
||||||
<button
|
|
||||||
class="px-2.5 h-full text-xs transition-colors"
|
|
||||||
:class="capabilityFilters.streaming ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
|
||||||
title="流式输出"
|
|
||||||
@click="capabilityFilters.streaming = !capabilityFilters.streaming"
|
|
||||||
>
|
|
||||||
<Zap class="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<div class="w-px h-4 bg-border/60" />
|
|
||||||
<button
|
|
||||||
class="px-2.5 h-full text-xs transition-colors"
|
|
||||||
:class="capabilityFilters.imageGeneration ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
|
||||||
title="图像生成"
|
|
||||||
@click="capabilityFilters.imageGeneration = !capabilityFilters.imageGeneration"
|
|
||||||
>
|
|
||||||
<Image class="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<div class="w-px h-4 bg-border/60" />
|
|
||||||
<button
|
|
||||||
class="px-2.5 h-full text-xs transition-colors"
|
|
||||||
:class="capabilityFilters.vision ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
|
||||||
title="视觉理解"
|
|
||||||
@click="capabilityFilters.vision = !capabilityFilters.vision"
|
|
||||||
>
|
|
||||||
<Eye class="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<div class="w-px h-4 bg-border/60" />
|
|
||||||
<button
|
|
||||||
class="px-2.5 h-full text-xs transition-colors"
|
|
||||||
:class="capabilityFilters.toolUse ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
|
||||||
title="工具调用"
|
|
||||||
@click="capabilityFilters.toolUse = !capabilityFilters.toolUse"
|
|
||||||
>
|
|
||||||
<Wrench class="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<div class="w-px h-4 bg-border/60" />
|
|
||||||
<button
|
|
||||||
class="px-2.5 h-full text-xs transition-colors"
|
|
||||||
:class="capabilityFilters.extendedThinking ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
|
||||||
title="深度思考"
|
|
||||||
@click="capabilityFilters.extendedThinking = !capabilityFilters.extendedThinking"
|
|
||||||
>
|
|
||||||
<Brain class="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -103,7 +51,7 @@
|
|||||||
模型名称
|
模型名称
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="w-[140px]">
|
<TableHead class="w-[140px]">
|
||||||
能力/偏好
|
模型偏好
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="w-[160px] text-center">
|
<TableHead class="w-[160px] text-center">
|
||||||
价格 ($/M)
|
价格 ($/M)
|
||||||
@@ -165,45 +113,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="space-y-1 w-fit">
|
<div class="flex flex-wrap gap-0.5">
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<Zap
|
|
||||||
v-if="model.config?.streaming !== false"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
title="流式输出"
|
|
||||||
/>
|
|
||||||
<Image
|
|
||||||
v-if="model.config?.image_generation === true"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
title="图像生成"
|
|
||||||
/>
|
|
||||||
<Eye
|
|
||||||
v-if="model.config?.vision === true"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
title="视觉理解"
|
|
||||||
/>
|
|
||||||
<Wrench
|
|
||||||
v-if="model.config?.function_calling === true"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
title="工具调用"
|
|
||||||
/>
|
|
||||||
<Brain
|
|
||||||
v-if="model.config?.extended_thinking === true"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
title="深度思考"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<template v-if="model.supported_capabilities?.length">
|
<template v-if="model.supported_capabilities?.length">
|
||||||
<div class="border-t border-border/50" />
|
<span
|
||||||
<div class="flex flex-wrap gap-0.5">
|
v-for="capName in model.supported_capabilities"
|
||||||
<span
|
:key="capName"
|
||||||
v-for="capName in model.supported_capabilities"
|
class="text-[11px] px-1 py-0.5 rounded bg-muted/60 text-muted-foreground"
|
||||||
:key="capName"
|
:title="getCapabilityDisplayName(capName)"
|
||||||
class="text-[11px] px-1 py-0.5 rounded bg-muted/60 text-muted-foreground"
|
>{{ getCapabilityShortName(capName) }}</span>
|
||||||
:title="getCapabilityDisplayName(capName)"
|
|
||||||
>{{ getCapabilityShortName(capName) }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-muted-foreground text-xs"
|
||||||
|
>-</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-center">
|
<TableCell class="text-center">
|
||||||
@@ -227,9 +149,17 @@
|
|||||||
<span class="text-muted-foreground">按次:</span>
|
<span class="text-muted-foreground">按次:</span>
|
||||||
<span class="font-mono ml-1">${{ model.default_price_per_request.toFixed(3) }}/次</span>
|
<span class="font-mono ml-1">${{ model.default_price_per_request.toFixed(3) }}/次</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 视频费用计费 -->
|
||||||
|
<div v-if="hasVideoPricing(model)">
|
||||||
|
<span class="text-muted-foreground">视频:</span>
|
||||||
|
<span
|
||||||
|
class="font-mono ml-1"
|
||||||
|
:title="getVideoPricingTooltip(model)"
|
||||||
|
>{{ getVideoPricingDisplay(model) }}</span>
|
||||||
|
</div>
|
||||||
<!-- 无计费配置 -->
|
<!-- 无计费配置 -->
|
||||||
<div
|
<div
|
||||||
v-if="!getFirstTierPrice(model, 'input') && !getFirstTierPrice(model, 'output') && !model.default_price_per_request"
|
v-if="!getFirstTierPrice(model, 'input') && !getFirstTierPrice(model, 'output') && !model.default_price_per_request && !hasVideoPricing(model)"
|
||||||
class="text-muted-foreground"
|
class="text-muted-foreground"
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
@@ -358,28 +288,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 第二行:能力图标 -->
|
<!-- 第二行:模型偏好 -->
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<div
|
||||||
<Zap
|
v-if="model.supported_capabilities?.length"
|
||||||
v-if="model.config?.streaming !== false"
|
class="flex flex-wrap gap-0.5"
|
||||||
class="w-4 h-4 text-muted-foreground"
|
>
|
||||||
/>
|
<span
|
||||||
<Image
|
v-for="capName in model.supported_capabilities"
|
||||||
v-if="model.config?.image_generation === true"
|
:key="capName"
|
||||||
class="w-4 h-4 text-muted-foreground"
|
class="text-[11px] px-1 py-0.5 rounded bg-muted/60 text-muted-foreground"
|
||||||
/>
|
>{{ getCapabilityShortName(capName) }}</span>
|
||||||
<Eye
|
|
||||||
v-if="model.config?.vision === true"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<Wrench
|
|
||||||
v-if="model.config?.function_calling === true"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<Brain
|
|
||||||
v-if="model.config?.extended_thinking === true"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 第三行:统计信息 -->
|
<!-- 第三行:统计信息 -->
|
||||||
@@ -601,6 +519,7 @@ import { useConfirm } from '@/composables/useConfirm'
|
|||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { useRowClick } from '@/composables/useRowClick'
|
import { useRowClick } from '@/composables/useRowClick'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
|
import { sortResolutionEntries } from '@/utils/form'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -722,6 +641,43 @@ function hasTieredPricing(model: GlobalModelResponse): boolean {
|
|||||||
return (tiered?.tiers?.length || 0) > 1
|
return (tiered?.tiers?.length || 0) > 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检测是否有视频分辨率计费配置
|
||||||
|
function hasVideoPricing(model: GlobalModelResponse): boolean {
|
||||||
|
const priceByResolution = model.config?.billing?.video?.price_per_second_by_resolution
|
||||||
|
return priceByResolution && typeof priceByResolution === 'object' && Object.keys(priceByResolution).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取视频分辨率计费的数量
|
||||||
|
function getVideoPricingCount(model: GlobalModelResponse): number {
|
||||||
|
const priceByResolution = model.config?.billing?.video?.price_per_second_by_resolution
|
||||||
|
if (!priceByResolution || typeof priceByResolution !== 'object') return 0
|
||||||
|
return Object.keys(priceByResolution).length
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取视频计费的显示文本(如:720p $0.1/s [多分辨率])
|
||||||
|
function getVideoPricingDisplay(model: GlobalModelResponse): string {
|
||||||
|
const priceByResolution = model.config?.billing?.video?.price_per_second_by_resolution
|
||||||
|
if (!priceByResolution || typeof priceByResolution !== 'object') return ''
|
||||||
|
const entries = sortResolutionEntries(Object.entries(priceByResolution))
|
||||||
|
if (entries.length === 0) return ''
|
||||||
|
// 获取最低分辨率和价格
|
||||||
|
const [firstRes, firstPrice] = entries[0]
|
||||||
|
const priceStr = `${firstRes} $${(firstPrice as number).toFixed(2)}/s`
|
||||||
|
// 如果有多个分辨率,添加标记
|
||||||
|
if (entries.length > 1) {
|
||||||
|
return `${priceStr} [${entries.length}种]`
|
||||||
|
}
|
||||||
|
return priceStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取视频计费详情的 tooltip
|
||||||
|
function getVideoPricingTooltip(model: GlobalModelResponse): string {
|
||||||
|
const priceByResolution = model.config?.billing?.video?.price_per_second_by_resolution
|
||||||
|
if (!priceByResolution || typeof priceByResolution !== 'object') return ''
|
||||||
|
const entries = sortResolutionEntries(Object.entries(priceByResolution))
|
||||||
|
return entries.map(([res, price]) => `${res}: $${(price as number).toFixed(4)}/s`).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
// 检测是否有对话框打开(防止误关闭抽屉)
|
// 检测是否有对话框打开(防止误关闭抽屉)
|
||||||
const hasBlockingDialogOpen = computed(() =>
|
const hasBlockingDialogOpen = computed(() =>
|
||||||
createModelDialogOpen.value ||
|
createModelDialogOpen.value ||
|
||||||
|
|||||||
@@ -97,23 +97,23 @@
|
|||||||
>
|
>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow class="border-b border-border/40 hover:bg-transparent">
|
<TableRow>
|
||||||
<TableHead class="w-[18%] min-w-[140px] h-11 font-medium text-foreground/80">
|
<TableHead class="w-[18%] min-w-[140px]">
|
||||||
提供商信息
|
提供商信息
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="w-[20%] min-w-[180px] h-11 font-medium text-foreground/80">
|
<TableHead class="w-[20%] min-w-[180px]">
|
||||||
余额监控
|
余额监控
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="w-[12%] min-w-[100px] h-11 font-medium text-foreground/80 text-center">
|
<TableHead class="w-[12%] min-w-[100px] text-center">
|
||||||
资源统计
|
资源统计
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="w-[24%] min-w-[260px] h-11 font-medium text-foreground/80">
|
<TableHead class="w-[24%] min-w-[260px]">
|
||||||
端点健康
|
端点健康
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="w-[8%] min-w-[60px] h-11 font-medium text-foreground/80 text-center">
|
<TableHead class="w-[8%] min-w-[60px] text-center">
|
||||||
状态
|
状态
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="w-[18%] min-w-[120px] h-11 font-medium text-foreground/80 text-center">
|
<TableHead class="w-[18%] min-w-[120px] text-center">
|
||||||
操作
|
操作
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -210,10 +210,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</CardSection>
|
</CardSection>
|
||||||
|
|
||||||
<!-- 日志记录配置 -->
|
<!-- 请求记录配置 -->
|
||||||
<CardSection
|
<CardSection
|
||||||
title="日志记录"
|
title="请求记录"
|
||||||
description="控制请求日志的记录方式和内容"
|
description="控制请求/响应详情的入库方式和内容"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<Button
|
<Button
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
记录详细程度
|
记录详细程度
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
v-model="systemConfig.request_log_level"
|
v-model="systemConfig.request_record_level"
|
||||||
v-model:open="logLevelSelectOpen"
|
v-model:open="logLevelSelectOpen"
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
@@ -317,10 +317,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</CardSection>
|
</CardSection>
|
||||||
|
|
||||||
<!-- 日志清理策略 -->
|
<!-- 请求记录清理策略 -->
|
||||||
<CardSection
|
<CardSection
|
||||||
title="日志清理策略"
|
title="请求记录清理策略"
|
||||||
description="配置日志的分级保留和自动清理"
|
description="配置请求记录的分级保留和自动清理"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
@@ -357,7 +357,7 @@
|
|||||||
for="detail-log-retention-days"
|
for="detail-log-retention-days"
|
||||||
class="block text-sm font-medium"
|
class="block text-sm font-medium"
|
||||||
>
|
>
|
||||||
详细日志保留天数
|
详细记录保留天数
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="detail-log-retention-days"
|
id="detail-log-retention-days"
|
||||||
@@ -376,7 +376,7 @@
|
|||||||
for="compressed-log-retention-days"
|
for="compressed-log-retention-days"
|
||||||
class="block text-sm font-medium"
|
class="block text-sm font-medium"
|
||||||
>
|
>
|
||||||
压缩日志保留天数
|
压缩记录保留天数
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="compressed-log-retention-days"
|
id="compressed-log-retention-days"
|
||||||
@@ -414,7 +414,7 @@
|
|||||||
for="log-retention-days"
|
for="log-retention-days"
|
||||||
class="block text-sm font-medium"
|
class="block text-sm font-medium"
|
||||||
>
|
>
|
||||||
完整日志保留天数
|
完整记录保留天数
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="log-retention-days"
|
id="log-retention-days"
|
||||||
@@ -909,12 +909,12 @@ interface SystemConfig {
|
|||||||
auto_delete_expired_keys: boolean
|
auto_delete_expired_keys: boolean
|
||||||
// 格式转换
|
// 格式转换
|
||||||
enable_format_conversion: boolean
|
enable_format_conversion: boolean
|
||||||
// 日志记录
|
// 请求记录
|
||||||
request_log_level: string
|
request_record_level: string
|
||||||
max_request_body_size: number
|
max_request_body_size: number
|
||||||
max_response_body_size: number
|
max_response_body_size: number
|
||||||
sensitive_headers: string[]
|
sensitive_headers: string[]
|
||||||
// 日志清理
|
// 请求记录清理
|
||||||
enable_auto_cleanup: boolean
|
enable_auto_cleanup: boolean
|
||||||
detail_log_retention_days: number
|
detail_log_retention_days: number
|
||||||
compressed_log_retention_days: number
|
compressed_log_retention_days: number
|
||||||
@@ -965,12 +965,12 @@ const systemConfig = ref<SystemConfig>({
|
|||||||
auto_delete_expired_keys: false,
|
auto_delete_expired_keys: false,
|
||||||
// 格式转换
|
// 格式转换
|
||||||
enable_format_conversion: false,
|
enable_format_conversion: false,
|
||||||
// 日志记录
|
// 请求记录
|
||||||
request_log_level: 'basic',
|
request_record_level: 'basic',
|
||||||
max_request_body_size: 1048576,
|
max_request_body_size: 1048576,
|
||||||
max_response_body_size: 1048576,
|
max_response_body_size: 1048576,
|
||||||
sensitive_headers: ['authorization', 'x-api-key', 'api-key', 'cookie', 'set-cookie'],
|
sensitive_headers: ['authorization', 'x-api-key', 'api-key', 'cookie', 'set-cookie'],
|
||||||
// 日志清理
|
// 请求记录清理
|
||||||
enable_auto_cleanup: true,
|
enable_auto_cleanup: true,
|
||||||
detail_log_retention_days: 7,
|
detail_log_retention_days: 7,
|
||||||
compressed_log_retention_days: 90,
|
compressed_log_retention_days: 90,
|
||||||
@@ -1000,7 +1000,7 @@ const hasBasicConfigChanges = computed(() => {
|
|||||||
const hasLogConfigChanges = computed(() => {
|
const hasLogConfigChanges = computed(() => {
|
||||||
if (!originalConfig.value) return false
|
if (!originalConfig.value) return false
|
||||||
return (
|
return (
|
||||||
systemConfig.value.request_log_level !== originalConfig.value.request_log_level ||
|
systemConfig.value.request_record_level !== originalConfig.value.request_record_level ||
|
||||||
systemConfig.value.max_request_body_size !== originalConfig.value.max_request_body_size ||
|
systemConfig.value.max_request_body_size !== originalConfig.value.max_request_body_size ||
|
||||||
systemConfig.value.max_response_body_size !== originalConfig.value.max_response_body_size ||
|
systemConfig.value.max_response_body_size !== originalConfig.value.max_response_body_size ||
|
||||||
JSON.stringify(systemConfig.value.sensitive_headers) !== JSON.stringify(originalConfig.value.sensitive_headers)
|
JSON.stringify(systemConfig.value.sensitive_headers) !== JSON.stringify(originalConfig.value.sensitive_headers)
|
||||||
@@ -1072,12 +1072,12 @@ async function loadSystemConfig() {
|
|||||||
'auto_delete_expired_keys',
|
'auto_delete_expired_keys',
|
||||||
// 格式转换
|
// 格式转换
|
||||||
'enable_format_conversion',
|
'enable_format_conversion',
|
||||||
// 日志记录
|
// 请求记录
|
||||||
'request_log_level',
|
'request_record_level',
|
||||||
'max_request_body_size',
|
'max_request_body_size',
|
||||||
'max_response_body_size',
|
'max_response_body_size',
|
||||||
'sensitive_headers',
|
'sensitive_headers',
|
||||||
// 日志清理
|
// 请求记录清理
|
||||||
'enable_auto_cleanup',
|
'enable_auto_cleanup',
|
||||||
'detail_log_retention_days',
|
'detail_log_retention_days',
|
||||||
'compressed_log_retention_days',
|
'compressed_log_retention_days',
|
||||||
@@ -1165,8 +1165,8 @@ async function saveLogConfig() {
|
|||||||
try {
|
try {
|
||||||
const configItems = [
|
const configItems = [
|
||||||
{
|
{
|
||||||
key: 'request_log_level',
|
key: 'request_record_level',
|
||||||
value: systemConfig.value.request_log_level,
|
value: systemConfig.value.request_record_level,
|
||||||
description: '请求记录级别'
|
description: '请求记录级别'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1193,15 +1193,15 @@ async function saveLogConfig() {
|
|||||||
)
|
)
|
||||||
// 更新原始值
|
// 更新原始值
|
||||||
if (originalConfig.value) {
|
if (originalConfig.value) {
|
||||||
originalConfig.value.request_log_level = systemConfig.value.request_log_level
|
originalConfig.value.request_record_level = systemConfig.value.request_record_level
|
||||||
originalConfig.value.max_request_body_size = systemConfig.value.max_request_body_size
|
originalConfig.value.max_request_body_size = systemConfig.value.max_request_body_size
|
||||||
originalConfig.value.max_response_body_size = systemConfig.value.max_response_body_size
|
originalConfig.value.max_response_body_size = systemConfig.value.max_response_body_size
|
||||||
originalConfig.value.sensitive_headers = [...systemConfig.value.sensitive_headers]
|
originalConfig.value.sensitive_headers = [...systemConfig.value.sensitive_headers]
|
||||||
}
|
}
|
||||||
success('日志配置已保存')
|
success('请求记录配置已保存')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error('保存配置失败')
|
error('保存配置失败')
|
||||||
log.error('保存日志配置失败:', err)
|
log.error('保存请求记录配置失败:', err)
|
||||||
} finally {
|
} finally {
|
||||||
logConfigLoading.value = false
|
logConfigLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -1250,12 +1250,12 @@ async function saveCleanupConfig() {
|
|||||||
{
|
{
|
||||||
key: 'detail_log_retention_days',
|
key: 'detail_log_retention_days',
|
||||||
value: systemConfig.value.detail_log_retention_days,
|
value: systemConfig.value.detail_log_retention_days,
|
||||||
description: '详细日志保留天数'
|
description: '详细记录保留天数'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'compressed_log_retention_days',
|
key: 'compressed_log_retention_days',
|
||||||
value: systemConfig.value.compressed_log_retention_days,
|
value: systemConfig.value.compressed_log_retention_days,
|
||||||
description: '压缩日志保留天数'
|
description: '压缩记录保留天数'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'header_retention_days',
|
key: 'header_retention_days',
|
||||||
@@ -1265,7 +1265,7 @@ async function saveCleanupConfig() {
|
|||||||
{
|
{
|
||||||
key: 'log_retention_days',
|
key: 'log_retention_days',
|
||||||
value: systemConfig.value.log_retention_days,
|
value: systemConfig.value.log_retention_days,
|
||||||
description: '完整日志保留天数'
|
description: '完整记录保留天数'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'cleanup_batch_size',
|
key: 'cleanup_batch_size',
|
||||||
@@ -1293,10 +1293,10 @@ async function saveCleanupConfig() {
|
|||||||
originalConfig.value.cleanup_batch_size = systemConfig.value.cleanup_batch_size
|
originalConfig.value.cleanup_batch_size = systemConfig.value.cleanup_batch_size
|
||||||
originalConfig.value.audit_log_retention_days = systemConfig.value.audit_log_retention_days
|
originalConfig.value.audit_log_retention_days = systemConfig.value.audit_log_retention_days
|
||||||
}
|
}
|
||||||
success('日志清理配置已保存')
|
success('请求记录清理配置已保存')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error('保存配置失败')
|
error('保存配置失败')
|
||||||
log.error('保存日志清理配置失败:', err)
|
log.error('保存请求记录清理配置失败:', err)
|
||||||
} finally {
|
} finally {
|
||||||
cleanupConfigLoading.value = false
|
cleanupConfigLoading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,38 +29,38 @@ const providerExamples = [
|
|||||||
{
|
{
|
||||||
name: 'OpenAI',
|
name: 'OpenAI',
|
||||||
url: 'https://api.openai.com',
|
url: 'https://api.openai.com',
|
||||||
format: 'OpenAI',
|
format: 'OpenAI Chat',
|
||||||
note: '官方 API,需要国际信用卡或通过代理访问'
|
note: '官方 API,需要国际信用卡或通过代理访问'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Anthropic',
|
name: 'Anthropic',
|
||||||
url: 'https://api.anthropic.com',
|
url: 'https://api.anthropic.com',
|
||||||
format: 'Claude',
|
format: 'Claude Chat',
|
||||||
note: '官方 Claude API'
|
note: '官方 Claude API'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Google AI',
|
name: 'Google AI',
|
||||||
url: 'https://generativelanguage.googleapis.com',
|
url: 'https://generativelanguage.googleapis.com',
|
||||||
format: 'Gemini',
|
format: 'Gemini Chat',
|
||||||
note: '官方 Gemini API'
|
note: '官方 Gemini API'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Azure OpenAI',
|
name: 'Azure OpenAI',
|
||||||
url: 'https://{resource}.openai.azure.com',
|
url: 'https://{resource}.openai.azure.com',
|
||||||
format: 'OpenAI',
|
format: 'OpenAI Chat',
|
||||||
note: '需要替换 {resource} 为你的资源名'
|
note: '需要替换 {resource} 为你的资源名'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'OpenRouter',
|
name: 'OpenRouter',
|
||||||
url: 'https://openrouter.ai/api',
|
url: 'https://openrouter.ai/api',
|
||||||
format: 'OpenAI',
|
format: 'OpenAI Chat',
|
||||||
note: '聚合多家供应商的 API 代理'
|
note: '聚合多家供应商的 API 代理'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '自托管 / 其他',
|
name: '自托管 / 其他',
|
||||||
url: 'https://your-api.com',
|
url: 'https://your-api.com',
|
||||||
format: 'OpenAI',
|
format: 'OpenAI Chat',
|
||||||
note: '大多数 OpenAI 兼容服务选择 OpenAI 格式'
|
note: '大多数 OpenAI 兼容服务选择 OpenAI Chat 格式'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export const coreConcepts = [
|
|||||||
// API 格式说明
|
// API 格式说明
|
||||||
export const apiFormats = [
|
export const apiFormats = [
|
||||||
{
|
{
|
||||||
name: 'OpenAI',
|
name: 'OpenAI Chat',
|
||||||
endpoint: '/v1/chat/completions',
|
endpoint: '/v1/chat/completions',
|
||||||
auth: 'Authorization: Bearer xxx',
|
auth: 'Authorization: Bearer xxx',
|
||||||
clients: ['OpenAI SDK', 'Cursor', 'LangChain', '大部分开源工具']
|
clients: ['OpenAI SDK', 'Cursor', 'LangChain', '大部分开源工具']
|
||||||
@@ -123,7 +123,7 @@ export const apiFormats = [
|
|||||||
clients: ['Codex CLI']
|
clients: ['Codex CLI']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Claude',
|
name: 'Claude Chat',
|
||||||
endpoint: '/v1/messages',
|
endpoint: '/v1/messages',
|
||||||
auth: 'x-api-key: xxx',
|
auth: 'x-api-key: xxx',
|
||||||
clients: ['Anthropic SDK']
|
clients: ['Anthropic SDK']
|
||||||
@@ -135,7 +135,7 @@ export const apiFormats = [
|
|||||||
clients: ['Claude Code']
|
clients: ['Claude Code']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Gemini',
|
name: 'Gemini Chat',
|
||||||
endpoint: '/v1beta/models/{model}:generateContent',
|
endpoint: '/v1beta/models/{model}:generateContent',
|
||||||
auth: 'x-goog-api-key: xxx',
|
auth: 'x-goog-api-key: xxx',
|
||||||
clients: ['Gemini SDK', 'Gemini CLI']
|
clients: ['Gemini SDK', 'Gemini CLI']
|
||||||
|
|||||||
@@ -24,40 +24,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
|
||||||
|
|
||||||
<!-- 能力筛选 -->
|
|
||||||
<div class="flex items-center border rounded-md border-border/60 h-8 overflow-hidden">
|
|
||||||
<button
|
|
||||||
class="px-2.5 h-full text-xs transition-colors"
|
|
||||||
:class="capabilityFilters.vision ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
|
||||||
title="Vision"
|
|
||||||
@click="capabilityFilters.vision = !capabilityFilters.vision"
|
|
||||||
>
|
|
||||||
<Eye class="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<div class="w-px h-4 bg-border/60" />
|
|
||||||
<button
|
|
||||||
class="px-2.5 h-full text-xs transition-colors"
|
|
||||||
:class="capabilityFilters.toolUse ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
|
||||||
title="Tool Use"
|
|
||||||
@click="capabilityFilters.toolUse = !capabilityFilters.toolUse"
|
|
||||||
>
|
|
||||||
<Wrench class="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<div class="w-px h-4 bg-border/60" />
|
|
||||||
<button
|
|
||||||
class="px-2.5 h-full text-xs transition-colors"
|
|
||||||
:class="capabilityFilters.extendedThinking ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
|
||||||
title="Extended Thinking"
|
|
||||||
@click="capabilityFilters.extendedThinking = !capabilityFilters.extendedThinking"
|
|
||||||
>
|
|
||||||
<Brain class="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
|
||||||
|
|
||||||
<!-- 刷新按钮 -->
|
<!-- 刷新按钮 -->
|
||||||
<RefreshButton
|
<RefreshButton
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@@ -77,9 +43,6 @@
|
|||||||
<TableHead class="w-[120px] h-12 font-semibold">
|
<TableHead class="w-[120px] h-12 font-semibold">
|
||||||
模型偏好
|
模型偏好
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="w-[100px] h-12 font-semibold">
|
|
||||||
能力
|
|
||||||
</TableHead>
|
|
||||||
<TableHead class="w-[140px] h-12 font-semibold text-center">
|
<TableHead class="w-[140px] h-12 font-semibold text-center">
|
||||||
价格 ($/M)
|
价格 ($/M)
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -91,7 +54,7 @@
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-if="loading">
|
<TableRow v-if="loading">
|
||||||
<TableCell
|
<TableCell
|
||||||
colspan="5"
|
colspan="4"
|
||||||
class="text-center py-12"
|
class="text-center py-12"
|
||||||
>
|
>
|
||||||
<Loader2 class="w-6 h-6 animate-spin mx-auto" />
|
<Loader2 class="w-6 h-6 animate-spin mx-auto" />
|
||||||
@@ -99,7 +62,7 @@
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow v-else-if="filteredModels.length === 0">
|
<TableRow v-else-if="filteredModels.length === 0">
|
||||||
<TableCell
|
<TableCell
|
||||||
colspan="5"
|
colspan="4"
|
||||||
class="text-center py-12 text-muted-foreground"
|
class="text-center py-12 text-muted-foreground"
|
||||||
>
|
>
|
||||||
没有找到匹配的模型
|
没有找到匹配的模型
|
||||||
@@ -162,25 +125,6 @@
|
|||||||
>-</span>
|
>-</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="py-4">
|
|
||||||
<div class="flex gap-1.5">
|
|
||||||
<Eye
|
|
||||||
v-if="model.config?.vision === true"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
title="Vision"
|
|
||||||
/>
|
|
||||||
<Wrench
|
|
||||||
v-if="model.config?.function_calling === true"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
title="Tool Use"
|
|
||||||
/>
|
|
||||||
<Brain
|
|
||||||
v-if="model.config?.extended_thinking === true"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
title="Extended Thinking"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="py-4 text-center">
|
<TableCell class="py-4 text-center">
|
||||||
<div class="text-xs space-y-0.5">
|
<div class="text-xs space-y-0.5">
|
||||||
<!-- 按 Token 计费 -->
|
<!-- 按 Token 计费 -->
|
||||||
@@ -250,23 +194,7 @@
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 第二行:能力图标 -->
|
<!-- 第二行:价格 -->
|
||||||
<div class="flex gap-1.5">
|
|
||||||
<Eye
|
|
||||||
v-if="model.config?.vision === true"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<Wrench
|
|
||||||
v-if="model.config?.function_calling === true"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<Brain
|
|
||||||
v-if="model.config?.extended_thinking === true"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 第三行:价格 -->
|
|
||||||
<div
|
<div
|
||||||
v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')"
|
v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')"
|
||||||
class="text-xs text-muted-foreground font-mono"
|
class="text-xs text-muted-foreground font-mono"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from src.core.exceptions import InvalidRequestException, NotFoundException
|
|||||||
from src.database import get_db
|
from src.database import get_db
|
||||||
from src.models.database import BillingRule, DimensionCollector
|
from src.models.database import BillingRule, DimensionCollector
|
||||||
from src.services.billing.formula_engine import SafeExpressionEvaluator, UnsafeExpressionError
|
from src.services.billing.formula_engine import SafeExpressionEvaluator, UnsafeExpressionError
|
||||||
|
from src.services.billing.presets import BillingPresetService, PresetApplyMode, list_preset_packs
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/billing", tags=["Admin - Billing"])
|
router = APIRouter(prefix="/api/admin/billing", tags=["Admin - Billing"])
|
||||||
pipeline = ApiRequestPipeline()
|
pipeline = ApiRequestPipeline()
|
||||||
@@ -127,6 +128,30 @@ class DimensionCollectorResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BillingPresetInfoResponse(BaseModel):
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
description: str
|
||||||
|
collector_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyBillingPresetRequest(BaseModel):
|
||||||
|
preset: str = Field(..., min_length=1, max_length=100)
|
||||||
|
mode: PresetApplyMode = "merge"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/presets")
|
||||||
|
async def list_billing_presets(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
adapter = BillingPresetListAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/presets/apply")
|
||||||
|
async def apply_billing_preset(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
adapter = BillingPresetApplyAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/rules")
|
@router.get("/rules")
|
||||||
async def list_billing_rules(
|
async def list_billing_rules(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -525,3 +550,38 @@ def _validate_dimension_collector_request(
|
|||||||
raise InvalidRequestException(
|
raise InvalidRequestException(
|
||||||
"default_value already exists for this (api_format, task_type, dimension_name)"
|
"default_value already exists for this (api_format, task_type, dimension_name)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BillingPresetListAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||||
|
items = []
|
||||||
|
for p in list_preset_packs():
|
||||||
|
items.append(
|
||||||
|
BillingPresetInfoResponse(
|
||||||
|
name=p.name,
|
||||||
|
version=p.version,
|
||||||
|
description=p.description,
|
||||||
|
collector_count=len(p.collectors or []),
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
|
class BillingPresetApplyAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
try:
|
||||||
|
req = ApplyBillingPresetRequest.model_validate(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
raise InvalidRequestException(f"Invalid request body: {exc}")
|
||||||
|
|
||||||
|
result = BillingPresetService.apply_preset(
|
||||||
|
context.db,
|
||||||
|
preset_name=req.preset,
|
||||||
|
mode=req.mode,
|
||||||
|
)
|
||||||
|
if result.errors:
|
||||||
|
# still return counts; caller can display partial results
|
||||||
|
return {"ok": False, **result.to_dict()}
|
||||||
|
return {"ok": True, **result.to_dict()}
|
||||||
|
|||||||
@@ -726,9 +726,9 @@ class AdminGetApiFormatsAdapter(AdminApiAdapter):
|
|||||||
def _label_for(sig: str) -> str:
|
def _label_for(sig: str) -> str:
|
||||||
fam, kind = (sig.split(":", 1) + [""])[:2]
|
fam, kind = (sig.split(":", 1) + [""])[:2]
|
||||||
fam_title = {"claude": "Claude", "openai": "OpenAI", "gemini": "Gemini"}.get(fam, fam)
|
fam_title = {"claude": "Claude", "openai": "OpenAI", "gemini": "Gemini"}.get(fam, fam)
|
||||||
if kind == "chat":
|
kind_title = {"chat": "Chat", "cli": "CLI", "video": "Video", "image": "Image"}.get(
|
||||||
return fam_title
|
kind, kind
|
||||||
kind_title = {"cli": "CLI", "video": "Video", "image": "Image"}.get(kind, kind)
|
)
|
||||||
return f"{fam_title} {kind_title}".strip()
|
return f"{fam_title} {kind_title}".strip()
|
||||||
|
|
||||||
endpoint_defs = list_endpoint_definitions()
|
endpoint_defs = list_endpoint_definitions()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
@@ -14,6 +14,8 @@ from sqlalchemy.orm import Session
|
|||||||
from src.api.base.admin_adapter import AdminApiAdapter
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
from src.api.base.context import ApiRequestContext
|
from src.api.base.context import ApiRequestContext
|
||||||
from src.api.base.pipeline import ApiRequestPipeline
|
from src.api.base.pipeline import ApiRequestPipeline
|
||||||
|
from src.config.constants import CacheTTL
|
||||||
|
from src.config.settings import config
|
||||||
from src.database import get_db
|
from src.database import get_db
|
||||||
from src.models.database import (
|
from src.models.database import (
|
||||||
ApiKey,
|
ApiKey,
|
||||||
@@ -25,11 +27,31 @@ from src.models.database import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from src.services.usage.service import UsageService
|
from src.services.usage.service import UsageService
|
||||||
|
from src.utils.cache_decorator import cache_result
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/usage", tags=["Admin - Usage"])
|
router = APIRouter(prefix="/api/admin/usage", tags=["Admin - Usage"])
|
||||||
pipeline = ApiRequestPipeline()
|
pipeline = ApiRequestPipeline()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_admin_default_range(
|
||||||
|
start_date: datetime | None, end_date: datetime | None
|
||||||
|
) -> tuple[datetime | None, datetime | None]:
|
||||||
|
"""
|
||||||
|
Apply a default time range for admin usage endpoints to protect DB from unbounded scans.
|
||||||
|
|
||||||
|
Enabled by setting ADMIN_USAGE_DEFAULT_DAYS>0.
|
||||||
|
"""
|
||||||
|
if start_date is not None or end_date is not None:
|
||||||
|
return start_date, end_date
|
||||||
|
|
||||||
|
days = int(getattr(config, "admin_usage_default_days", 0) or 0)
|
||||||
|
if days <= 0:
|
||||||
|
return start_date, end_date
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return now - timedelta(days=days), now
|
||||||
|
|
||||||
|
|
||||||
# ==================== RESTful Routes ====================
|
# ==================== RESTful Routes ====================
|
||||||
|
|
||||||
|
|
||||||
@@ -267,9 +289,14 @@ async def get_usage_detail(
|
|||||||
|
|
||||||
class AdminUsageStatsAdapter(AdminApiAdapter):
|
class AdminUsageStatsAdapter(AdminApiAdapter):
|
||||||
def __init__(self, start_date: datetime | None, end_date: datetime | None):
|
def __init__(self, start_date: datetime | None, end_date: datetime | None):
|
||||||
self.start_date = start_date
|
self.start_date, self.end_date = _apply_admin_default_range(start_date, end_date)
|
||||||
self.end_date = end_date
|
|
||||||
|
|
||||||
|
@cache_result(
|
||||||
|
key_prefix="admin:usage:stats",
|
||||||
|
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
||||||
|
user_specific=False,
|
||||||
|
vary_by=["start_date", "end_date"],
|
||||||
|
)
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
# Perf: use a single aggregate query (avoid 3 full scans).
|
# Perf: use a single aggregate query (avoid 3 full scans).
|
||||||
from sqlalchemy import case
|
from sqlalchemy import case
|
||||||
@@ -347,10 +374,15 @@ class AdminActivityHeatmapAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
class AdminUsageByModelAdapter(AdminApiAdapter):
|
class AdminUsageByModelAdapter(AdminApiAdapter):
|
||||||
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
|
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
|
||||||
self.start_date = start_date
|
self.start_date, self.end_date = _apply_admin_default_range(start_date, end_date)
|
||||||
self.end_date = end_date
|
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
|
|
||||||
|
@cache_result(
|
||||||
|
key_prefix="admin:usage:agg:model",
|
||||||
|
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
||||||
|
user_specific=False,
|
||||||
|
vary_by=["start_date", "end_date", "limit"],
|
||||||
|
)
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
query = db.query(
|
query = db.query(
|
||||||
@@ -394,10 +426,15 @@ class AdminUsageByModelAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
class AdminUsageByUserAdapter(AdminApiAdapter):
|
class AdminUsageByUserAdapter(AdminApiAdapter):
|
||||||
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
|
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
|
||||||
self.start_date = start_date
|
self.start_date, self.end_date = _apply_admin_default_range(start_date, end_date)
|
||||||
self.end_date = end_date
|
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
|
|
||||||
|
@cache_result(
|
||||||
|
key_prefix="admin:usage:agg:user",
|
||||||
|
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
||||||
|
user_specific=False,
|
||||||
|
vary_by=["start_date", "end_date", "limit"],
|
||||||
|
)
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
query = (
|
query = (
|
||||||
@@ -444,10 +481,15 @@ class AdminUsageByUserAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
class AdminUsageByProviderAdapter(AdminApiAdapter):
|
class AdminUsageByProviderAdapter(AdminApiAdapter):
|
||||||
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
|
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
|
||||||
self.start_date = start_date
|
self.start_date, self.end_date = _apply_admin_default_range(start_date, end_date)
|
||||||
self.end_date = end_date
|
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
|
|
||||||
|
@cache_result(
|
||||||
|
key_prefix="admin:usage:agg:provider",
|
||||||
|
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
||||||
|
user_specific=False,
|
||||||
|
vary_by=["start_date", "end_date", "limit"],
|
||||||
|
)
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
|
|
||||||
@@ -558,10 +600,15 @@ class AdminUsageByProviderAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
class AdminUsageByApiFormatAdapter(AdminApiAdapter):
|
class AdminUsageByApiFormatAdapter(AdminApiAdapter):
|
||||||
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
|
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
|
||||||
self.start_date = start_date
|
self.start_date, self.end_date = _apply_admin_default_range(start_date, end_date)
|
||||||
self.end_date = end_date
|
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
|
|
||||||
|
@cache_result(
|
||||||
|
key_prefix="admin:usage:agg:api_format",
|
||||||
|
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
||||||
|
user_specific=False,
|
||||||
|
vary_by=["start_date", "end_date", "limit"],
|
||||||
|
)
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
query = db.query(
|
query = db.query(
|
||||||
@@ -624,8 +671,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
limit: int,
|
limit: int,
|
||||||
offset: int,
|
offset: int,
|
||||||
):
|
):
|
||||||
self.start_date = start_date
|
self.start_date, self.end_date = _apply_admin_default_range(start_date, end_date)
|
||||||
self.end_date = end_date
|
|
||||||
self.search = search
|
self.search = search
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
self.username = username
|
self.username = username
|
||||||
@@ -635,6 +681,23 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.offset = offset
|
self.offset = offset
|
||||||
|
|
||||||
|
@cache_result(
|
||||||
|
key_prefix="admin:usage:records",
|
||||||
|
ttl=CacheTTL.ADMIN_USAGE_RECORDS,
|
||||||
|
user_specific=False,
|
||||||
|
vary_by=[
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"search",
|
||||||
|
"user_id",
|
||||||
|
"username",
|
||||||
|
"model",
|
||||||
|
"provider",
|
||||||
|
"status",
|
||||||
|
"limit",
|
||||||
|
"offset",
|
||||||
|
],
|
||||||
|
)
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import load_only
|
from sqlalchemy.orm import load_only
|
||||||
@@ -955,7 +1018,11 @@ class AdminUsageDetailAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
|
# 先通过主键 id 查找,如果找不到再尝试通过 request_id 查找
|
||||||
usage_record = db.query(Usage).filter(Usage.id == self.usage_id).first()
|
usage_record = db.query(Usage).filter(Usage.id == self.usage_id).first()
|
||||||
|
if not usage_record:
|
||||||
|
# 兼容通过 request_id 查找(用于异步任务等场景)
|
||||||
|
usage_record = db.query(Usage).filter(Usage.request_id == self.usage_id).first()
|
||||||
if not usage_record:
|
if not usage_record:
|
||||||
raise HTTPException(status_code=404, detail="Usage record not found")
|
raise HTTPException(status_code=404, detail="Usage record not found")
|
||||||
|
|
||||||
@@ -970,6 +1037,9 @@ class AdminUsageDetailAdapter(AdminApiAdapter):
|
|||||||
usage_id=self.usage_id,
|
usage_id=self.usage_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 提取视频/图像/音频计费信息
|
||||||
|
video_billing_info = self._extract_video_billing_info(usage_record)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": usage_record.id,
|
"id": usage_record.id,
|
||||||
"request_id": usage_record.request_id,
|
"request_id": usage_record.request_id,
|
||||||
@@ -1022,6 +1092,7 @@ class AdminUsageDetailAdapter(AdminApiAdapter):
|
|||||||
"response_body": usage_record.get_response_body(),
|
"response_body": usage_record.get_response_body(),
|
||||||
"metadata": usage_record.request_metadata,
|
"metadata": usage_record.request_metadata,
|
||||||
"tiered_pricing": tiered_pricing_info,
|
"tiered_pricing": tiered_pricing_info,
|
||||||
|
"video_billing": video_billing_info,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _get_tiered_pricing_info(self, db: Session, usage_record: Any) -> dict | None:
|
async def _get_tiered_pricing_info(self, db: Session, usage_record: Any) -> dict | None:
|
||||||
@@ -1077,6 +1148,75 @@ class AdminUsageDetailAdapter(AdminApiAdapter):
|
|||||||
"source": pricing_source, # 定价来源: 'provider' 或 'global'
|
"source": pricing_source, # 定价来源: 'provider' 或 'global'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _extract_video_billing_info(self, usage_record: Any) -> dict | None:
|
||||||
|
"""
|
||||||
|
从 request_metadata.billing_snapshot 和 dimensions 中提取视频/图像/音频计费信息。
|
||||||
|
|
||||||
|
返回结构:
|
||||||
|
{
|
||||||
|
"task_type": "video" | "image" | "audio",
|
||||||
|
"duration_seconds": 10.5, # 视频时长(秒)
|
||||||
|
"resolution": "1080p", # 分辨率
|
||||||
|
"video_price_per_second": 0.1, # 每秒单价
|
||||||
|
"video_cost": 1.05, # 视频费用
|
||||||
|
"rule_name": "...", # 计费规则名称
|
||||||
|
"expression": "...", # 计费公式
|
||||||
|
"status": "complete", # 计费状态
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
request_type = getattr(usage_record, "request_type", None)
|
||||||
|
if request_type not in {"video", "image", "audio"}:
|
||||||
|
return None
|
||||||
|
|
||||||
|
metadata = getattr(usage_record, "request_metadata", None)
|
||||||
|
if not metadata:
|
||||||
|
return None
|
||||||
|
|
||||||
|
billing_snapshot = metadata.get("billing_snapshot") if isinstance(metadata, dict) else None
|
||||||
|
dimensions = metadata.get("dimensions") if isinstance(metadata, dict) else None
|
||||||
|
|
||||||
|
result: dict = {
|
||||||
|
"task_type": request_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 从 billing_snapshot 中提取计费规则信息
|
||||||
|
if billing_snapshot and isinstance(billing_snapshot, dict):
|
||||||
|
result["rule_name"] = billing_snapshot.get("rule_name")
|
||||||
|
result["expression"] = billing_snapshot.get("expression")
|
||||||
|
result["status"] = billing_snapshot.get("status")
|
||||||
|
result["cost"] = billing_snapshot.get("cost")
|
||||||
|
|
||||||
|
# 从 dimensions_used 中提取维度
|
||||||
|
dims_used = billing_snapshot.get("dimensions_used")
|
||||||
|
if dims_used and isinstance(dims_used, dict):
|
||||||
|
if "duration_seconds" in dims_used:
|
||||||
|
result["duration_seconds"] = dims_used["duration_seconds"]
|
||||||
|
if "video_resolution_key" in dims_used:
|
||||||
|
result["resolution"] = dims_used["video_resolution_key"]
|
||||||
|
if "video_price_per_second" in dims_used:
|
||||||
|
result["video_price_per_second"] = dims_used["video_price_per_second"]
|
||||||
|
if "video_cost" in dims_used:
|
||||||
|
result["video_cost"] = dims_used["video_cost"]
|
||||||
|
|
||||||
|
# 补充从 dimensions 中提取(备用)
|
||||||
|
if dimensions and isinstance(dimensions, dict):
|
||||||
|
if "duration_seconds" not in result and "duration_seconds" in dimensions:
|
||||||
|
result["duration_seconds"] = dimensions["duration_seconds"]
|
||||||
|
if "resolution" not in result and "video_resolution_key" in dimensions:
|
||||||
|
result["resolution"] = dimensions["video_resolution_key"]
|
||||||
|
|
||||||
|
# 如果没有有意义的视频计费信息,返回 None
|
||||||
|
has_video_info = (
|
||||||
|
result.get("duration_seconds")
|
||||||
|
or result.get("resolution")
|
||||||
|
or result.get("video_cost")
|
||||||
|
or result.get("cost")
|
||||||
|
)
|
||||||
|
if not has_video_info:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ==================== 缓存亲和性分析 ====================
|
# ==================== 缓存亲和性分析 ====================
|
||||||
|
|
||||||
|
|||||||
@@ -7,18 +7,22 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from src.api.base.context import ApiRequestContext
|
from src.api.base.context import ApiRequestContext
|
||||||
from src.api.base.pipeline import ApiRequestPipeline
|
from src.api.base.pipeline import ApiRequestPipeline
|
||||||
from src.api.dashboard.routes import DashboardAdapter
|
from src.api.dashboard.routes import DashboardAdapter
|
||||||
|
from src.clients.http_client import HTTPClientPool
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
from src.core.enums import UserRole
|
from src.core.enums import UserRole
|
||||||
|
from src.core.logger import logger
|
||||||
from src.database import get_db
|
from src.database import get_db
|
||||||
from src.models.database import Provider, ProviderEndpoint, User, VideoTask
|
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint, User, VideoTask
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/video-tasks", tags=["Admin - Video Tasks"])
|
router = APIRouter(prefix="/api/admin/video-tasks", tags=["Admin - Video Tasks"])
|
||||||
pipeline = ApiRequestPipeline()
|
pipeline = ApiRequestPipeline()
|
||||||
@@ -119,6 +123,117 @@ async def cancel_video_task(
|
|||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{task_id}/video")
|
||||||
|
async def proxy_video_stream(
|
||||||
|
task_id: str,
|
||||||
|
request: Request,
|
||||||
|
token: str | None = Query(None, description="JWT access token"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""
|
||||||
|
代理视频流(用于需要认证的视频链接)
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `task_id`: 任务 ID
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `token`: JWT access token(用于 video 标签请求)
|
||||||
|
|
||||||
|
**返回**:
|
||||||
|
- 视频流
|
||||||
|
"""
|
||||||
|
from src.services.auth.service import AuthService
|
||||||
|
|
||||||
|
# 尝试从多个来源获取 token:query param > cookie > header
|
||||||
|
auth_token = token
|
||||||
|
if not auth_token:
|
||||||
|
auth_token = request.cookies.get("access_token")
|
||||||
|
if not auth_token:
|
||||||
|
auth_header = request.headers.get("Authorization")
|
||||||
|
if auth_header and auth_header.startswith("Bearer "):
|
||||||
|
auth_token = auth_header[7:]
|
||||||
|
|
||||||
|
if not auth_token:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 验证 token 并获取 payload
|
||||||
|
payload = await AuthService.verify_token(auth_token, token_type="access")
|
||||||
|
user_id = payload.get("user_id") or payload.get("sub")
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
# 查询用户
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
# 查询任务
|
||||||
|
query = db.query(VideoTask).filter(VideoTask.id == task_id)
|
||||||
|
if user.role != UserRole.ADMIN:
|
||||||
|
query = query.filter(VideoTask.user_id == user.id)
|
||||||
|
|
||||||
|
task = query.first()
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Video task not found")
|
||||||
|
|
||||||
|
if not task.video_url:
|
||||||
|
raise HTTPException(status_code=404, detail="Video not available")
|
||||||
|
|
||||||
|
# 检查是否需要代理(Google API 链接需要认证)
|
||||||
|
video_url = task.video_url
|
||||||
|
needs_proxy = "generativelanguage.googleapis.com" in video_url
|
||||||
|
|
||||||
|
if not needs_proxy:
|
||||||
|
# 不需要代理,重定向到原始 URL
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
return RedirectResponse(url=video_url)
|
||||||
|
|
||||||
|
# 需要代理:获取 provider key 进行认证
|
||||||
|
if not task.key_id:
|
||||||
|
raise HTTPException(status_code=500, detail="Missing provider key")
|
||||||
|
|
||||||
|
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == task.key_id).first()
|
||||||
|
if not key or not key.api_key:
|
||||||
|
raise HTTPException(status_code=500, detail="Provider key not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
api_key = crypto_service.decrypt(key.api_key)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to decrypt provider key")
|
||||||
|
|
||||||
|
# 构建认证头
|
||||||
|
headers = {"x-goog-api-key": api_key}
|
||||||
|
|
||||||
|
async def stream_video() -> AsyncIterator[bytes]:
|
||||||
|
"""流式下载并返回视频"""
|
||||||
|
try:
|
||||||
|
client = await HTTPClientPool.get_default_client_async()
|
||||||
|
async with client.stream("GET", video_url, headers=headers) as response:
|
||||||
|
if response.status_code >= 400:
|
||||||
|
logger.warning(
|
||||||
|
"Video proxy failed: task={} status={}", task_id, response.status_code
|
||||||
|
)
|
||||||
|
return
|
||||||
|
async for chunk in response.aiter_bytes(chunk_size=65536):
|
||||||
|
yield chunk
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Video proxy error: task={} error={}", task_id, str(e))
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
stream_video(),
|
||||||
|
media_type="video/mp4",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'inline; filename="video_{task_id}.mp4"',
|
||||||
|
"Cache-Control": "private, max-age=3600",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ==================== Adapters ====================
|
# ==================== Adapters ====================
|
||||||
|
|
||||||
|
|
||||||
@@ -359,6 +474,7 @@ class VideoTaskDetailAdapter(DashboardAdapter):
|
|||||||
"video_urls": task.video_urls,
|
"video_urls": task.video_urls,
|
||||||
"thumbnail_url": task.thumbnail_url,
|
"thumbnail_url": task.thumbnail_url,
|
||||||
"video_size_bytes": task.video_size_bytes,
|
"video_size_bytes": task.video_size_bytes,
|
||||||
|
"video_duration_seconds": task.video_duration_seconds,
|
||||||
"video_expires_at": (
|
"video_expires_at": (
|
||||||
task.video_expires_at.isoformat() if task.video_expires_at else None
|
task.video_expires_at.isoformat() if task.video_expires_at else None
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -211,8 +211,10 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
|||||||
month_start = month_start_local.astimezone(timezone.utc)
|
month_start = month_start_local.astimezone(timezone.utc)
|
||||||
|
|
||||||
# ==================== 使用预聚合数据 ====================
|
# ==================== 使用预聚合数据 ====================
|
||||||
|
# 今日实时数据只查询一次,避免重复扫描 Usage 表
|
||||||
|
today_stats = StatsAggregatorService.get_today_realtime_stats(db)
|
||||||
# 从 stats_summary + 今日实时数据获取全局统计
|
# 从 stats_summary + 今日实时数据获取全局统计
|
||||||
combined_stats = StatsAggregatorService.get_combined_stats(db)
|
combined_stats = StatsAggregatorService.get_combined_stats(db, today_stats=today_stats)
|
||||||
|
|
||||||
all_time_requests = combined_stats["total_requests"]
|
all_time_requests = combined_stats["total_requests"]
|
||||||
all_time_success_requests = combined_stats["success_requests"]
|
all_time_success_requests = combined_stats["success_requests"]
|
||||||
@@ -237,7 +239,6 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ==================== 今日实时统计 ====================
|
# ==================== 今日实时统计 ====================
|
||||||
today_stats = StatsAggregatorService.get_today_realtime_stats(db)
|
|
||||||
requests_today = today_stats["total_requests"]
|
requests_today = today_stats["total_requests"]
|
||||||
cost_today = today_stats["total_cost"]
|
cost_today = today_stats["total_cost"]
|
||||||
actual_cost_today = today_stats["actual_total_cost"]
|
actual_cost_today = today_stats["actual_total_cost"]
|
||||||
@@ -951,26 +952,9 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
|
|||||||
today_stats = StatsAggregatorService.get_today_realtime_stats(db)
|
today_stats = StatsAggregatorService.get_today_realtime_stats(db)
|
||||||
today_str = today_local.date().isoformat()
|
today_str = today_local.date().isoformat()
|
||||||
if today_stats["total_requests"] > 0:
|
if today_stats["total_requests"] > 0:
|
||||||
# 今日平均响应时间需要单独查询
|
today_avg_rt_ms = float(today_stats.get("avg_response_time_ms") or 0.0)
|
||||||
today_avg_rt = (
|
today_unique_models = int(today_stats.get("unique_models") or 0)
|
||||||
db.query(func.avg(Usage.response_time_ms))
|
today_unique_providers = int(today_stats.get("unique_providers") or 0)
|
||||||
.filter(Usage.created_at >= today, Usage.response_time_ms.isnot(None))
|
|
||||||
.scalar()
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
# 今日 unique_models 和 unique_providers
|
|
||||||
today_unique_models = (
|
|
||||||
db.query(func.count(func.distinct(Usage.model)))
|
|
||||||
.filter(Usage.created_at >= today)
|
|
||||||
.scalar()
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
today_unique_providers = (
|
|
||||||
db.query(func.count(func.distinct(Usage.provider_name)))
|
|
||||||
.filter(Usage.created_at >= today)
|
|
||||||
.scalar()
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
# 今日 fallback_count
|
# 今日 fallback_count
|
||||||
today_fallback_count = (
|
today_fallback_count = (
|
||||||
db.query(func.count())
|
db.query(func.count())
|
||||||
@@ -996,7 +980,7 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
|
|||||||
+ today_stats["cache_read_tokens"]
|
+ today_stats["cache_read_tokens"]
|
||||||
),
|
),
|
||||||
"cost": today_stats["total_cost"],
|
"cost": today_stats["total_cost"],
|
||||||
"avg_response_time": float(today_avg_rt) / 1000.0 if today_avg_rt else 0,
|
"avg_response_time": today_avg_rt_ms / 1000.0 if today_avg_rt_ms else 0,
|
||||||
"unique_models": today_unique_models,
|
"unique_models": today_unique_models,
|
||||||
"unique_providers": today_unique_providers,
|
"unique_providers": today_unique_providers,
|
||||||
"fallback_count": today_fallback_count,
|
"fallback_count": today_fallback_count,
|
||||||
|
|||||||
@@ -100,10 +100,7 @@ class StreamTelemetryRecorder:
|
|||||||
return
|
return
|
||||||
actual_request_body = ctx.provider_request_body or original_request_body
|
actual_request_body = ctx.provider_request_body or original_request_body
|
||||||
response_body = None
|
response_body = None
|
||||||
if (
|
if not isinstance(writer, QueueTelemetryWriter) or writer.include_bodies:
|
||||||
not isinstance(writer, QueueTelemetryWriter)
|
|
||||||
or config.usage_queue_include_bodies
|
|
||||||
):
|
|
||||||
response_body = ctx.build_response_body(response_time_ms)
|
response_body = ctx.build_response_body(response_time_ms)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -403,10 +400,25 @@ class StreamTelemetryRecorder:
|
|||||||
self, bg_db: Session, ctx: StreamContext, response_time_ms: int
|
self, bg_db: Session, ctx: StreamContext, response_time_ms: int
|
||||||
) -> TelemetryWriter | None:
|
) -> TelemetryWriter | None:
|
||||||
if config.usage_queue_enabled and self.user_id and self.api_key_id:
|
if config.usage_queue_enabled and self.user_id and self.api_key_id:
|
||||||
|
from src.services.system.config import SystemConfigService
|
||||||
|
|
||||||
|
# Queue payload detail follows system config request_record_level.
|
||||||
|
log_level = SystemConfigService.get_request_record_level(bg_db).value
|
||||||
|
sensitive_headers = SystemConfigService.get_sensitive_headers(bg_db) or []
|
||||||
|
max_request_body_size = int(
|
||||||
|
SystemConfigService.get_config(bg_db, "max_request_body_size", 5242880) or 0
|
||||||
|
)
|
||||||
|
max_response_body_size = int(
|
||||||
|
SystemConfigService.get_config(bg_db, "max_response_body_size", 5242880) or 0
|
||||||
|
)
|
||||||
return QueueTelemetryWriter(
|
return QueueTelemetryWriter(
|
||||||
request_id=self.request_id,
|
request_id=self.request_id,
|
||||||
user_id=self.user_id,
|
user_id=self.user_id,
|
||||||
api_key_id=self.api_key_id,
|
api_key_id=self.api_key_id,
|
||||||
|
log_level=log_level,
|
||||||
|
sensitive_headers=sensitive_headers,
|
||||||
|
max_request_body_size=max_request_body_size,
|
||||||
|
max_response_body_size=max_response_body_size,
|
||||||
)
|
)
|
||||||
db_writer = self._build_db_writer(bg_db)
|
db_writer = self._build_db_writer(bg_db)
|
||||||
if db_writer is None:
|
if db_writer is None:
|
||||||
|
|||||||
@@ -75,6 +75,17 @@ class GeminiVeoHandler(VideoHandlerBase):
|
|||||||
)
|
)
|
||||||
self._normalizer = GeminiNormalizer()
|
self._normalizer = GeminiNormalizer()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_request_base_url(http_request: Request) -> str:
|
||||||
|
"""从 HTTP 请求中获取基础 URL(协议 + 主机)"""
|
||||||
|
# 优先使用 X-Forwarded-Proto 和 X-Forwarded-Host(代理场景)
|
||||||
|
proto = http_request.headers.get("x-forwarded-proto") or http_request.url.scheme
|
||||||
|
host = http_request.headers.get("x-forwarded-host") or http_request.headers.get("host")
|
||||||
|
if host:
|
||||||
|
return f"{proto}://{host}"
|
||||||
|
# 回退到 request.url
|
||||||
|
return f"{http_request.url.scheme}://{http_request.url.netloc}"
|
||||||
|
|
||||||
async def handle_create_task(
|
async def handle_create_task(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -259,7 +270,8 @@ class GeminiVeoHandler(VideoHandlerBase):
|
|||||||
created_at=task.created_at,
|
created_at=task.created_at,
|
||||||
original_request=internal_request,
|
original_request=internal_request,
|
||||||
)
|
)
|
||||||
response_body = self._normalizer.video_task_from_internal(internal_task)
|
base_url = self._get_request_base_url(http_request)
|
||||||
|
response_body = self._normalizer.video_task_from_internal(internal_task, base_url=base_url)
|
||||||
|
|
||||||
# 提交成功后立即结算 Usage(费用暂时为 0,轮询完成后更新)
|
# 提交成功后立即结算 Usage(费用暂时为 0,轮询完成后更新)
|
||||||
response_time_ms = int((time.time() - self.start_time) * 1000)
|
response_time_ms = int((time.time() - self.start_time) * 1000)
|
||||||
@@ -313,7 +325,8 @@ class GeminiVeoHandler(VideoHandlerBase):
|
|||||||
|
|
||||||
# 直接从数据库返回任务状态(后台轮询服务会持续更新状态)
|
# 直接从数据库返回任务状态(后台轮询服务会持续更新状态)
|
||||||
internal_task = self._task_to_internal(task)
|
internal_task = self._task_to_internal(task)
|
||||||
response_body = self._normalizer.video_task_from_internal(internal_task)
|
base_url = self._get_request_base_url(http_request)
|
||||||
|
response_body = self._normalizer.video_task_from_internal(internal_task, base_url=base_url)
|
||||||
return JSONResponse(response_body)
|
return JSONResponse(response_body)
|
||||||
|
|
||||||
async def handle_list_tasks(
|
async def handle_list_tasks(
|
||||||
@@ -331,8 +344,10 @@ class GeminiVeoHandler(VideoHandlerBase):
|
|||||||
.limit(100)
|
.limit(100)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
base_url = self._get_request_base_url(http_request)
|
||||||
items = [
|
items = [
|
||||||
self._normalizer.video_task_from_internal(self._task_to_internal(t)) for t in tasks
|
self._normalizer.video_task_from_internal(self._task_to_internal(t), base_url=base_url)
|
||||||
|
for t in tasks
|
||||||
]
|
]
|
||||||
return JSONResponse({"operations": items})
|
return JSONResponse({"operations": items})
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@@ -650,8 +651,10 @@ class OpenAIVideoHandler(VideoHandlerBase):
|
|||||||
try:
|
try:
|
||||||
# 使用 httpx 的 stream 方法并正确管理上下文
|
# 使用 httpx 的 stream 方法并正确管理上下文
|
||||||
# 视频下载可能较大,设置 5 分钟超时
|
# 视频下载可能较大,设置 5 分钟超时
|
||||||
request = client.build_request("GET", upstream_url, headers=headers)
|
request = client.build_request(
|
||||||
response = await client.send(request, stream=True, timeout=300.0)
|
"GET", upstream_url, headers=headers, timeout=httpx.Timeout(300.0)
|
||||||
|
)
|
||||||
|
response = await client.send(request, stream=True)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"[VideoDownload] Upstream connection failed task={} url={}: {}",
|
"[VideoDownload] Upstream connection failed task={} url={}: {}",
|
||||||
@@ -723,8 +726,8 @@ class OpenAIVideoHandler(VideoHandlerBase):
|
|||||||
"""代理直接的视频 URL(如 CDN URL),保持与官方 API 一致的流式返回行为"""
|
"""代理直接的视频 URL(如 CDN URL),保持与官方 API 一致的流式返回行为"""
|
||||||
client = await HTTPClientPool.get_default_client_async()
|
client = await HTTPClientPool.get_default_client_async()
|
||||||
try:
|
try:
|
||||||
request = client.build_request("GET", url)
|
request = client.build_request("GET", url, timeout=httpx.Timeout(300.0))
|
||||||
response = await client.send(request, stream=True, timeout=300.0)
|
response = await client.send(request, stream=True)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"[VideoDownload] Direct URL connection failed task={} url={}: {}",
|
"[VideoDownload] Direct URL connection failed task={} url={}: {}",
|
||||||
|
|||||||
@@ -73,11 +73,11 @@ def _get_formats_for_api(api_format: str) -> list[str]:
|
|||||||
return _OPENAI_FORMATS
|
return _OPENAI_FORMATS
|
||||||
|
|
||||||
|
|
||||||
def _is_format_conversion_enabled() -> bool:
|
def _is_format_conversion_enabled(db: Session) -> bool:
|
||||||
"""检查全局格式转换开关(从环境变量读取,默认开启)"""
|
"""检查全局格式转换开关(从数据库配置读取,默认开启)"""
|
||||||
from src.config.settings import config
|
from src.services.system.config import SystemConfigService
|
||||||
|
|
||||||
return config.format_conversion_enabled
|
return SystemConfigService.is_format_conversion_enabled(db)
|
||||||
|
|
||||||
|
|
||||||
def _get_convertible_formats(client_format: str, global_conversion_enabled: bool) -> list[str]:
|
def _get_convertible_formats(client_format: str, global_conversion_enabled: bool) -> list[str]:
|
||||||
@@ -500,7 +500,7 @@ async def list_models(
|
|||||||
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
|
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
|
||||||
|
|
||||||
# 获取可用格式(包括可转换的格式)
|
# 获取可用格式(包括可转换的格式)
|
||||||
global_conversion_enabled = _is_format_conversion_enabled()
|
global_conversion_enabled = _is_format_conversion_enabled(db)
|
||||||
candidate_formats = _get_convertible_formats(api_format, global_conversion_enabled)
|
candidate_formats = _get_convertible_formats(api_format, global_conversion_enabled)
|
||||||
candidate_formats, empty_response = _filter_formats_by_restrictions(
|
candidate_formats, empty_response = _filter_formats_by_restrictions(
|
||||||
candidate_formats, restrictions, api_format
|
candidate_formats, restrictions, api_format
|
||||||
@@ -604,7 +604,7 @@ async def retrieve_model(
|
|||||||
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
|
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
|
||||||
|
|
||||||
# 获取可用格式(包括可转换的格式)
|
# 获取可用格式(包括可转换的格式)
|
||||||
global_conversion_enabled = _is_format_conversion_enabled()
|
global_conversion_enabled = _is_format_conversion_enabled(db)
|
||||||
candidate_formats = _get_convertible_formats(api_format, global_conversion_enabled)
|
candidate_formats = _get_convertible_formats(api_format, global_conversion_enabled)
|
||||||
candidate_formats, _ = _filter_formats_by_restrictions(
|
candidate_formats, _ = _filter_formats_by_restrictions(
|
||||||
candidate_formats, restrictions, api_format
|
candidate_formats, restrictions, api_format
|
||||||
@@ -687,7 +687,7 @@ async def list_models_gemini(
|
|||||||
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
|
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
|
||||||
|
|
||||||
# 获取可用格式(包括可转换的格式)
|
# 获取可用格式(包括可转换的格式)
|
||||||
global_conversion_enabled = _is_format_conversion_enabled()
|
global_conversion_enabled = _is_format_conversion_enabled(db)
|
||||||
candidate_formats = _get_convertible_formats(api_format, global_conversion_enabled)
|
candidate_formats = _get_convertible_formats(api_format, global_conversion_enabled)
|
||||||
candidate_formats, empty_response = _filter_formats_by_restrictions(
|
candidate_formats, empty_response = _filter_formats_by_restrictions(
|
||||||
candidate_formats, restrictions, api_format
|
candidate_formats, restrictions, api_format
|
||||||
@@ -766,7 +766,7 @@ async def get_model_gemini(
|
|||||||
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
|
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
|
||||||
|
|
||||||
# 获取可用格式(包括可转换的格式)
|
# 获取可用格式(包括可转换的格式)
|
||||||
global_conversion_enabled = _is_format_conversion_enabled()
|
global_conversion_enabled = _is_format_conversion_enabled(db)
|
||||||
candidate_formats = _get_convertible_formats(api_format, global_conversion_enabled)
|
candidate_formats = _get_convertible_formats(api_format, global_conversion_enabled)
|
||||||
candidate_formats, _ = _filter_formats_by_restrictions(
|
candidate_formats, _ = _filter_formats_by_restrictions(
|
||||||
candidate_formats, restrictions, api_format
|
candidate_formats, restrictions, api_format
|
||||||
|
|||||||
@@ -1110,7 +1110,7 @@ class ListAvailableModelsAdapter(AuthenticatedApiAdapter):
|
|||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
|
|
||||||
from src.api.base.models_service import AccessRestrictions
|
from src.api.base.models_service import AccessRestrictions
|
||||||
from src.config.settings import config as app_config
|
from src.services.system.config import SystemConfigService
|
||||||
|
|
||||||
db = context.db
|
db = context.db
|
||||||
user = context.user
|
user = context.user
|
||||||
@@ -1118,8 +1118,8 @@ class ListAvailableModelsAdapter(AuthenticatedApiAdapter):
|
|||||||
# 使用 AccessRestrictions 类来处理限制(与 /v1/models 逻辑一致)
|
# 使用 AccessRestrictions 类来处理限制(与 /v1/models 逻辑一致)
|
||||||
restrictions = AccessRestrictions.from_api_key_and_user(api_key=None, user=user)
|
restrictions = AccessRestrictions.from_api_key_and_user(api_key=None, user=user)
|
||||||
|
|
||||||
# 检查全局格式转换开关
|
# 检查全局格式转换开关(从数据库配置读取)
|
||||||
global_conversion_enabled = app_config.format_conversion_enabled
|
global_conversion_enabled = SystemConfigService.is_format_conversion_enabled(db)
|
||||||
|
|
||||||
# 获取所有可用的 Provider ID(考虑格式转换)
|
# 获取所有可用的 Provider ID(考虑格式转换)
|
||||||
available_provider_ids = self._get_all_available_provider_ids(db, global_conversion_enabled)
|
available_provider_ids = self._get_all_available_provider_ids(db, global_conversion_enabled)
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ class CacheTTL:
|
|||||||
DASHBOARD_STATS = 120 # 2分钟(管理员)
|
DASHBOARD_STATS = 120 # 2分钟(管理员)
|
||||||
DASHBOARD_DAILY = 600 # 10分钟(每日统计)
|
DASHBOARD_DAILY = 600 # 10分钟(每日统计)
|
||||||
|
|
||||||
|
# Admin usage pages (heavy DB aggregations / list queries)
|
||||||
|
ADMIN_USAGE_AGGREGATION = 30 # 30秒
|
||||||
|
ADMIN_USAGE_RECORDS = 10 # 10秒(列表页短缓存,避免轮询/重复刷新打爆 DB)
|
||||||
|
|
||||||
# 并发锁 TTL - 防止死锁
|
# 并发锁 TTL - 防止死锁
|
||||||
CONCURRENCY_LOCK = 600 # 10分钟
|
CONCURRENCY_LOCK = 600 # 10分钟
|
||||||
|
|
||||||
|
|||||||
@@ -148,21 +148,6 @@ class Config:
|
|||||||
# HTTP_REQUEST_TIMEOUT: 非流式请求整体超时(秒),默认 300 秒
|
# HTTP_REQUEST_TIMEOUT: 非流式请求整体超时(秒),默认 300 秒
|
||||||
self.http_request_timeout = float(os.getenv("HTTP_REQUEST_TIMEOUT", "300.0"))
|
self.http_request_timeout = float(os.getenv("HTTP_REQUEST_TIMEOUT", "300.0"))
|
||||||
|
|
||||||
# 格式转换配置
|
|
||||||
# FORMAT_CONVERSION_ENABLED: 全局格式转换总开关,默认开启
|
|
||||||
# 注意:即使开启,也需要端点配置 format_acceptance_config.enabled=true 才能生效
|
|
||||||
self.format_conversion_enabled = (
|
|
||||||
os.getenv("FORMAT_CONVERSION_ENABLED", "true").lower() == "true"
|
|
||||||
)
|
|
||||||
|
|
||||||
# KEEP_PRIORITY_ON_CONVERSION: 格式转换时是否保持提供商原优先级,默认关闭
|
|
||||||
# - false(默认): 需要格式转换的候选整体降级到不需要转换的候选之后
|
|
||||||
# - true: 所有提供商保持原优先级,不因格式转换降级
|
|
||||||
# 注意:即使全局关闭,单个提供商也可以通过 keep_priority_on_conversion 字段保持自己的优先级
|
|
||||||
self.keep_priority_on_conversion = (
|
|
||||||
os.getenv("KEEP_PRIORITY_ON_CONVERSION", "false").lower() == "true"
|
|
||||||
)
|
|
||||||
|
|
||||||
# HTTP 连接池配置
|
# HTTP 连接池配置
|
||||||
# HTTP_MAX_CONNECTIONS: 最大连接数,影响并发能力
|
# HTTP_MAX_CONNECTIONS: 最大连接数,影响并发能力
|
||||||
# - 每个连接占用一个 socket,过多会耗尽系统资源
|
# - 每个连接占用一个 socket,过多会耗尽系统资源
|
||||||
@@ -192,15 +177,8 @@ class Config:
|
|||||||
# Usage 队列配置(Redis Streams)
|
# Usage 队列配置(Redis Streams)
|
||||||
# 默认启用队列模式,通过 Redis Streams 异步写入 DB,提升响应性能
|
# 默认启用队列模式,通过 Redis Streams 异步写入 DB,提升响应性能
|
||||||
self.usage_queue_enabled = os.getenv("USAGE_QUEUE_ENABLED", "true").lower() == "true"
|
self.usage_queue_enabled = os.getenv("USAGE_QUEUE_ENABLED", "true").lower() == "true"
|
||||||
# 默认传输 headers/bodies,由系统设置(request_log_level)决定最终存储内容
|
# 队列事件是否包含 headers/bodies 由系统配置(request_record_level)决定;
|
||||||
self.usage_queue_include_headers = (
|
# 最终写入 DB 前仍会按 SystemConfigService 做脱敏与截断。
|
||||||
os.getenv("USAGE_QUEUE_INCLUDE_HEADERS", "true").lower() == "true"
|
|
||||||
)
|
|
||||||
self.usage_queue_include_bodies = (
|
|
||||||
os.getenv("USAGE_QUEUE_INCLUDE_BODIES", "true").lower() == "true"
|
|
||||||
)
|
|
||||||
# 0 表示不截断,由系统设置(max_request/response_body_size)统一控制
|
|
||||||
self.usage_queue_body_max_bytes = int(os.getenv("USAGE_QUEUE_BODY_MAX_BYTES", "0"))
|
|
||||||
self.usage_queue_stream_key = os.getenv("USAGE_QUEUE_STREAM_KEY", "usage:events")
|
self.usage_queue_stream_key = os.getenv("USAGE_QUEUE_STREAM_KEY", "usage:events")
|
||||||
self.usage_queue_stream_group = os.getenv("USAGE_QUEUE_STREAM_GROUP", "usage_consumers")
|
self.usage_queue_stream_group = os.getenv("USAGE_QUEUE_STREAM_GROUP", "usage_consumers")
|
||||||
self.usage_queue_stream_maxlen = int(os.getenv("USAGE_QUEUE_STREAM_MAXLEN", "200000"))
|
self.usage_queue_stream_maxlen = int(os.getenv("USAGE_QUEUE_STREAM_MAXLEN", "200000"))
|
||||||
@@ -217,6 +195,17 @@ class Config:
|
|||||||
os.getenv("USAGE_QUEUE_METRICS_INTERVAL_SECONDS", "30")
|
os.getenv("USAGE_QUEUE_METRICS_INTERVAL_SECONDS", "30")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Admin analytics query defaults (protect DB from unbounded scans)
|
||||||
|
# ADMIN_USAGE_DEFAULT_DAYS:
|
||||||
|
# - 0: keep current behavior (no implicit time filter)
|
||||||
|
# - >0: when admin usage endpoints omit start_date/end_date, default to "last N days"
|
||||||
|
default_admin_usage_default_days = (
|
||||||
|
"0" if self.environment in {"development", "test", "testing"} else "30"
|
||||||
|
)
|
||||||
|
self.admin_usage_default_days = int(
|
||||||
|
os.getenv("ADMIN_USAGE_DEFAULT_DAYS", default_admin_usage_default_days)
|
||||||
|
)
|
||||||
|
|
||||||
# Thinking 整流器配置
|
# Thinking 整流器配置
|
||||||
# THINKING_RECTIFIER_ENABLED: 是否启用 Thinking 整流器
|
# THINKING_RECTIFIER_ENABLED: 是否启用 Thinking 整流器
|
||||||
# 当遇到跨 Provider 的 thinking 签名错误时,自动整流请求体后重试
|
# 当遇到跨 Provider 的 thinking 签名错误时,自动整流请求体后重试
|
||||||
@@ -252,6 +241,37 @@ class Config:
|
|||||||
self.billing_require_rule = os.getenv("BILLING_REQUIRE_RULE", "false").lower() == "true"
|
self.billing_require_rule = os.getenv("BILLING_REQUIRE_RULE", "false").lower() == "true"
|
||||||
self.billing_strict_mode = os.getenv("BILLING_STRICT_MODE", "false").lower() == "true"
|
self.billing_strict_mode = os.getenv("BILLING_STRICT_MODE", "false").lower() == "true"
|
||||||
|
|
||||||
|
# 计费迁移运行时开关(用于灰度/影子计费/快速止血)
|
||||||
|
# BILLING_ENGINE:
|
||||||
|
# - legacy: 仅旧系统(当前默认)
|
||||||
|
# - shadow: 旧系统为真值 + 新系统影子计算(对账期)
|
||||||
|
# - new_with_fallback: 新系统为真值,差异过大时回退旧系统
|
||||||
|
# - new: 仅新系统
|
||||||
|
# Default to "new" per unified billing architecture.
|
||||||
|
self.billing_engine = os.getenv("BILLING_ENGINE", "new").strip().lower()
|
||||||
|
# 按 provider/model 粒度覆盖(JSON 字符串)
|
||||||
|
# 示例: {"anthropic/*": "shadow", "openai/gpt-4*": "new"}
|
||||||
|
self.billing_engine_overrides = os.getenv("BILLING_ENGINE_OVERRIDES", "{}")
|
||||||
|
# 影子计费差异阈值(美元)
|
||||||
|
self.billing_diff_threshold_usd = float(os.getenv("BILLING_DIFF_THRESHOLD_USD", "0.0001"))
|
||||||
|
# 差异日志级别(DEBUG/INFO/WARNING/ERROR)
|
||||||
|
self.billing_shadow_log_level = os.getenv("BILLING_SHADOW_LOG_LEVEL", "INFO").strip()
|
||||||
|
# 是否启用差异告警(预留扩展)
|
||||||
|
self.billing_diff_alert_enabled = (
|
||||||
|
os.getenv("BILLING_DIFF_ALERT_ENABLED", "false").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Usage.request_metadata 体积控制(用于降低 DB/CPU/内存压力)
|
||||||
|
# USAGE_METADATA_MAX_BYTES:
|
||||||
|
# - 0: unlimited (backward compatible)
|
||||||
|
# - >0: best-effort prune large keys when metadata JSON exceeds this size
|
||||||
|
default_usage_metadata_max_bytes = (
|
||||||
|
"0" if self.environment in {"development", "test", "testing"} else "65536"
|
||||||
|
)
|
||||||
|
self.usage_metadata_max_bytes = int(
|
||||||
|
os.getenv("USAGE_METADATA_MAX_BYTES", default_usage_metadata_max_bytes)
|
||||||
|
)
|
||||||
|
|
||||||
# 视频任务轮询配置
|
# 视频任务轮询配置
|
||||||
# VIDEO_POLL_INTERVAL_SECONDS: 轮询间隔(秒),默认 10 秒
|
# VIDEO_POLL_INTERVAL_SECONDS: 轮询间隔(秒),默认 10 秒
|
||||||
# VIDEO_MAX_POLL_COUNT: 最大轮询次数,默认 360 次(约 1 小时)
|
# VIDEO_MAX_POLL_COUNT: 最大轮询次数,默认 360 次(约 1 小时)
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ def is_format_compatible(
|
|||||||
# 2. 格式不同 -> 需要检查格式转换开关
|
# 2. 格式不同 -> 需要检查格式转换开关
|
||||||
# 如果总开关为 False,直接拒绝(禁用任何跨格式转换)
|
# 如果总开关为 False,直接拒绝(禁用任何跨格式转换)
|
||||||
if not effective_conversion_enabled:
|
if not effective_conversion_enabled:
|
||||||
return False, False, "格式转换已禁用(FORMAT_CONVERSION_ENABLED=false)"
|
return False, False, "格式转换已禁用(enable_format_conversion=false)"
|
||||||
|
|
||||||
# 3. 如果全局或提供商开关为 ON,跳过端点配置检查
|
# 3. 如果全局或提供商开关为 ON,跳过端点配置检查
|
||||||
if not skip_endpoint_check:
|
if not skip_endpoint_check:
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ class InternalVideoPollResult:
|
|||||||
error_code: str | None = None
|
error_code: str | None = None
|
||||||
error_message: str | None = None
|
error_message: str | None = None
|
||||||
raw_response: dict[str, Any] | None = None
|
raw_response: dict[str, Any] | None = None
|
||||||
|
video_duration_seconds: float | None = None # 实际视频时长
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
@@ -102,8 +102,15 @@ class FormatNormalizer(ABC):
|
|||||||
"""将视频任务响应转换为内部表示"""
|
"""将视频任务响应转换为内部表示"""
|
||||||
raise NotImplementedError(f"{self.__class__.__name__} does not support video conversion")
|
raise NotImplementedError(f"{self.__class__.__name__} does not support video conversion")
|
||||||
|
|
||||||
def video_task_from_internal(self, internal: InternalVideoTask) -> dict[str, Any]:
|
def video_task_from_internal(
|
||||||
"""将内部视频任务转换为格式特定响应"""
|
self, internal: InternalVideoTask, *, base_url: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""将内部视频任务转换为格式特定响应
|
||||||
|
|
||||||
|
Args:
|
||||||
|
internal: 内部视频任务表示
|
||||||
|
base_url: 可选的基础 URL,用于构建完整的下载链接
|
||||||
|
"""
|
||||||
raise NotImplementedError(f"{self.__class__.__name__} does not support video conversion")
|
raise NotImplementedError(f"{self.__class__.__name__} does not support video conversion")
|
||||||
|
|
||||||
def video_poll_to_internal(self, response: dict[str, Any]) -> InternalVideoPollResult:
|
def video_poll_to_internal(self, response: dict[str, Any]) -> InternalVideoPollResult:
|
||||||
|
|||||||
@@ -872,7 +872,9 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
extra={"metadata": metadata},
|
extra={"metadata": metadata},
|
||||||
)
|
)
|
||||||
|
|
||||||
def video_task_from_internal(self, internal: InternalVideoTask) -> dict[str, Any]:
|
def video_task_from_internal(
|
||||||
|
self, internal: InternalVideoTask, *, base_url: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
# 从 external_id 中提取 model 名称,用于构建 operation name
|
# 从 external_id 中提取 model 名称,用于构建 operation name
|
||||||
# external_id 格式: models/{model}/operations/{gemini_id}
|
# external_id 格式: models/{model}/operations/{gemini_id}
|
||||||
model_name = "unknown"
|
model_name = "unknown"
|
||||||
@@ -889,7 +891,9 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
# 使用我们的内部 task_id 构建下载 URL,不暴露真实的 Gemini file_id
|
# 使用我们的内部 task_id 构建下载 URL,不暴露真实的 Gemini file_id
|
||||||
# 使用 aev_ 前缀标识这是视频任务的下载链接
|
# 使用 aev_ 前缀标识这是视频任务的下载链接
|
||||||
# 格式:/v1beta/files/aev_{task_id}:download?alt=media
|
# 格式:/v1beta/files/aev_{task_id}:download?alt=media
|
||||||
proxy_download_url = f"/v1beta/files/aev_{internal.id}:download?alt=media"
|
download_path = f"/v1beta/files/aev_{internal.id}:download?alt=media"
|
||||||
|
# 如果提供了 base_url,返回完整 URL;否则返回相对路径
|
||||||
|
proxy_download_url = f"{base_url}{download_path}" if base_url else download_path
|
||||||
return {
|
return {
|
||||||
"name": operation_name,
|
"name": operation_name,
|
||||||
"done": True,
|
"done": True,
|
||||||
@@ -938,12 +942,15 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
if isinstance(s, dict) and s.get("video", {}).get("uri")
|
if isinstance(s, dict) and s.get("video", {}).get("uri")
|
||||||
]
|
]
|
||||||
video_url = video_urls[0] if video_urls else None
|
video_url = video_urls[0] if video_urls else None
|
||||||
|
# 提取实际视频时长
|
||||||
|
video_duration = self._extract_gemini_video_duration(response, samples)
|
||||||
return InternalVideoPollResult(
|
return InternalVideoPollResult(
|
||||||
status=VideoStatus.COMPLETED,
|
status=VideoStatus.COMPLETED,
|
||||||
progress_percent=100,
|
progress_percent=100,
|
||||||
video_url=video_url,
|
video_url=video_url,
|
||||||
video_urls=video_urls,
|
video_urls=video_urls,
|
||||||
raw_response=response,
|
raw_response=response,
|
||||||
|
video_duration_seconds=video_duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
return InternalVideoPollResult(
|
return InternalVideoPollResult(
|
||||||
@@ -952,6 +959,41 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
raw_response=response,
|
raw_response=response,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _extract_gemini_video_duration(
|
||||||
|
self, response: dict[str, Any], samples: list[dict[str, Any]]
|
||||||
|
) -> float | None:
|
||||||
|
"""从 Gemini 响应中提取实际视频时长"""
|
||||||
|
# 尝试从 samples 中获取时长
|
||||||
|
for sample in samples:
|
||||||
|
if not isinstance(sample, dict):
|
||||||
|
continue
|
||||||
|
video = sample.get("video", {})
|
||||||
|
if isinstance(video, dict):
|
||||||
|
# 尝试多种字段名
|
||||||
|
for field in ["durationSeconds", "duration_seconds", "duration"]:
|
||||||
|
val = video.get(field)
|
||||||
|
if val is not None:
|
||||||
|
try:
|
||||||
|
# duration 可能是 "5s" 格式
|
||||||
|
if isinstance(val, str) and val.endswith("s"):
|
||||||
|
return float(val[:-1])
|
||||||
|
return float(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
# 尝试从 response.metadata 获取
|
||||||
|
metadata = response.get("metadata", {})
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
for field in ["durationSeconds", "duration_seconds", "duration"]:
|
||||||
|
val = metadata.get(field)
|
||||||
|
if val is not None:
|
||||||
|
try:
|
||||||
|
if isinstance(val, str) and val.endswith("s"):
|
||||||
|
return float(val[:-1])
|
||||||
|
return float(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
# =========================
|
# =========================
|
||||||
# Helpers
|
# Helpers
|
||||||
# =========================
|
# =========================
|
||||||
|
|||||||
@@ -396,7 +396,9 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
# OpenAI streaming may send a final "usage-only" chunk when
|
# OpenAI streaming may send a final "usage-only" chunk when
|
||||||
# stream_options.include_usage=true, where `choices` is empty but `usage` exists.
|
# stream_options.include_usage=true, where `choices` is empty but `usage` exists.
|
||||||
usage_info = self._openai_usage_to_internal(chunk.get("usage"))
|
usage_info = self._openai_usage_to_internal(chunk.get("usage"))
|
||||||
if usage_info is not None and (usage_info.total_tokens or usage_info.input_tokens or usage_info.output_tokens):
|
if usage_info is not None and (
|
||||||
|
usage_info.total_tokens or usage_info.input_tokens or usage_info.output_tokens
|
||||||
|
):
|
||||||
# For cross-format targets (e.g. Gemini), emitting usage as a late MessageStopEvent
|
# For cross-format targets (e.g. Gemini), emitting usage as a late MessageStopEvent
|
||||||
# allows the target normalizer to surface usage metadata even if the stop chunk
|
# allows the target normalizer to surface usage metadata even if the stop chunk
|
||||||
# didn't carry it.
|
# didn't carry it.
|
||||||
@@ -730,7 +732,9 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def video_task_from_internal(self, internal: InternalVideoTask) -> dict[str, Any]:
|
def video_task_from_internal(
|
||||||
|
self, internal: InternalVideoTask, *, base_url: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
status_map = {
|
status_map = {
|
||||||
VideoStatus.PENDING: "queued",
|
VideoStatus.PENDING: "queued",
|
||||||
VideoStatus.SUBMITTED: "queued",
|
VideoStatus.SUBMITTED: "queued",
|
||||||
@@ -797,6 +801,8 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
error_message="Upstream response missing video url",
|
error_message="Upstream response missing video url",
|
||||||
raw_response=response,
|
raw_response=response,
|
||||||
)
|
)
|
||||||
|
# 提取实际视频时长(尝试多种字段名)
|
||||||
|
video_duration = self._extract_video_duration(response)
|
||||||
return InternalVideoPollResult(
|
return InternalVideoPollResult(
|
||||||
status=VideoStatus.COMPLETED,
|
status=VideoStatus.COMPLETED,
|
||||||
progress_percent=100,
|
progress_percent=100,
|
||||||
@@ -805,6 +811,7 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
|
datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
|
||||||
),
|
),
|
||||||
raw_response=response,
|
raw_response=response,
|
||||||
|
video_duration_seconds=video_duration,
|
||||||
)
|
)
|
||||||
if status == "failed":
|
if status == "failed":
|
||||||
error = response.get("error") or {}
|
error = response.get("error") or {}
|
||||||
@@ -821,6 +828,36 @@ class OpenAINormalizer(FormatNormalizer):
|
|||||||
raw_response=response,
|
raw_response=response,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _extract_video_duration(self, response: dict[str, Any]) -> float | None:
|
||||||
|
"""从响应中提取实际视频时长"""
|
||||||
|
# 尝试多种可能的字段名
|
||||||
|
duration_fields = [
|
||||||
|
"duration_seconds",
|
||||||
|
"duration",
|
||||||
|
"video_duration",
|
||||||
|
"video_duration_seconds",
|
||||||
|
"length",
|
||||||
|
"length_seconds",
|
||||||
|
]
|
||||||
|
for field in duration_fields:
|
||||||
|
val = response.get(field)
|
||||||
|
if val is not None:
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
# 尝试从嵌套的 metadata 中获取
|
||||||
|
metadata = response.get("metadata") or response.get("video_metadata") or {}
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
for field in duration_fields:
|
||||||
|
val = metadata.get(field)
|
||||||
|
if val is not None:
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
# =========================
|
# =========================
|
||||||
# Helpers
|
# Helpers
|
||||||
# =========================
|
# =========================
|
||||||
|
|||||||
@@ -80,3 +80,28 @@ format_conversion_duration_seconds = Histogram(
|
|||||||
["direction", "source_format", "target_format"],
|
["direction", "source_format", "target_format"],
|
||||||
buckets=[0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0],
|
buckets=[0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ==================== Billing migration / shadow billing ====================
|
||||||
|
|
||||||
|
billing_requests_total = Counter(
|
||||||
|
"billing_requests_total",
|
||||||
|
"Total number of billing calculations",
|
||||||
|
["engine_mode", "truth_engine"], # low-cardinality labels
|
||||||
|
)
|
||||||
|
|
||||||
|
billing_fallback_total = Counter(
|
||||||
|
"billing_fallback_total",
|
||||||
|
"Total number of billing fallbacks to legacy engine",
|
||||||
|
)
|
||||||
|
|
||||||
|
billing_diff_exceeds_threshold_total = Counter(
|
||||||
|
"billing_diff_exceeds_threshold_total",
|
||||||
|
"Total number of shadow billing diffs exceeding threshold",
|
||||||
|
["engine_mode"],
|
||||||
|
)
|
||||||
|
|
||||||
|
billing_invariant_violation_total = Counter(
|
||||||
|
"billing_invariant_violation_total",
|
||||||
|
"Total number of billing invariant violations (sum(breakdown)!=total)",
|
||||||
|
["engine_mode", "truth_engine"],
|
||||||
|
)
|
||||||
|
|||||||
@@ -616,6 +616,9 @@ class ModelResponse(BaseModel):
|
|||||||
global_model_name: str | None = None
|
global_model_name: str | None = None
|
||||||
global_model_display_name: str | None = None
|
global_model_display_name: str | None = None
|
||||||
|
|
||||||
|
# 有效配置(合并 Model 和 GlobalModel 的 config)
|
||||||
|
effective_config: dict | None = None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -666,7 +666,7 @@ class Provider(Base):
|
|||||||
# 格式转换时是否保持优先级(默认 False)
|
# 格式转换时是否保持优先级(默认 False)
|
||||||
# - False: 需要格式转换时,该提供商的候选会被降级到不需要转换的候选之后
|
# - False: 需要格式转换时,该提供商的候选会被降级到不需要转换的候选之后
|
||||||
# - True: 即使需要格式转换,也保持原优先级排名
|
# - True: 即使需要格式转换,也保持原优先级排名
|
||||||
# 注意:如果全局配置 KEEP_PRIORITY_ON_CONVERSION=true,此字段被忽略(所有提供商都保持优先级)
|
# 注意:如果系统配置 keep_priority_on_conversion=true,此字段被忽略(所有提供商都保持优先级)
|
||||||
keep_priority_on_conversion = Column(Boolean, default=False, nullable=False)
|
keep_priority_on_conversion = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
# 是否允许格式转换(默认 True)
|
# 是否允许格式转换(默认 True)
|
||||||
@@ -1038,6 +1038,32 @@ class Model(Base):
|
|||||||
def get_effective_supports_image_generation(self) -> bool:
|
def get_effective_supports_image_generation(self) -> bool:
|
||||||
return self._get_effective_capability("supports_image_generation", False)
|
return self._get_effective_capability("supports_image_generation", False)
|
||||||
|
|
||||||
|
def get_effective_config(self) -> dict | None:
|
||||||
|
"""获取有效的 config(合并 Model 和 GlobalModel 的 config)
|
||||||
|
|
||||||
|
合并策略:
|
||||||
|
- GlobalModel.config 作为基础
|
||||||
|
- Model.config 覆盖 GlobalModel.config
|
||||||
|
- 深度合并 billing 子字段
|
||||||
|
"""
|
||||||
|
global_config = {}
|
||||||
|
if self.global_model and self.global_model.config:
|
||||||
|
global_config = dict(self.global_model.config)
|
||||||
|
|
||||||
|
if not self.config:
|
||||||
|
return global_config if global_config else None
|
||||||
|
|
||||||
|
# 深度合并 config
|
||||||
|
result = dict(global_config)
|
||||||
|
for key, value in self.config.items():
|
||||||
|
if key == "billing" and isinstance(value, dict) and isinstance(result.get(key), dict):
|
||||||
|
# 深度合并 billing
|
||||||
|
result[key] = {**result[key], **value}
|
||||||
|
else:
|
||||||
|
result[key] = value
|
||||||
|
|
||||||
|
return result if result else None
|
||||||
|
|
||||||
def select_provider_model_name(
|
def select_provider_model_name(
|
||||||
self, affinity_key: str | None = None, api_format: str | None = None
|
self, affinity_key: str | None = None, api_format: str | None = None
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -1429,6 +1455,7 @@ class VideoTask(Base):
|
|||||||
video_urls = Column(JSON)
|
video_urls = Column(JSON)
|
||||||
thumbnail_url = Column(String(2000))
|
thumbnail_url = Column(String(2000))
|
||||||
video_size_bytes = Column(BigInteger)
|
video_size_bytes = Column(BigInteger)
|
||||||
|
video_duration_seconds = Column(Float) # 实际视频时长(秒)
|
||||||
video_expires_at = Column(DateTime(timezone=True))
|
video_expires_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
# 存储 (可选)
|
# 存储 (可选)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from src.services.billing.models import (
|
|||||||
)
|
)
|
||||||
from src.services.billing.schema import BillingSnapshot, CostResult
|
from src.services.billing.schema import BillingSnapshot, CostResult
|
||||||
from src.services.billing.service import BillingService
|
from src.services.billing.service import BillingService
|
||||||
|
from src.services.billing.shadow import ShadowBillingService
|
||||||
from src.services.billing.templates import BILLING_TEMPLATE_REGISTRY, BillingTemplates
|
from src.services.billing.templates import BILLING_TEMPLATE_REGISTRY, BillingTemplates
|
||||||
from src.services.billing.usage_mapper import UsageMapper, map_usage, map_usage_from_response
|
from src.services.billing.usage_mapper import UsageMapper, map_usage, map_usage_from_response
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ __all__ = [
|
|||||||
"BillingService",
|
"BillingService",
|
||||||
"BillingSnapshot",
|
"BillingSnapshot",
|
||||||
"CostResult",
|
"CostResult",
|
||||||
|
"ShadowBillingService",
|
||||||
# 映射器
|
# 映射器
|
||||||
"UsageMapper",
|
"UsageMapper",
|
||||||
"map_usage",
|
"map_usage",
|
||||||
|
|||||||
130
src/services/billing/cache.py
Normal file
130
src/services/billing/cache.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
Billing in-process cache.
|
||||||
|
|
||||||
|
This module provides a small TTL cache for billing rule lookups and other
|
||||||
|
high-read, low-churn billing configuration objects.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
- Keep cached values *session-agnostic*. Avoid caching SQLAlchemy ORM objects
|
||||||
|
bound to a specific Session; prefer plain dataclasses / dicts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class BillingCache:
|
||||||
|
"""
|
||||||
|
Simple TTL + LRU cache.
|
||||||
|
|
||||||
|
- TTL: 300s (5 minutes)
|
||||||
|
- Max entries per cache: 2048 (evict oldest on overflow)
|
||||||
|
"""
|
||||||
|
|
||||||
|
TTL_SECONDS = 300
|
||||||
|
MAX_ENTRIES = 2048
|
||||||
|
|
||||||
|
_rule_cache: dict[str, tuple[Any, float]] = {}
|
||||||
|
_collector_cache: dict[str, tuple[Any, float]] = {}
|
||||||
|
_default_rule_cache: dict[str, tuple[Any, float]] = {}
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Rule cache
|
||||||
|
# ----------------------------
|
||||||
|
@classmethod
|
||||||
|
def get_rule(cls, cache_key: str) -> Any | None:
|
||||||
|
return cls._get(cls._rule_cache, cache_key)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set_rule(cls, cache_key: str, value: Any) -> None:
|
||||||
|
cls._set(cls._rule_cache, cache_key, value)
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Default-rule cache
|
||||||
|
# ----------------------------
|
||||||
|
@classmethod
|
||||||
|
def get_default_rule(cls, cache_key: str) -> Any | None:
|
||||||
|
return cls._get(cls._default_rule_cache, cache_key)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set_default_rule(cls, cache_key: str, value: Any) -> None:
|
||||||
|
cls._set(cls._default_rule_cache, cache_key, value)
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Collector cache (reserved)
|
||||||
|
# ----------------------------
|
||||||
|
@classmethod
|
||||||
|
def get_collectors(cls, cache_key: str) -> Any | None:
|
||||||
|
return cls._get(cls._collector_cache, cache_key)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set_collectors(cls, cache_key: str, value: Any) -> None:
|
||||||
|
cls._set(cls._collector_cache, cache_key, value)
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Invalidation
|
||||||
|
# ----------------------------
|
||||||
|
@classmethod
|
||||||
|
def invalidate_all(cls) -> None:
|
||||||
|
cls._rule_cache.clear()
|
||||||
|
cls._collector_cache.clear()
|
||||||
|
cls._default_rule_cache.clear()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def invalidate_model(cls, model_name: str) -> None:
|
||||||
|
"""
|
||||||
|
Invalidate cache entries referencing a model name.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- This is best-effort string matching (cache key format must include model_name).
|
||||||
|
"""
|
||||||
|
cls._invalidate_by_substring(cls._rule_cache, model_name)
|
||||||
|
cls._invalidate_by_substring(cls._default_rule_cache, model_name)
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ----------------------------
|
||||||
|
@classmethod
|
||||||
|
def _get(cls, cache: dict[str, tuple[Any, float]], key: str) -> Any | None:
|
||||||
|
item = cache.get(key)
|
||||||
|
if item is None:
|
||||||
|
return None
|
||||||
|
value, ts = item
|
||||||
|
if time.time() - ts < cls.TTL_SECONDS:
|
||||||
|
return value
|
||||||
|
# expired
|
||||||
|
try:
|
||||||
|
del cache[key]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _set(cls, cache: dict[str, tuple[Any, float]], key: str, value: Any) -> None:
|
||||||
|
"""Set with LRU eviction when cache exceeds MAX_ENTRIES."""
|
||||||
|
now = time.time()
|
||||||
|
cache[key] = (value, now)
|
||||||
|
|
||||||
|
# Evict oldest entries if over limit
|
||||||
|
if len(cache) > cls.MAX_ENTRIES:
|
||||||
|
cls._evict_oldest(cache, cls.MAX_ENTRIES // 4)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _evict_oldest(cls, cache: dict[str, tuple[Any, float]], count: int) -> None:
|
||||||
|
"""Evict the oldest `count` entries from cache."""
|
||||||
|
if not cache or count <= 0:
|
||||||
|
return
|
||||||
|
# Sort by timestamp (oldest first) and remove
|
||||||
|
sorted_keys = sorted(cache.keys(), key=lambda k: cache[k][1])
|
||||||
|
for k in sorted_keys[:count]:
|
||||||
|
cache.pop(k, None)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _invalidate_by_substring(cache: dict[str, tuple[Any, float]], needle: str) -> None:
|
||||||
|
if not needle:
|
||||||
|
return
|
||||||
|
keys = [k for k in cache.keys() if needle in k]
|
||||||
|
for k in keys:
|
||||||
|
cache.pop(k, None)
|
||||||
31
src/services/billing/collector_defs/__init__.py
Normal file
31
src/services/billing/collector_defs/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Collector definitions (config-file mode).
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
- Developers define dimension collectors in code, grouped by api_format.
|
||||||
|
- Adding support for a new api_format should only require adding a new file here.
|
||||||
|
- No DB seeding required.
|
||||||
|
|
||||||
|
Each module should export:
|
||||||
|
- COLLECTORS: list[dict[str, Any]]
|
||||||
|
|
||||||
|
Each dict supports keys (aligned with DimensionCollector):
|
||||||
|
- api_format: "openai:chat" (canonical family:kind)
|
||||||
|
- task_type: "chat" | "cli" | "video" | "image" | "audio"
|
||||||
|
- dimension_name: string
|
||||||
|
- source_type: "request" | "response" | "metadata" | "computed"
|
||||||
|
- source_path: string | None
|
||||||
|
- value_type: "float" | "int" | "string"
|
||||||
|
- transform_expression: string | None
|
||||||
|
- default_value: string | None
|
||||||
|
- priority: int
|
||||||
|
- is_enabled: bool
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# This package is discovered dynamically by `src.services.billing.presets`.
|
||||||
|
|
||||||
|
COLLECTORS: list[dict[str, Any]] = []
|
||||||
27
src/services/billing/collector_defs/claude_chat.py
Normal file
27
src/services/billing/collector_defs/claude_chat.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Anthropic / Claude messages
|
||||||
|
COLLECTORS: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"api_format": "claude:chat",
|
||||||
|
"task_type": "chat",
|
||||||
|
"dimension_name": "input_tokens",
|
||||||
|
"source_type": "response",
|
||||||
|
"source_path": "usage.input_tokens",
|
||||||
|
"value_type": "int",
|
||||||
|
"priority": 10,
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_format": "claude:chat",
|
||||||
|
"task_type": "chat",
|
||||||
|
"dimension_name": "output_tokens",
|
||||||
|
"source_type": "response",
|
||||||
|
"source_path": "usage.output_tokens",
|
||||||
|
"value_type": "int",
|
||||||
|
"priority": 10,
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
27
src/services/billing/collector_defs/gemini_chat.py
Normal file
27
src/services/billing/collector_defs/gemini_chat.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Gemini generateContent
|
||||||
|
COLLECTORS: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"api_format": "gemini:chat",
|
||||||
|
"task_type": "chat",
|
||||||
|
"dimension_name": "input_tokens",
|
||||||
|
"source_type": "response",
|
||||||
|
"source_path": "usageMetadata.promptTokenCount",
|
||||||
|
"value_type": "int",
|
||||||
|
"priority": 10,
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_format": "gemini:chat",
|
||||||
|
"task_type": "chat",
|
||||||
|
"dimension_name": "output_tokens",
|
||||||
|
"source_type": "response",
|
||||||
|
"source_path": "usageMetadata.candidatesTokenCount",
|
||||||
|
"value_type": "int",
|
||||||
|
"priority": 10,
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
27
src/services/billing/collector_defs/openai_chat.py
Normal file
27
src/services/billing/collector_defs/openai_chat.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# OpenAI chat completions
|
||||||
|
COLLECTORS: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"api_format": "openai:chat",
|
||||||
|
"task_type": "chat",
|
||||||
|
"dimension_name": "input_tokens",
|
||||||
|
"source_type": "response",
|
||||||
|
"source_path": "usage.prompt_tokens",
|
||||||
|
"value_type": "int",
|
||||||
|
"priority": 10,
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_format": "openai:chat",
|
||||||
|
"task_type": "chat",
|
||||||
|
"dimension_name": "output_tokens",
|
||||||
|
"source_type": "response",
|
||||||
|
"source_path": "usage.completion_tokens",
|
||||||
|
"value_type": "int",
|
||||||
|
"priority": 10,
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
116
src/services/billing/collector_defs/video_common.py
Normal file
116
src/services/billing/collector_defs/video_common.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Async video finalize flow: extra dims from metadata (base_dimensions already provided by caller).
|
||||||
|
#
|
||||||
|
# Note:
|
||||||
|
# - DimensionCollectorService has a "video -> base api_format fallback" that may query
|
||||||
|
# base api_format collectors when api_format is "openai:video"/"gemini:video" etc.
|
||||||
|
COLLECTORS: list[dict[str, Any]] = [
|
||||||
|
# Prefer size as "resolution key" (e.g. 1024x1792), fallback to resolution label (e.g. 720p/4k).
|
||||||
|
{
|
||||||
|
"api_format": "openai:chat",
|
||||||
|
"task_type": "video",
|
||||||
|
"dimension_name": "video_resolution_key",
|
||||||
|
"source_type": "metadata",
|
||||||
|
"source_path": "task.size",
|
||||||
|
"value_type": "string",
|
||||||
|
"priority": 10,
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_format": "openai:chat",
|
||||||
|
"task_type": "video",
|
||||||
|
"dimension_name": "video_resolution_key",
|
||||||
|
"source_type": "metadata",
|
||||||
|
"source_path": "task.resolution",
|
||||||
|
"value_type": "string",
|
||||||
|
"priority": 0,
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_format": "openai:chat",
|
||||||
|
"task_type": "video",
|
||||||
|
"dimension_name": "video_size_bytes",
|
||||||
|
"source_type": "metadata",
|
||||||
|
"source_path": "task.video_size_bytes",
|
||||||
|
"value_type": "int",
|
||||||
|
"priority": 0,
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
# 实际视频时长(秒),优先使用从 provider 响应中提取的实际时长
|
||||||
|
{
|
||||||
|
"api_format": "openai:chat",
|
||||||
|
"task_type": "video",
|
||||||
|
"dimension_name": "video_duration_seconds",
|
||||||
|
"source_type": "metadata",
|
||||||
|
"source_path": "task.video_duration_seconds",
|
||||||
|
"value_type": "float",
|
||||||
|
"priority": 10,
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
# 回退到请求的 duration_seconds(如果没有实际时长)
|
||||||
|
{
|
||||||
|
"api_format": "openai:chat",
|
||||||
|
"task_type": "video",
|
||||||
|
"dimension_name": "video_duration_seconds",
|
||||||
|
"source_type": "metadata",
|
||||||
|
"source_path": "task.duration_seconds",
|
||||||
|
"value_type": "int",
|
||||||
|
"priority": 0,
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_format": "gemini:chat",
|
||||||
|
"task_type": "video",
|
||||||
|
"dimension_name": "video_resolution_key",
|
||||||
|
"source_type": "metadata",
|
||||||
|
"source_path": "task.size",
|
||||||
|
"value_type": "string",
|
||||||
|
"priority": 10,
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_format": "gemini:chat",
|
||||||
|
"task_type": "video",
|
||||||
|
"dimension_name": "video_resolution_key",
|
||||||
|
"source_type": "metadata",
|
||||||
|
"source_path": "task.resolution",
|
||||||
|
"value_type": "string",
|
||||||
|
"priority": 0,
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_format": "gemini:chat",
|
||||||
|
"task_type": "video",
|
||||||
|
"dimension_name": "video_size_bytes",
|
||||||
|
"source_type": "metadata",
|
||||||
|
"source_path": "task.video_size_bytes",
|
||||||
|
"value_type": "int",
|
||||||
|
"priority": 0,
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
# 实际视频时长(秒),优先使用从 provider 响应中提取的实际时长
|
||||||
|
{
|
||||||
|
"api_format": "gemini:chat",
|
||||||
|
"task_type": "video",
|
||||||
|
"dimension_name": "video_duration_seconds",
|
||||||
|
"source_type": "metadata",
|
||||||
|
"source_path": "task.video_duration_seconds",
|
||||||
|
"value_type": "float",
|
||||||
|
"priority": 10,
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
# 回退到请求的 duration_seconds(如果没有实际时长)
|
||||||
|
{
|
||||||
|
"api_format": "gemini:chat",
|
||||||
|
"task_type": "video",
|
||||||
|
"dimension_name": "video_duration_seconds",
|
||||||
|
"source_type": "metadata",
|
||||||
|
"source_path": "task.duration_seconds",
|
||||||
|
"value_type": "int",
|
||||||
|
"priority": 0,
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
259
src/services/billing/default_rules.py
Normal file
259
src/services/billing/default_rules.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
"""
|
||||||
|
Default billing rules (runtime-generated).
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
- Keep backward compatibility with existing GlobalModel/Model pricing config
|
||||||
|
(tiered_pricing + price_per_request)
|
||||||
|
- Provide a virtual BillingRule when no explicit BillingRule is configured in DB.
|
||||||
|
|
||||||
|
This module MUST NOT write to DB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.models.database import GlobalModel, Model
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VirtualBillingRule:
|
||||||
|
"""A rule object compatible with BillingRule fields, generated at runtime."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
task_type: str
|
||||||
|
expression: str
|
||||||
|
variables: dict[str, Any]
|
||||||
|
dimension_mappings: dict[str, Any]
|
||||||
|
is_virtual: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def _as_float(value: Any, *, default: float = 0.0) -> float:
|
||||||
|
try:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
# avoid bool being treated as int
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return default
|
||||||
|
return float(value)
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tiers(tiered_pricing: dict | None) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(tiered_pricing, dict):
|
||||||
|
return []
|
||||||
|
tiers = tiered_pricing.get("tiers")
|
||||||
|
if not isinstance(tiers, list):
|
||||||
|
return []
|
||||||
|
return [t for t in tiers if isinstance(t, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultBillingRuleGenerator:
|
||||||
|
"""
|
||||||
|
Build a virtual BillingRule from GlobalModel/Model pricing fields.
|
||||||
|
|
||||||
|
Pricing sources:
|
||||||
|
- Tiered pricing: Model.tiered_pricing overrides GlobalModel.default_tiered_pricing
|
||||||
|
- Per-request price: Model.price_per_request overrides GlobalModel.default_price_per_request
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_for_model(
|
||||||
|
*,
|
||||||
|
global_model: GlobalModel,
|
||||||
|
model: Model | None = None,
|
||||||
|
task_type: str = "chat",
|
||||||
|
) -> VirtualBillingRule:
|
||||||
|
tiered_pricing = (
|
||||||
|
model.get_effective_tiered_pricing()
|
||||||
|
if model is not None
|
||||||
|
else global_model.default_tiered_pricing
|
||||||
|
)
|
||||||
|
tiers = _get_tiers(tiered_pricing)
|
||||||
|
|
||||||
|
# Base prices (used as defaults if tier_key missing)
|
||||||
|
first_tier = tiers[0] if tiers else {}
|
||||||
|
base_input_price = _as_float(first_tier.get("input_price_per_1m"), default=0.0)
|
||||||
|
base_output_price = _as_float(first_tier.get("output_price_per_1m"), default=0.0)
|
||||||
|
|
||||||
|
# Cache prices: keep legacy behavior (derive from input price when missing)
|
||||||
|
base_cache_creation_price = _as_float(
|
||||||
|
first_tier.get("cache_creation_price_per_1m"),
|
||||||
|
default=base_input_price * 1.25,
|
||||||
|
)
|
||||||
|
base_cache_read_price = _as_float(
|
||||||
|
first_tier.get("cache_read_price_per_1m"),
|
||||||
|
default=base_input_price * 0.1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-request price
|
||||||
|
if model is not None:
|
||||||
|
request_price = model.get_effective_price_per_request()
|
||||||
|
else:
|
||||||
|
request_price = global_model.default_price_per_request
|
||||||
|
base_request_price = _as_float(request_price, default=0.0)
|
||||||
|
|
||||||
|
# Expression uses per-1M prices and token counts.
|
||||||
|
# v2-friendly expression: total cost is sum of component costs.
|
||||||
|
expression = (
|
||||||
|
"input_cost + output_cost + cache_creation_cost + cache_read_cost + request_cost"
|
||||||
|
)
|
||||||
|
|
||||||
|
variables: dict[str, Any] = {
|
||||||
|
"input_price_per_1m": base_input_price,
|
||||||
|
"output_price_per_1m": base_output_price,
|
||||||
|
"cache_creation_price_per_1m": base_cache_creation_price,
|
||||||
|
"cache_read_price_per_1m": base_cache_read_price,
|
||||||
|
"price_per_request": base_request_price,
|
||||||
|
}
|
||||||
|
|
||||||
|
dimension_mappings: dict[str, Any] = {
|
||||||
|
# Raw dimensions
|
||||||
|
"input_tokens": {
|
||||||
|
"source": "dimension",
|
||||||
|
"key": "input_tokens",
|
||||||
|
"required": False,
|
||||||
|
"allow_zero": True,
|
||||||
|
"default": 0,
|
||||||
|
},
|
||||||
|
"output_tokens": {
|
||||||
|
"source": "dimension",
|
||||||
|
"key": "output_tokens",
|
||||||
|
"required": False,
|
||||||
|
"allow_zero": True,
|
||||||
|
"default": 0,
|
||||||
|
},
|
||||||
|
"cache_creation_tokens": {
|
||||||
|
"source": "dimension",
|
||||||
|
"key": "cache_creation_tokens",
|
||||||
|
"required": False,
|
||||||
|
"allow_zero": True,
|
||||||
|
"default": 0,
|
||||||
|
},
|
||||||
|
"cache_read_tokens": {
|
||||||
|
"source": "dimension",
|
||||||
|
"key": "cache_read_tokens",
|
||||||
|
"required": False,
|
||||||
|
"allow_zero": True,
|
||||||
|
"default": 0,
|
||||||
|
},
|
||||||
|
"request_count": {
|
||||||
|
"source": "dimension",
|
||||||
|
"key": "request_count",
|
||||||
|
"required": False,
|
||||||
|
"allow_zero": True,
|
||||||
|
"default": 1,
|
||||||
|
},
|
||||||
|
# Component costs (computed)
|
||||||
|
"input_cost": {
|
||||||
|
"source": "computed",
|
||||||
|
"expression": "input_tokens * input_price_per_1m / 1000000",
|
||||||
|
"required": False,
|
||||||
|
"default": 0,
|
||||||
|
},
|
||||||
|
"output_cost": {
|
||||||
|
"source": "computed",
|
||||||
|
"expression": "output_tokens * output_price_per_1m / 1000000",
|
||||||
|
"required": False,
|
||||||
|
"default": 0,
|
||||||
|
},
|
||||||
|
"cache_creation_cost": {
|
||||||
|
"source": "computed",
|
||||||
|
"expression": "cache_creation_tokens * cache_creation_price_per_1m / 1000000",
|
||||||
|
"required": False,
|
||||||
|
"default": 0,
|
||||||
|
},
|
||||||
|
"cache_read_cost": {
|
||||||
|
"source": "computed",
|
||||||
|
"expression": "cache_read_tokens * cache_read_price_per_1m / 1000000",
|
||||||
|
"required": False,
|
||||||
|
"default": 0,
|
||||||
|
},
|
||||||
|
"request_cost": {
|
||||||
|
"source": "computed",
|
||||||
|
"expression": "request_count * price_per_request",
|
||||||
|
"required": False,
|
||||||
|
"default": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tiered pricing: resolve effective prices based on total_input_context
|
||||||
|
# (legacy definition: input_tokens + cache_read_tokens)
|
||||||
|
if tiers:
|
||||||
|
# Build tier lists with legacy cache fallbacks per tier.
|
||||||
|
def _tier_value(
|
||||||
|
t: dict[str, Any], key: str, *, default_multiplier: float | None = None
|
||||||
|
) -> float:
|
||||||
|
if key in t and t.get(key) is not None:
|
||||||
|
return _as_float(t.get(key), default=0.0)
|
||||||
|
if default_multiplier is not None:
|
||||||
|
input_price = _as_float(t.get("input_price_per_1m"), default=0.0)
|
||||||
|
return input_price * default_multiplier
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _tiers_for(
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
default_multiplier: float | None = None,
|
||||||
|
include_cache_ttl_pricing: bool = False,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for t in tiers:
|
||||||
|
item: dict[str, Any] = {
|
||||||
|
"up_to": t.get("up_to"),
|
||||||
|
"value": _tier_value(t, key, default_multiplier=default_multiplier),
|
||||||
|
}
|
||||||
|
if include_cache_ttl_pricing and isinstance(t.get("cache_ttl_pricing"), list):
|
||||||
|
# Preserve raw ttl pricing list for FormulaEngine tiered resolver.
|
||||||
|
item["cache_ttl_pricing"] = t.get("cache_ttl_pricing")
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
tier_key = "total_input_context"
|
||||||
|
dimension_mappings["input_price_per_1m"] = {
|
||||||
|
"source": "tiered",
|
||||||
|
"tier_key": tier_key,
|
||||||
|
"allow_zero": True,
|
||||||
|
"tiers": _tiers_for("input_price_per_1m"),
|
||||||
|
"default": base_input_price,
|
||||||
|
}
|
||||||
|
dimension_mappings["output_price_per_1m"] = {
|
||||||
|
"source": "tiered",
|
||||||
|
"tier_key": tier_key,
|
||||||
|
"allow_zero": True,
|
||||||
|
"tiers": _tiers_for("output_price_per_1m"),
|
||||||
|
"default": base_output_price,
|
||||||
|
}
|
||||||
|
dimension_mappings["cache_creation_price_per_1m"] = {
|
||||||
|
"source": "tiered",
|
||||||
|
"tier_key": tier_key,
|
||||||
|
"allow_zero": True,
|
||||||
|
"tiers": _tiers_for("cache_creation_price_per_1m", default_multiplier=1.25),
|
||||||
|
"default": base_cache_creation_price,
|
||||||
|
}
|
||||||
|
dimension_mappings["cache_read_price_per_1m"] = {
|
||||||
|
"source": "tiered",
|
||||||
|
"tier_key": tier_key,
|
||||||
|
"allow_zero": True,
|
||||||
|
# TTL override supported when dims include cache_ttl_minutes
|
||||||
|
"ttl_key": "cache_ttl_minutes",
|
||||||
|
"ttl_value_key": "cache_read_price_per_1m",
|
||||||
|
"tiers": _tiers_for(
|
||||||
|
"cache_read_price_per_1m",
|
||||||
|
default_multiplier=0.1,
|
||||||
|
include_cache_ttl_pricing=True,
|
||||||
|
),
|
||||||
|
"default": base_cache_read_price,
|
||||||
|
}
|
||||||
|
|
||||||
|
return VirtualBillingRule(
|
||||||
|
id="__default__",
|
||||||
|
name=f"Default rule for {getattr(global_model, 'name', 'unknown')}",
|
||||||
|
task_type=task_type,
|
||||||
|
expression=expression,
|
||||||
|
variables=variables,
|
||||||
|
dimension_mappings=dimension_mappings,
|
||||||
|
)
|
||||||
@@ -10,24 +10,40 @@ DimensionCollector 运行时维度采集
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal, Protocol
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
from src.models.database import DimensionCollector
|
from src.models.database import DimensionCollector
|
||||||
|
from src.services.billing.cache import BillingCache
|
||||||
from src.services.billing.formula_engine import (
|
from src.services.billing.formula_engine import (
|
||||||
ExpressionEvaluationError,
|
ExpressionEvaluationError,
|
||||||
SafeExpressionEvaluator,
|
SafeExpressionEvaluator,
|
||||||
UnsafeExpressionError,
|
UnsafeExpressionError,
|
||||||
extract_variable_names,
|
extract_variable_names,
|
||||||
)
|
)
|
||||||
|
from src.services.billing.presets import CORE_PRESET_PACK
|
||||||
|
|
||||||
ValueType = Literal["float", "int", "string"]
|
ValueType = Literal["float", "int", "string"]
|
||||||
|
|
||||||
|
|
||||||
|
class CollectorLike(Protocol):
|
||||||
|
api_format: str
|
||||||
|
task_type: str
|
||||||
|
dimension_name: str
|
||||||
|
source_type: str
|
||||||
|
source_path: str | None
|
||||||
|
value_type: str
|
||||||
|
transform_expression: str | None
|
||||||
|
default_value: str | None
|
||||||
|
priority: int
|
||||||
|
is_enabled: bool
|
||||||
|
|
||||||
|
|
||||||
def _normalize_api_format(api_format: str | None) -> str:
|
def _normalize_api_format(api_format: str | None) -> str:
|
||||||
if not api_format:
|
if not api_format:
|
||||||
return ""
|
return ""
|
||||||
@@ -88,6 +104,23 @@ def _type_default(value_type: ValueType) -> Any:
|
|||||||
return "" if value_type == "string" else (0 if value_type == "int" else 0.0)
|
return "" if value_type == "string" else (0 if value_type == "int" else 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
_WXH_PATTERN = re.compile(r"^(\d+)x(\d+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_resolution_key(raw: str) -> str:
|
||||||
|
"""
|
||||||
|
Normalize resolution key:
|
||||||
|
- lowercase, remove spaces, × → x
|
||||||
|
- For WxH format, sort dimensions so smaller comes first (1080x720 → 720x1080)
|
||||||
|
"""
|
||||||
|
k = (raw or "").strip().lower().replace(" ", "").replace("×", "x")
|
||||||
|
match = _WXH_PATTERN.match(k)
|
||||||
|
if match:
|
||||||
|
a, b = int(match.group(1)), int(match.group(2))
|
||||||
|
k = f"{a}x{b}" if a <= b else f"{b}x{a}"
|
||||||
|
return k
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DimensionCollectInput:
|
class DimensionCollectInput:
|
||||||
request: dict[str, Any] | None = None
|
request: dict[str, Any] | None = None
|
||||||
@@ -105,13 +138,13 @@ class DimensionCollectorRuntime:
|
|||||||
def collect(
|
def collect(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
collectors: list[DimensionCollector],
|
collectors: list[CollectorLike],
|
||||||
inp: DimensionCollectInput,
|
inp: DimensionCollectInput,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
dims: dict[str, Any] = dict(inp.base_dimensions or {})
|
dims: dict[str, Any] = dict(inp.base_dimensions or {})
|
||||||
|
|
||||||
# dimension_name -> collectors (priority desc)
|
# dimension_name -> collectors (priority desc)
|
||||||
grouped: dict[str, list[DimensionCollector]] = {}
|
grouped: dict[str, list[CollectorLike]] = {}
|
||||||
for c in collectors:
|
for c in collectors:
|
||||||
grouped.setdefault(c.dimension_name, []).append(c)
|
grouped.setdefault(c.dimension_name, []).append(c)
|
||||||
for name in grouped:
|
for name in grouped:
|
||||||
@@ -144,7 +177,7 @@ class DimensionCollectorRuntime:
|
|||||||
def _resolve_dimension(
|
def _resolve_dimension(
|
||||||
self,
|
self,
|
||||||
dim_name: str,
|
dim_name: str,
|
||||||
collectors: list[DimensionCollector],
|
collectors: list[CollectorLike],
|
||||||
dims: dict[str, Any],
|
dims: dict[str, Any],
|
||||||
inp: DimensionCollectInput,
|
inp: DimensionCollectInput,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -207,7 +240,7 @@ class DimensionCollectorRuntime:
|
|||||||
def _resolve_computed_dimension(
|
def _resolve_computed_dimension(
|
||||||
self,
|
self,
|
||||||
dim_name: str,
|
dim_name: str,
|
||||||
collectors: list[DimensionCollector],
|
collectors: list[CollectorLike],
|
||||||
dims: dict[str, Any],
|
dims: dict[str, Any],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
fallback_default: str | None = None
|
fallback_default: str | None = None
|
||||||
@@ -245,7 +278,7 @@ class DimensionCollectorRuntime:
|
|||||||
|
|
||||||
def _toposort_computed(
|
def _toposort_computed(
|
||||||
self,
|
self,
|
||||||
grouped: dict[str, list[DimensionCollector]],
|
grouped: dict[str, list[CollectorLike]],
|
||||||
computed_only: set[str],
|
computed_only: set[str],
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
# 建图:dependency -> dim
|
# 建图:dependency -> dim
|
||||||
@@ -300,7 +333,7 @@ class DimensionCollectorRuntime:
|
|||||||
|
|
||||||
|
|
||||||
class DimensionCollectorService:
|
class DimensionCollectorService:
|
||||||
"""DB + runtime 的封装:读取 collectors 并执行采集。"""
|
"""运行时读取 collectors 并执行采集(code-only)。"""
|
||||||
|
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session):
|
||||||
self.db = db
|
self.db = db
|
||||||
@@ -311,14 +344,67 @@ class DimensionCollectorService:
|
|||||||
*,
|
*,
|
||||||
api_format: str | None,
|
api_format: str | None,
|
||||||
task_type: str | None,
|
task_type: str | None,
|
||||||
) -> list[DimensionCollector]:
|
) -> list[CollectorLike]:
|
||||||
api = _normalize_api_format(api_format)
|
api = _normalize_api_format(api_format)
|
||||||
task = _normalize_task_type(task_type)
|
task = _normalize_task_type(task_type)
|
||||||
|
if not api or not task:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Code-defined collectors cache.
|
||||||
|
cache_key = f"code:{api}:{task}"
|
||||||
|
cached = BillingCache.get_collectors(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
built = self._list_builtin_collectors(api_format=api_format, task_type=task_type)
|
||||||
|
BillingCache.set_collectors(cache_key, built)
|
||||||
|
return built
|
||||||
|
|
||||||
|
def _list_builtin_collectors(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
api_format: str | None,
|
||||||
|
task_type: str | None,
|
||||||
|
) -> list[DimensionCollector]:
|
||||||
|
"""
|
||||||
|
Built-in (code) collectors.
|
||||||
|
|
||||||
|
Developers ship a curated set of collectors in code (config-file mode).
|
||||||
|
"""
|
||||||
|
api = _normalize_api_format(api_format)
|
||||||
|
task = _normalize_task_type(task_type)
|
||||||
|
if not api or not task:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _preset_query(api_keys: list[str], task_t: str) -> list[DimensionCollector]:
|
||||||
|
out: list[DimensionCollector] = []
|
||||||
|
allowed = {k for k in api_keys if k}
|
||||||
|
for p in CORE_PRESET_PACK.collectors:
|
||||||
|
if not p.is_enabled:
|
||||||
|
continue
|
||||||
|
if _normalize_api_format(p.api_format) not in allowed:
|
||||||
|
continue
|
||||||
|
if _normalize_task_type(p.task_type) != task_t:
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
DimensionCollector(
|
||||||
|
api_format=_normalize_api_format(p.api_format),
|
||||||
|
task_type=_normalize_task_type(p.task_type),
|
||||||
|
dimension_name=p.dimension_name,
|
||||||
|
source_type=p.source_type,
|
||||||
|
source_path=p.source_path,
|
||||||
|
value_type=p.value_type,
|
||||||
|
transform_expression=p.transform_expression,
|
||||||
|
default_value=p.default_value,
|
||||||
|
priority=int(p.priority or 0),
|
||||||
|
is_enabled=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
api_variants = list({api, api.lower()})
|
api_variants = list({api, api.lower()})
|
||||||
|
|
||||||
if task == "video":
|
if task == "video":
|
||||||
# VIDEO → base 回退:优先使用 family:video 专用 collector;
|
|
||||||
# 缺失的维度再回退到 family:chat。
|
|
||||||
from src.core.api_format.signature import parse_signature_key
|
from src.core.api_format.signature import parse_signature_key
|
||||||
|
|
||||||
base_api = api
|
base_api = api
|
||||||
@@ -330,24 +416,8 @@ class DimensionCollectorService:
|
|||||||
base_api = api
|
base_api = api
|
||||||
base_variants = list({base_api, base_api.lower()})
|
base_variants = list({base_api, base_api.lower()})
|
||||||
|
|
||||||
video_collectors = (
|
video_collectors = _preset_query(api_variants, "video")
|
||||||
self.db.query(DimensionCollector)
|
base_collectors = _preset_query(base_variants, "video")
|
||||||
.filter(
|
|
||||||
DimensionCollector.api_format.in_(api_variants),
|
|
||||||
DimensionCollector.task_type == "video",
|
|
||||||
DimensionCollector.is_enabled == True, # noqa: E712
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
base_collectors = (
|
|
||||||
self.db.query(DimensionCollector)
|
|
||||||
.filter(
|
|
||||||
DimensionCollector.api_format.in_(base_variants),
|
|
||||||
DimensionCollector.task_type == "video",
|
|
||||||
DimensionCollector.is_enabled == True, # noqa: E712
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
video_dims: set[str] = {c.dimension_name for c in video_collectors}
|
video_dims: set[str] = {c.dimension_name for c in video_collectors}
|
||||||
result: list[DimensionCollector] = list(video_collectors)
|
result: list[DimensionCollector] = list(video_collectors)
|
||||||
for c in base_collectors:
|
for c in base_collectors:
|
||||||
@@ -356,25 +426,8 @@ class DimensionCollectorService:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
if task == "cli":
|
if task == "cli":
|
||||||
# CLI → chat:按维度回退(维度存在 cli collector 则用 cli,否则用 chat)
|
cli_collectors = _preset_query(api_variants, "cli")
|
||||||
cli_collectors = (
|
chat_collectors = _preset_query(api_variants, "chat")
|
||||||
self.db.query(DimensionCollector)
|
|
||||||
.filter(
|
|
||||||
DimensionCollector.api_format.in_(api_variants),
|
|
||||||
DimensionCollector.task_type == "cli",
|
|
||||||
DimensionCollector.is_enabled == True, # noqa: E712
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
chat_collectors = (
|
|
||||||
self.db.query(DimensionCollector)
|
|
||||||
.filter(
|
|
||||||
DimensionCollector.api_format.in_(api_variants),
|
|
||||||
DimensionCollector.task_type == "chat",
|
|
||||||
DimensionCollector.is_enabled == True, # noqa: E712
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
cli_dims: set[str] = {c.dimension_name for c in cli_collectors}
|
cli_dims: set[str] = {c.dimension_name for c in cli_collectors}
|
||||||
result: list[DimensionCollector] = list(cli_collectors)
|
result: list[DimensionCollector] = list(cli_collectors)
|
||||||
for c in chat_collectors:
|
for c in chat_collectors:
|
||||||
@@ -382,15 +435,7 @@ class DimensionCollectorService:
|
|||||||
result.append(c)
|
result.append(c)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
return (
|
return _preset_query(api_variants, task)
|
||||||
self.db.query(DimensionCollector)
|
|
||||||
.filter(
|
|
||||||
DimensionCollector.api_format.in_(api_variants),
|
|
||||||
DimensionCollector.task_type == task,
|
|
||||||
DimensionCollector.is_enabled == True, # noqa: E712
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
def collect_dimensions(
|
def collect_dimensions(
|
||||||
self,
|
self,
|
||||||
@@ -403,7 +448,7 @@ class DimensionCollectorService:
|
|||||||
base_dimensions: dict[str, Any] | None = None,
|
base_dimensions: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
collectors = self.list_enabled_collectors(api_format=api_format, task_type=task_type)
|
collectors = self.list_enabled_collectors(api_format=api_format, task_type=task_type)
|
||||||
return self._runtime.collect(
|
dims = self._runtime.collect(
|
||||||
collectors=collectors,
|
collectors=collectors,
|
||||||
inp=DimensionCollectInput(
|
inp=DimensionCollectInput(
|
||||||
request=request,
|
request=request,
|
||||||
@@ -412,3 +457,9 @@ class DimensionCollectorService:
|
|||||||
base_dimensions=base_dimensions,
|
base_dimensions=base_dimensions,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
# Post-process: normalize video_resolution_key (e.g., 1080x720 → 720x1080)
|
||||||
|
if "video_resolution_key" in dims:
|
||||||
|
raw = dims["video_resolution_key"]
|
||||||
|
if isinstance(raw, str) and raw:
|
||||||
|
dims["video_resolution_key"] = _normalize_resolution_key(raw)
|
||||||
|
return dims
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ FormulaEngine - 配置驱动的安全计费表达式引擎
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ast
|
import ast
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
from decimal import Decimal
|
||||||
|
from functools import lru_cache
|
||||||
from typing import Any, Iterable, Literal
|
from typing import Any, Iterable, Literal
|
||||||
|
|
||||||
|
from src.services.billing.precision import DECIMAL_CONTEXT_PRECISION, to_decimal
|
||||||
|
|
||||||
|
|
||||||
class UnsafeExpressionError(ValueError):
|
class UnsafeExpressionError(ValueError):
|
||||||
"""表达式包含不安全/不支持的 AST 结构。"""
|
"""表达式包含不安全/不支持的 AST 结构。"""
|
||||||
@@ -35,9 +39,13 @@ class BillingIncompleteError(RuntimeError):
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class FormulaEvaluationResult:
|
class FormulaEvaluationResult:
|
||||||
status: Literal["complete", "incomplete"]
|
status: Literal["complete", "incomplete"]
|
||||||
cost: float
|
cost: Decimal
|
||||||
resolved_values: dict[str, Any]
|
resolved_dimensions: dict[str, Any]
|
||||||
missing_required: list[str]
|
resolved_variables: dict[str, Any]
|
||||||
|
cost_breakdown: dict[str, Decimal] = field(default_factory=dict)
|
||||||
|
tier_index: int | None = None
|
||||||
|
tier_info: dict[str, Any] | None = None
|
||||||
|
missing_required: list[str] = field(default_factory=list)
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -53,6 +61,9 @@ _ALLOWED_BINOPS = (
|
|||||||
_ALLOWED_UNARYOPS = (ast.UAdd, ast.USub)
|
_ALLOWED_UNARYOPS = (ast.UAdd, ast.USub)
|
||||||
_ALLOWED_OP_NODES = _ALLOWED_BINOPS + _ALLOWED_UNARYOPS
|
_ALLOWED_OP_NODES = _ALLOWED_BINOPS + _ALLOWED_UNARYOPS
|
||||||
|
|
||||||
|
# Allowed function names used in expressions.
|
||||||
|
_ALLOWED_FUNC_NAMES = frozenset(("min", "max", "abs", "round", "int", "float"))
|
||||||
|
|
||||||
|
|
||||||
def _iter_ast_nodes(node: ast.AST) -> Iterable[ast.AST]:
|
def _iter_ast_nodes(node: ast.AST) -> Iterable[ast.AST]:
|
||||||
yield node
|
yield node
|
||||||
@@ -60,13 +71,92 @@ def _iter_ast_nodes(node: ast.AST) -> Iterable[ast.AST]:
|
|||||||
yield from _iter_ast_nodes(child)
|
yield from _iter_ast_nodes(child)
|
||||||
|
|
||||||
|
|
||||||
def extract_variable_names(expression: str) -> set[str]:
|
@lru_cache(maxsize=2048)
|
||||||
"""提取表达式中出现的变量名(不含函数名)。"""
|
def _validate_expression_cached(expression: str) -> ast.Expression:
|
||||||
|
"""
|
||||||
|
Parse + validate an expression and cache the resulting AST.
|
||||||
|
|
||||||
|
This is a hot path (called for every billing evaluation and many collector transforms),
|
||||||
|
so we cache validated ASTs to avoid repeated ast.parse + whitelist scans.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
tree = ast.parse(expression, mode="eval")
|
tree = ast.parse(expression, mode="eval")
|
||||||
except SyntaxError as exc:
|
except SyntaxError as exc:
|
||||||
raise UnsafeExpressionError(f"Invalid expression syntax: {exc}") from exc
|
raise UnsafeExpressionError(f"Invalid expression syntax: {exc}") from exc
|
||||||
|
|
||||||
|
for node in _iter_ast_nodes(tree):
|
||||||
|
if isinstance(node, ast.Expression):
|
||||||
|
continue
|
||||||
|
# 运算符节点本身也会出现在 iter_child_nodes 中
|
||||||
|
if isinstance(node, _ALLOWED_OP_NODES):
|
||||||
|
continue
|
||||||
|
if isinstance(node, ast.Constant):
|
||||||
|
# 仅允许数字常量(bool 是 int 子类,需要显式排除)
|
||||||
|
if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
|
||||||
|
raise UnsafeExpressionError("Only int/float constants are allowed")
|
||||||
|
continue
|
||||||
|
if isinstance(node, ast.BinOp):
|
||||||
|
if not isinstance(node.op, _ALLOWED_BINOPS):
|
||||||
|
raise UnsafeExpressionError(f"Operator not allowed: {type(node.op).__name__}")
|
||||||
|
continue
|
||||||
|
if isinstance(node, ast.UnaryOp):
|
||||||
|
if not isinstance(node.op, _ALLOWED_UNARYOPS):
|
||||||
|
raise UnsafeExpressionError(f"Unary operator not allowed: {type(node.op).__name__}")
|
||||||
|
continue
|
||||||
|
if isinstance(node, ast.Name):
|
||||||
|
# 防御:拒绝双下划线变量名
|
||||||
|
if node.id.startswith("__"):
|
||||||
|
raise UnsafeExpressionError("Dunder names are not allowed")
|
||||||
|
continue
|
||||||
|
if isinstance(node, ast.Load):
|
||||||
|
continue
|
||||||
|
if isinstance(node, ast.keyword):
|
||||||
|
continue
|
||||||
|
if isinstance(node, ast.Call):
|
||||||
|
if not isinstance(node.func, ast.Name):
|
||||||
|
raise UnsafeExpressionError("Only direct function calls are allowed")
|
||||||
|
func_name = node.func.id
|
||||||
|
if func_name not in _ALLOWED_FUNC_NAMES:
|
||||||
|
raise UnsafeExpressionError(f"Function not allowed: {func_name}")
|
||||||
|
if any(k.arg is None for k in node.keywords):
|
||||||
|
raise UnsafeExpressionError("**kwargs is not allowed")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 明确禁止的/不需要的节点类型(属性访问、下标、推导式、比较等)
|
||||||
|
if isinstance(
|
||||||
|
node,
|
||||||
|
(
|
||||||
|
ast.Attribute,
|
||||||
|
ast.Subscript,
|
||||||
|
ast.Compare,
|
||||||
|
ast.BoolOp,
|
||||||
|
ast.IfExp,
|
||||||
|
ast.Lambda,
|
||||||
|
ast.Dict,
|
||||||
|
ast.List,
|
||||||
|
ast.Tuple,
|
||||||
|
ast.Set,
|
||||||
|
ast.ListComp,
|
||||||
|
ast.SetComp,
|
||||||
|
ast.DictComp,
|
||||||
|
ast.GeneratorExp,
|
||||||
|
ast.Await,
|
||||||
|
ast.Yield,
|
||||||
|
ast.YieldFrom,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
raise UnsafeExpressionError(f"AST node not allowed: {type(node).__name__}")
|
||||||
|
|
||||||
|
raise UnsafeExpressionError(f"AST node not allowed: {type(node).__name__}")
|
||||||
|
|
||||||
|
assert isinstance(tree, ast.Expression)
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
def extract_variable_names(expression: str) -> set[str]:
|
||||||
|
"""提取表达式中出现的变量名(不含函数名)。"""
|
||||||
|
tree = _validate_expression_cached(expression)
|
||||||
|
|
||||||
names: set[str] = set()
|
names: set[str] = set()
|
||||||
for node in _iter_ast_nodes(tree):
|
for node in _iter_ast_nodes(tree):
|
||||||
if isinstance(node, ast.Name):
|
if isinstance(node, ast.Name):
|
||||||
@@ -81,110 +171,154 @@ def extract_variable_names(expression: str) -> set[str]:
|
|||||||
class SafeExpressionEvaluator:
|
class SafeExpressionEvaluator:
|
||||||
"""AST 白名单 + 无 builtins 的安全求值器。"""
|
"""AST 白名单 + 无 builtins 的安全求值器。"""
|
||||||
|
|
||||||
ALLOWED_FUNCS: dict[str, Any] = {
|
def __init__(self) -> None:
|
||||||
"min": min,
|
# Decimal-friendly allowed functions (return Decimal)
|
||||||
"max": max,
|
self.ALLOWED_FUNCS: dict[str, Any] = {
|
||||||
"abs": abs,
|
"min": self._min,
|
||||||
"round": round,
|
"max": self._max,
|
||||||
"int": int,
|
"abs": self._abs,
|
||||||
"float": float,
|
"round": self._round,
|
||||||
}
|
"int": self._int,
|
||||||
|
"float": self._float,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _min(*args: Any) -> Decimal:
|
||||||
|
return min(to_decimal(a) for a in args)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _max(*args: Any) -> Decimal:
|
||||||
|
return max(to_decimal(a) for a in args)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _abs(x: Any) -> Decimal:
|
||||||
|
return abs(to_decimal(x))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _round(x: Any, ndigits: Any = 0) -> Decimal:
|
||||||
|
# Round Decimal returns Decimal; coerce ndigits to int safely.
|
||||||
|
try:
|
||||||
|
n = int(ndigits)
|
||||||
|
except Exception:
|
||||||
|
n = 0
|
||||||
|
return round(to_decimal(x), n)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _int(x: Any) -> Decimal:
|
||||||
|
return to_decimal(int(to_decimal(x)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _float(x: Any) -> Decimal:
|
||||||
|
# Keep numeric chain in Decimal even if caller used float()
|
||||||
|
return to_decimal(float(to_decimal(x)))
|
||||||
|
|
||||||
def validate(self, expression: str) -> ast.Expression:
|
def validate(self, expression: str) -> ast.Expression:
|
||||||
try:
|
return _validate_expression_cached(expression)
|
||||||
tree = ast.parse(expression, mode="eval")
|
|
||||||
except SyntaxError as exc:
|
|
||||||
raise UnsafeExpressionError(f"Invalid expression syntax: {exc}") from exc
|
|
||||||
|
|
||||||
for node in _iter_ast_nodes(tree):
|
def eval_decimal(self, expression: str, variables: dict[str, Any]) -> Decimal:
|
||||||
if isinstance(node, ast.Expression):
|
"""
|
||||||
continue
|
Evaluate expression into Decimal.
|
||||||
# 运算符节点本身也会出现在 iter_child_nodes 中
|
|
||||||
if isinstance(node, _ALLOWED_OP_NODES):
|
|
||||||
continue
|
|
||||||
if isinstance(node, ast.Constant):
|
|
||||||
# 仅允许数字常量(bool 是 int 子类,需要显式排除)
|
|
||||||
if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
|
|
||||||
raise UnsafeExpressionError("Only int/float constants are allowed")
|
|
||||||
continue
|
|
||||||
if isinstance(node, ast.BinOp):
|
|
||||||
if not isinstance(node.op, _ALLOWED_BINOPS):
|
|
||||||
raise UnsafeExpressionError(f"Operator not allowed: {type(node.op).__name__}")
|
|
||||||
continue
|
|
||||||
if isinstance(node, ast.UnaryOp):
|
|
||||||
if not isinstance(node.op, _ALLOWED_UNARYOPS):
|
|
||||||
raise UnsafeExpressionError(
|
|
||||||
f"Unary operator not allowed: {type(node.op).__name__}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if isinstance(node, ast.Name):
|
|
||||||
# 防御:拒绝双下划线变量名
|
|
||||||
if node.id.startswith("__"):
|
|
||||||
raise UnsafeExpressionError("Dunder names are not allowed")
|
|
||||||
continue
|
|
||||||
if isinstance(node, ast.Load):
|
|
||||||
continue
|
|
||||||
if isinstance(node, ast.keyword):
|
|
||||||
continue
|
|
||||||
if isinstance(node, ast.Call):
|
|
||||||
if not isinstance(node.func, ast.Name):
|
|
||||||
raise UnsafeExpressionError("Only direct function calls are allowed")
|
|
||||||
func_name = node.func.id
|
|
||||||
if func_name not in self.ALLOWED_FUNCS:
|
|
||||||
raise UnsafeExpressionError(f"Function not allowed: {func_name}")
|
|
||||||
if any(k.arg is None for k in node.keywords):
|
|
||||||
raise UnsafeExpressionError("**kwargs is not allowed")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 明确禁止的/不需要的节点类型(属性访问、下标、推导式、比较等)
|
We avoid Python eval() here to ensure:
|
||||||
if isinstance(
|
- float literals don't leak binary float arithmetic
|
||||||
node,
|
- all arithmetic stays within Decimal
|
||||||
(
|
"""
|
||||||
ast.Attribute,
|
|
||||||
ast.Subscript,
|
|
||||||
ast.Compare,
|
|
||||||
ast.BoolOp,
|
|
||||||
ast.IfExp,
|
|
||||||
ast.Lambda,
|
|
||||||
ast.Dict,
|
|
||||||
ast.List,
|
|
||||||
ast.Tuple,
|
|
||||||
ast.Set,
|
|
||||||
ast.ListComp,
|
|
||||||
ast.SetComp,
|
|
||||||
ast.DictComp,
|
|
||||||
ast.GeneratorExp,
|
|
||||||
ast.Await,
|
|
||||||
ast.Yield,
|
|
||||||
ast.YieldFrom,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
raise UnsafeExpressionError(f"AST node not allowed: {type(node).__name__}")
|
|
||||||
|
|
||||||
raise UnsafeExpressionError(f"AST node not allowed: {type(node).__name__}")
|
|
||||||
|
|
||||||
assert isinstance(tree, ast.Expression)
|
|
||||||
return tree
|
|
||||||
|
|
||||||
def eval_number(self, expression: str, variables: dict[str, Any]) -> float:
|
|
||||||
tree = self.validate(expression)
|
tree = self.validate(expression)
|
||||||
|
|
||||||
safe_globals = {"__builtins__": {}}
|
|
||||||
safe_locals = dict(self.ALLOWED_FUNCS)
|
|
||||||
safe_locals.update(variables or {})
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
compiled = compile(tree, "<billing_expr>", "eval")
|
with _decimal_context(DECIMAL_CONTEXT_PRECISION):
|
||||||
value = eval(compiled, safe_globals, safe_locals) # noqa: S307 - validated AST
|
return _eval_decimal(tree.body, variables or {}, self.ALLOWED_FUNCS)
|
||||||
|
except NameError:
|
||||||
|
raise
|
||||||
|
except ExpressionEvaluationError:
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ExpressionEvaluationError(str(exc)) from exc
|
raise ExpressionEvaluationError(str(exc)) from exc
|
||||||
|
|
||||||
|
def eval_number(self, expression: str, variables: dict[str, Any]) -> float:
|
||||||
|
"""Backward-compatible float evaluation (used by DimensionCollector transforms)."""
|
||||||
|
value = self.eval_decimal(expression, variables)
|
||||||
try:
|
try:
|
||||||
return float(value)
|
return float(value)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ExpressionEvaluationError(f"Expression result is not numeric: {value!r}") from exc
|
raise ExpressionEvaluationError(f"Expression result is not numeric: {value!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
class _decimal_context:
|
||||||
|
def __init__(self, prec: int):
|
||||||
|
self.prec = prec
|
||||||
|
|
||||||
|
def __enter__(self) -> None:
|
||||||
|
from decimal import getcontext
|
||||||
|
|
||||||
|
self._ctx = getcontext().copy()
|
||||||
|
getcontext().prec = self.prec
|
||||||
|
|
||||||
|
def __exit__(self, exc_type: type | None, exc: BaseException | None, tb: Any) -> None:
|
||||||
|
from decimal import setcontext
|
||||||
|
|
||||||
|
# Restore full context to avoid leaking settings.
|
||||||
|
setcontext(self._ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def _eval_decimal(node: ast.AST, variables: dict[str, Any], funcs: dict[str, Any]) -> Decimal:
|
||||||
|
if isinstance(node, ast.Constant):
|
||||||
|
return to_decimal(node.value)
|
||||||
|
if isinstance(node, ast.Name):
|
||||||
|
if node.id not in variables:
|
||||||
|
raise NameError(node.id)
|
||||||
|
return to_decimal(variables[node.id])
|
||||||
|
if isinstance(node, ast.UnaryOp):
|
||||||
|
v = _eval_decimal(node.operand, variables, funcs)
|
||||||
|
if isinstance(node.op, ast.UAdd):
|
||||||
|
return v
|
||||||
|
if isinstance(node.op, ast.USub):
|
||||||
|
return -v
|
||||||
|
raise ExpressionEvaluationError(f"Unary operator not allowed: {type(node.op).__name__}")
|
||||||
|
if isinstance(node, ast.BinOp):
|
||||||
|
left = _eval_decimal(node.left, variables, funcs)
|
||||||
|
right = _eval_decimal(node.right, variables, funcs)
|
||||||
|
if isinstance(node.op, ast.Add):
|
||||||
|
return left + right
|
||||||
|
if isinstance(node.op, ast.Sub):
|
||||||
|
return left - right
|
||||||
|
if isinstance(node.op, ast.Mult):
|
||||||
|
return left * right
|
||||||
|
if isinstance(node.op, ast.Div):
|
||||||
|
return left / right
|
||||||
|
if isinstance(node.op, ast.FloorDiv):
|
||||||
|
return left // right
|
||||||
|
if isinstance(node.op, ast.Mod):
|
||||||
|
return left % right
|
||||||
|
if isinstance(node.op, ast.Pow):
|
||||||
|
# Decimal power is only well-defined for integer exponents here.
|
||||||
|
try:
|
||||||
|
exp_int = int(right)
|
||||||
|
if to_decimal(exp_int) != right:
|
||||||
|
raise ValueError("non-integer exponent")
|
||||||
|
except Exception as exc:
|
||||||
|
raise ExpressionEvaluationError("Pow only supports integer exponents") from exc
|
||||||
|
return left**exp_int
|
||||||
|
raise ExpressionEvaluationError(f"Operator not allowed: {type(node.op).__name__}")
|
||||||
|
if isinstance(node, ast.Call):
|
||||||
|
if not isinstance(node.func, ast.Name):
|
||||||
|
raise ExpressionEvaluationError("Only direct function calls are allowed")
|
||||||
|
func_name = node.func.id
|
||||||
|
func = funcs.get(func_name)
|
||||||
|
if func is None:
|
||||||
|
raise ExpressionEvaluationError(f"Function not allowed: {func_name}")
|
||||||
|
args = [_eval_decimal(a, variables, funcs) for a in node.args]
|
||||||
|
kwargs = {
|
||||||
|
kw.arg: _eval_decimal(kw.value, variables, funcs) for kw in node.keywords if kw.arg
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ExpressionEvaluationError(str(exc)) from exc
|
||||||
|
return to_decimal(result)
|
||||||
|
|
||||||
|
raise ExpressionEvaluationError(f"AST node not allowed: {type(node).__name__}")
|
||||||
|
|
||||||
|
|
||||||
class FormulaEngine:
|
class FormulaEngine:
|
||||||
"""计费表达式引擎:解析 dimension_mappings 并进行安全求值。"""
|
"""计费表达式引擎:解析 dimension_mappings 并进行安全求值。"""
|
||||||
|
|
||||||
@@ -205,19 +339,59 @@ class FormulaEngine:
|
|||||||
resolved: dict[str, Any] = dict(variables or {})
|
resolved: dict[str, Any] = dict(variables or {})
|
||||||
|
|
||||||
missing_required: list[str] = []
|
missing_required: list[str] = []
|
||||||
|
tier_index: int | None = None
|
||||||
|
tier_info: dict[str, Any] | None = None
|
||||||
|
|
||||||
# 先解析 dimension_mappings,产出 expression 变量表
|
computed: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
# 1) Resolve non-computed mappings first
|
||||||
for var_name, mapping in mappings.items():
|
for var_name, mapping in mappings.items():
|
||||||
source = (mapping.get("source") or "constant").lower()
|
source = (mapping.get("source") or "constant").lower()
|
||||||
# 显式 constant 映射属于“兜底行为”:如果 variables 已经提供该变量,则不覆盖。
|
if source == "computed":
|
||||||
|
computed[var_name] = mapping
|
||||||
|
continue
|
||||||
|
# Explicit constant mapping is fallback-only when variable already exists.
|
||||||
if source == "constant" and var_name in resolved:
|
if source == "constant" and var_name in resolved:
|
||||||
continue
|
continue
|
||||||
value, is_missing = self._resolve_mapping(var_name, mapping, dims)
|
value, is_missing, tier_meta = self._resolve_mapping(var_name, mapping, dims)
|
||||||
|
if tier_meta and tier_index is None:
|
||||||
|
tier_index = tier_meta.get("tier_index")
|
||||||
|
tier_info = tier_meta.get("tier_info")
|
||||||
if is_missing:
|
if is_missing:
|
||||||
missing_required.append(var_name)
|
missing_required.append(var_name)
|
||||||
continue
|
continue
|
||||||
resolved[var_name] = value
|
resolved[var_name] = value
|
||||||
|
|
||||||
|
# 2) Resolve computed mappings (iterative dependency resolution)
|
||||||
|
if computed:
|
||||||
|
unresolved = dict(computed)
|
||||||
|
for _ in range(max(4, len(unresolved) + 1)):
|
||||||
|
progressed = False
|
||||||
|
for var_name, mapping in list(unresolved.items()):
|
||||||
|
if var_name in resolved:
|
||||||
|
unresolved.pop(var_name, None)
|
||||||
|
continue
|
||||||
|
value, status = self._try_resolve_computed(var_name, mapping, dims, resolved)
|
||||||
|
if status == "pending":
|
||||||
|
continue
|
||||||
|
unresolved.pop(var_name, None)
|
||||||
|
if status == "missing_required":
|
||||||
|
missing_required.append(var_name)
|
||||||
|
continue
|
||||||
|
resolved[var_name] = value
|
||||||
|
progressed = True
|
||||||
|
if not progressed:
|
||||||
|
break
|
||||||
|
|
||||||
|
# any remaining unresolved computed vars
|
||||||
|
for var_name, mapping in unresolved.items():
|
||||||
|
required = bool(mapping.get("required", False))
|
||||||
|
default = mapping.get("default", 0)
|
||||||
|
if required:
|
||||||
|
missing_required.append(var_name)
|
||||||
|
else:
|
||||||
|
resolved[var_name] = default
|
||||||
|
|
||||||
# required 维度缺失:直接标记 incomplete(并由 strict_mode 决定是否抛错)
|
# required 维度缺失:直接标记 incomplete(并由 strict_mode 决定是否抛错)
|
||||||
if missing_required:
|
if missing_required:
|
||||||
if strict_mode:
|
if strict_mode:
|
||||||
@@ -227,45 +401,120 @@ class FormulaEngine:
|
|||||||
)
|
)
|
||||||
return FormulaEvaluationResult(
|
return FormulaEvaluationResult(
|
||||||
status="incomplete",
|
status="incomplete",
|
||||||
cost=0.0,
|
cost=Decimal("0"),
|
||||||
resolved_values=resolved,
|
resolved_dimensions=dims,
|
||||||
|
resolved_variables=resolved,
|
||||||
missing_required=missing_required,
|
missing_required=missing_required,
|
||||||
|
tier_index=tier_index,
|
||||||
|
tier_info=tier_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 3) Evaluate total cost
|
||||||
try:
|
try:
|
||||||
cost = self._evaluator.eval_number(expression, resolved)
|
cost = self._evaluator.eval_decimal(expression, resolved)
|
||||||
if cost < 0:
|
if cost < 0:
|
||||||
# 防御:不允许负数成本(通常表示配置错误)
|
|
||||||
return FormulaEvaluationResult(
|
return FormulaEvaluationResult(
|
||||||
status="incomplete",
|
status="incomplete",
|
||||||
cost=0.0,
|
cost=Decimal("0"),
|
||||||
resolved_values=resolved,
|
resolved_dimensions=dims,
|
||||||
|
resolved_variables=resolved,
|
||||||
missing_required=[],
|
missing_required=[],
|
||||||
|
tier_index=tier_index,
|
||||||
|
tier_info=tier_info,
|
||||||
error="negative_cost",
|
error="negative_cost",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
breakdown = self._extract_cost_breakdown(resolved)
|
||||||
return FormulaEvaluationResult(
|
return FormulaEvaluationResult(
|
||||||
status="complete",
|
status="complete",
|
||||||
cost=cost,
|
cost=cost,
|
||||||
resolved_values=resolved,
|
resolved_dimensions=dims,
|
||||||
|
resolved_variables=resolved,
|
||||||
|
cost_breakdown=breakdown,
|
||||||
|
tier_index=tier_index,
|
||||||
|
tier_info=tier_info,
|
||||||
missing_required=[],
|
missing_required=[],
|
||||||
)
|
)
|
||||||
|
except NameError as exc:
|
||||||
|
# expression references missing vars
|
||||||
|
if strict_mode:
|
||||||
|
raise ExpressionEvaluationError(f"Missing variable: {exc}") from exc
|
||||||
|
return FormulaEvaluationResult(
|
||||||
|
status="incomplete",
|
||||||
|
cost=Decimal("0"),
|
||||||
|
resolved_dimensions=dims,
|
||||||
|
resolved_variables=resolved,
|
||||||
|
missing_required=[],
|
||||||
|
tier_index=tier_index,
|
||||||
|
tier_info=tier_info,
|
||||||
|
error=f"missing_variable:{exc}",
|
||||||
|
)
|
||||||
except (UnsafeExpressionError, ExpressionEvaluationError) as exc:
|
except (UnsafeExpressionError, ExpressionEvaluationError) as exc:
|
||||||
if strict_mode:
|
if strict_mode:
|
||||||
raise
|
raise
|
||||||
return FormulaEvaluationResult(
|
return FormulaEvaluationResult(
|
||||||
status="incomplete",
|
status="incomplete",
|
||||||
cost=0.0,
|
cost=Decimal("0"),
|
||||||
resolved_values=resolved,
|
resolved_dimensions=dims,
|
||||||
|
resolved_variables=resolved,
|
||||||
missing_required=[],
|
missing_required=[],
|
||||||
|
tier_index=tier_index,
|
||||||
|
tier_info=tier_info,
|
||||||
error=str(exc),
|
error=str(exc),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _extract_cost_breakdown(self, resolved: dict[str, Any]) -> dict[str, Decimal]:
|
||||||
|
breakdown: dict[str, Decimal] = {}
|
||||||
|
for k, v in resolved.items():
|
||||||
|
if not k.endswith("_cost"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
breakdown[k] = to_decimal(v)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return breakdown
|
||||||
|
|
||||||
|
def _try_resolve_computed(
|
||||||
|
self,
|
||||||
|
var_name: str,
|
||||||
|
mapping: dict[str, Any],
|
||||||
|
dims: dict[str, Any],
|
||||||
|
resolved: dict[str, Any],
|
||||||
|
) -> tuple[Any, Literal["ok", "pending", "missing_required"]]:
|
||||||
|
"""
|
||||||
|
Try resolve a computed mapping.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(value, status)
|
||||||
|
- ok: value computed
|
||||||
|
- pending: missing dependencies, retry later
|
||||||
|
- missing_required: required=true and cannot resolve
|
||||||
|
"""
|
||||||
|
required = bool(mapping.get("required", False))
|
||||||
|
default = mapping.get("default", 0)
|
||||||
|
expr = mapping.get("expression") or mapping.get("transform_expression")
|
||||||
|
if not expr:
|
||||||
|
return (None, "missing_required") if required else (default, "ok")
|
||||||
|
# Computed vars can reference both resolved variables and raw dims.
|
||||||
|
env: dict[str, Any] = {}
|
||||||
|
env.update(dims)
|
||||||
|
env.update(resolved)
|
||||||
|
try:
|
||||||
|
value = self._evaluator.eval_decimal(str(expr), env)
|
||||||
|
return value, "ok"
|
||||||
|
except NameError:
|
||||||
|
# dependency not ready yet
|
||||||
|
return (None, "pending") if required else (default, "pending")
|
||||||
|
except Exception:
|
||||||
|
# treat as config error: fallback to default unless required
|
||||||
|
return (None, "missing_required") if required else (default, "ok")
|
||||||
|
|
||||||
def _resolve_mapping(
|
def _resolve_mapping(
|
||||||
self,
|
self,
|
||||||
var_name: str,
|
var_name: str,
|
||||||
mapping: dict[str, Any],
|
mapping: dict[str, Any],
|
||||||
dims: dict[str, Any],
|
dims: dict[str, Any],
|
||||||
) -> tuple[Any, bool]:
|
) -> tuple[Any, bool, dict[str, Any] | None]:
|
||||||
"""
|
"""
|
||||||
Returns:
|
Returns:
|
||||||
(value, is_missing_required)
|
(value, is_missing_required)
|
||||||
@@ -287,82 +536,185 @@ class FormulaEngine:
|
|||||||
|
|
||||||
if source == "constant":
|
if source == "constant":
|
||||||
# constant 默认行为:由 variables 提供;dimension_mappings 显式 constant 时仅做兜底
|
# constant 默认行为:由 variables 提供;dimension_mappings 显式 constant 时仅做兜底
|
||||||
return default, False
|
return default, False, None
|
||||||
|
|
||||||
if source == "dimension":
|
if source == "dimension":
|
||||||
key = mapping.get("key") or var_name
|
key = mapping.get("key") or var_name
|
||||||
raw = dims.get(key)
|
raw = dims.get(key)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return _missing()
|
v, m = _missing()
|
||||||
|
return v, m, None
|
||||||
if isinstance(raw, str):
|
if isinstance(raw, str):
|
||||||
if raw == "":
|
if raw == "":
|
||||||
return _missing()
|
v, m = _missing()
|
||||||
|
return v, m, None
|
||||||
# 尝试将字符串解析为数字,否则按字符串返回(供上层自行决定)
|
# 尝试将字符串解析为数字,否则按字符串返回(供上层自行决定)
|
||||||
try:
|
try:
|
||||||
num = float(raw)
|
num = to_decimal(raw)
|
||||||
if num == 0 and not allow_zero:
|
if num == 0 and not allow_zero:
|
||||||
return _missing()
|
v, m = _missing()
|
||||||
return num, False
|
return v, m, None
|
||||||
|
return num, False, None
|
||||||
except Exception:
|
except Exception:
|
||||||
return raw, False
|
return raw, False, None
|
||||||
if isinstance(raw, (int, float)):
|
if isinstance(raw, (int, float, Decimal)):
|
||||||
if float(raw) == 0 and not allow_zero:
|
num = to_decimal(raw)
|
||||||
return _missing()
|
if num == 0 and not allow_zero:
|
||||||
return raw, False
|
v, m = _missing()
|
||||||
|
return v, m, None
|
||||||
|
return num, False, None
|
||||||
# 其他类型:尽量转为 float,否则视为缺失
|
# 其他类型:尽量转为 float,否则视为缺失
|
||||||
try:
|
try:
|
||||||
num = float(raw)
|
num = to_decimal(raw)
|
||||||
if num == 0 and not allow_zero:
|
if num == 0 and not allow_zero:
|
||||||
return _missing()
|
v, m = _missing()
|
||||||
return num, False
|
return v, m, None
|
||||||
|
return num, False, None
|
||||||
except Exception:
|
except Exception:
|
||||||
return _missing()
|
v, m = _missing()
|
||||||
|
return v, m, None
|
||||||
|
|
||||||
if source == "matrix":
|
if source == "matrix":
|
||||||
key = mapping.get("key") or var_name
|
key = mapping.get("key") or var_name
|
||||||
raw = dims.get(key)
|
raw = dims.get(key)
|
||||||
if raw is None or raw == "":
|
if raw is None or raw == "":
|
||||||
return _missing()
|
v, m = _missing()
|
||||||
|
return v, m, None
|
||||||
raw_key = str(raw)
|
raw_key = str(raw)
|
||||||
matrix = mapping.get("map") or {}
|
matrix = mapping.get("map") or {}
|
||||||
if raw_key in matrix:
|
if raw_key in matrix:
|
||||||
return matrix[raw_key], False
|
try:
|
||||||
|
return to_decimal(matrix[raw_key]), False, None
|
||||||
|
except Exception:
|
||||||
|
return matrix[raw_key], False, None
|
||||||
# matrix 未命中:若 required=true 则仍视为缺失;否则使用 default
|
# matrix 未命中:若 required=true 则仍视为缺失;否则使用 default
|
||||||
if required:
|
if required:
|
||||||
return None, True
|
return None, True, None
|
||||||
return default, False
|
return default, False, None
|
||||||
|
|
||||||
if source == "tiered":
|
if source == "tiered":
|
||||||
tier_key = mapping.get("tier_key")
|
tier_key = mapping.get("tier_key")
|
||||||
if not tier_key:
|
if not tier_key:
|
||||||
return _missing()
|
v, m = _missing()
|
||||||
|
return v, m, None
|
||||||
raw_tier_value = dims.get(tier_key)
|
raw_tier_value = dims.get(tier_key)
|
||||||
if raw_tier_value is None:
|
if raw_tier_value is None:
|
||||||
return _missing()
|
v, m = _missing()
|
||||||
|
return v, m, None
|
||||||
try:
|
try:
|
||||||
tier_value = float(raw_tier_value)
|
tier_value = to_decimal(raw_tier_value)
|
||||||
except Exception:
|
except Exception:
|
||||||
return _missing()
|
v, m = _missing()
|
||||||
|
return v, m, None
|
||||||
|
|
||||||
if tier_value == 0 and not allow_zero:
|
if tier_value == 0 and not allow_zero:
|
||||||
return _missing()
|
v, m = _missing()
|
||||||
|
return v, m, None
|
||||||
|
|
||||||
|
# Optional TTL override (legacy: Claude cache pricing)
|
||||||
|
ttl_key = mapping.get("ttl_key")
|
||||||
|
ttl_value_key = mapping.get("ttl_value_key")
|
||||||
|
ttl_minutes: Decimal | None = None
|
||||||
|
if ttl_key and ttl_value_key and dims.get(ttl_key) is not None:
|
||||||
|
try:
|
||||||
|
ttl_minutes = to_decimal(dims.get(ttl_key))
|
||||||
|
except Exception:
|
||||||
|
ttl_minutes = None
|
||||||
|
|
||||||
tiers = mapping.get("tiers") or []
|
tiers = mapping.get("tiers") or []
|
||||||
# tiers: [{up_to: 128000, value: 2.5}, {up_to: null, value: 1.25}]
|
# tiers: [{up_to: 128000, value: 2.5}, {up_to: null, value: 1.25}]
|
||||||
for tier in tiers:
|
for idx, tier in enumerate(tiers):
|
||||||
up_to = tier.get("up_to")
|
up_to = tier.get("up_to")
|
||||||
if up_to is None:
|
if up_to is None:
|
||||||
return tier.get("value", default), False
|
value = to_decimal(tier.get("value", default))
|
||||||
|
if (
|
||||||
|
ttl_minutes is not None
|
||||||
|
and ttl_value_key
|
||||||
|
and isinstance(tier.get("cache_ttl_pricing"), list)
|
||||||
|
):
|
||||||
|
value = self._resolve_ttl_pricing(
|
||||||
|
tier.get("cache_ttl_pricing") or [],
|
||||||
|
ttl_minutes,
|
||||||
|
str(ttl_value_key),
|
||||||
|
fallback=value,
|
||||||
|
)
|
||||||
|
return value, False, {"tier_index": idx, "tier_info": dict(tier)}
|
||||||
try:
|
try:
|
||||||
if tier_value <= float(up_to):
|
if tier_value <= to_decimal(up_to):
|
||||||
return tier.get("value", default), False
|
value = to_decimal(tier.get("value", default))
|
||||||
|
if (
|
||||||
|
ttl_minutes is not None
|
||||||
|
and ttl_value_key
|
||||||
|
and isinstance(tier.get("cache_ttl_pricing"), list)
|
||||||
|
):
|
||||||
|
value = self._resolve_ttl_pricing(
|
||||||
|
tier.get("cache_ttl_pricing") or [],
|
||||||
|
ttl_minutes,
|
||||||
|
str(ttl_value_key),
|
||||||
|
fallback=value,
|
||||||
|
)
|
||||||
|
return value, False, {"tier_index": idx, "tier_info": dict(tier)}
|
||||||
except Exception:
|
except Exception:
|
||||||
# up_to 配置异常:忽略并继续
|
# up_to 配置异常:忽略并继续
|
||||||
continue
|
continue
|
||||||
# 无匹配:使用最后一个或 default
|
# 无匹配:使用最后一个或 default
|
||||||
if tiers:
|
if tiers:
|
||||||
return tiers[-1].get("value", default), False
|
last = tiers[-1]
|
||||||
return default, False
|
value = to_decimal(last.get("value", default))
|
||||||
|
if (
|
||||||
|
ttl_minutes is not None
|
||||||
|
and ttl_value_key
|
||||||
|
and isinstance(last.get("cache_ttl_pricing"), list)
|
||||||
|
):
|
||||||
|
value = self._resolve_ttl_pricing(
|
||||||
|
last.get("cache_ttl_pricing") or [],
|
||||||
|
ttl_minutes,
|
||||||
|
str(ttl_value_key),
|
||||||
|
fallback=value,
|
||||||
|
)
|
||||||
|
return value, False, {"tier_index": len(tiers) - 1, "tier_info": dict(last)}
|
||||||
|
return default, False, None
|
||||||
|
|
||||||
# 未知 source:视为配置错误,但不直接中断计费(返回 default)
|
# 未知 source:视为配置错误,但不直接中断计费(返回 default)
|
||||||
return default, False
|
return default, False, None
|
||||||
|
|
||||||
|
def _resolve_ttl_pricing(
|
||||||
|
self,
|
||||||
|
ttl_pricing: list[Any],
|
||||||
|
ttl_minutes: Decimal,
|
||||||
|
ttl_value_key: str,
|
||||||
|
*,
|
||||||
|
fallback: Decimal,
|
||||||
|
) -> Decimal:
|
||||||
|
"""
|
||||||
|
Resolve TTL-dependent pricing (legacy: cache_ttl_pricing).
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- pick the first entry whose ttl_minutes >= requested ttl
|
||||||
|
- otherwise pick the last entry
|
||||||
|
- if missing/invalid, fallback to base value
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
entries = [e for e in ttl_pricing if isinstance(e, dict)]
|
||||||
|
if not entries:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
def _ttl_key(e: dict[str, Any]) -> Decimal:
|
||||||
|
return to_decimal(e.get("ttl_minutes") or 0)
|
||||||
|
|
||||||
|
entries_sorted = sorted(entries, key=_ttl_key)
|
||||||
|
chosen: dict[str, Any] = entries_sorted[-1]
|
||||||
|
for e in entries_sorted:
|
||||||
|
try:
|
||||||
|
if ttl_minutes <= to_decimal(e.get("ttl_minutes") or 0):
|
||||||
|
chosen = e
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
v = chosen.get(ttl_value_key)
|
||||||
|
if v is None:
|
||||||
|
return fallback
|
||||||
|
return to_decimal(v)
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|||||||
49
src/services/billing/precision.py
Normal file
49
src/services/billing/precision.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""
|
||||||
|
Precision helpers for billing calculations.
|
||||||
|
|
||||||
|
We standardize money arithmetic with `Decimal` and quantize to stable precisions.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `Decimal` context precision (`DECIMAL_CONTEXT_PRECISION`) is **significant digits**,
|
||||||
|
not "decimal places".
|
||||||
|
- We keep these as constants (not runtime-configurable) to avoid drift between
|
||||||
|
environments during billing reconciliation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import ROUND_HALF_UP, Decimal, localcontext
|
||||||
|
|
||||||
|
# Decimal context precision (significant digits)
|
||||||
|
DECIMAL_CONTEXT_PRECISION = 28
|
||||||
|
|
||||||
|
# Money precisions
|
||||||
|
BILLING_STORAGE_PRECISION = 8 # persisted to DB / metadata
|
||||||
|
BILLING_DISPLAY_PRECISION = 6 # UI display
|
||||||
|
|
||||||
|
|
||||||
|
def to_decimal(value: float | int | str | Decimal | None) -> Decimal:
|
||||||
|
"""Convert values to Decimal safely (float via str to avoid binary artifacts)."""
|
||||||
|
if value is None:
|
||||||
|
return Decimal("0")
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return value
|
||||||
|
return Decimal(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def quantize_decimal(value: Decimal, *, precision: int) -> Decimal:
|
||||||
|
"""Quantize a Decimal to the given number of decimal places (ROUND_HALF_UP)."""
|
||||||
|
quantizer = Decimal(10) ** -precision
|
||||||
|
with localcontext() as ctx:
|
||||||
|
ctx.prec = DECIMAL_CONTEXT_PRECISION
|
||||||
|
return value.quantize(quantizer, rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
|
||||||
|
def quantize_cost(value: Decimal) -> Decimal:
|
||||||
|
"""Quantize to storage precision."""
|
||||||
|
return quantize_decimal(value, precision=BILLING_STORAGE_PRECISION)
|
||||||
|
|
||||||
|
|
||||||
|
def quantize_display(value: Decimal) -> Decimal:
|
||||||
|
"""Quantize to display precision."""
|
||||||
|
return quantize_decimal(value, precision=BILLING_DISPLAY_PRECISION)
|
||||||
254
src/services/billing/presets.py
Normal file
254
src/services/billing/presets.py
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
"""
|
||||||
|
Billing presets (developer-provided defaults).
|
||||||
|
|
||||||
|
Why:
|
||||||
|
- Asking end-users to configure DimensionCollectors / BillingRules from scratch is too complex.
|
||||||
|
- We ship a curated set of "known-good" collector presets per api_format/task_type
|
||||||
|
and provide an Admin API to apply them into DB (merge or overwrite).
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- BillingRule presets are intentionally NOT materialized here, because the unified
|
||||||
|
billing architecture already provides a runtime default rule generator that stays
|
||||||
|
in-sync with Model/GlobalModel pricing. Persisting those prices into BillingRule
|
||||||
|
rows would become stale when model pricing changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import pkgutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.core.api_format.signature import normalize_signature_key
|
||||||
|
from src.models.database import DimensionCollector
|
||||||
|
|
||||||
|
PresetApplyMode = Literal["merge", "overwrite"]
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_api(api_format: str) -> str:
|
||||||
|
return normalize_signature_key(api_format or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_task(task_type: str) -> str:
|
||||||
|
return (task_type or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CollectorPreset:
|
||||||
|
api_format: str
|
||||||
|
task_type: str
|
||||||
|
dimension_name: str
|
||||||
|
|
||||||
|
source_type: str
|
||||||
|
source_path: str | None = None
|
||||||
|
|
||||||
|
value_type: str = "float" # float/int/string
|
||||||
|
transform_expression: str | None = None
|
||||||
|
default_value: str | None = None
|
||||||
|
|
||||||
|
priority: int = 0
|
||||||
|
is_enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PresetPack:
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
description: str
|
||||||
|
collectors: list[CollectorPreset]
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_collectors() -> list[CollectorPreset]:
|
||||||
|
"""
|
||||||
|
Config-file mode: discover collectors from `src.services.billing.collector_defs`.
|
||||||
|
|
||||||
|
Developers add a new file under that package; no central registry edits required.
|
||||||
|
"""
|
||||||
|
out: list[CollectorPreset] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
pkg = importlib.import_module("src.services.billing.collector_defs")
|
||||||
|
pkg_path = getattr(pkg, "__path__", None)
|
||||||
|
if not pkg_path:
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
return out
|
||||||
|
|
||||||
|
for mod in pkgutil.iter_modules(pkg_path):
|
||||||
|
if mod.ispkg:
|
||||||
|
continue
|
||||||
|
mod_name = f"src.services.billing.collector_defs.{mod.name}"
|
||||||
|
try:
|
||||||
|
m = importlib.import_module(mod_name)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
items = getattr(m, "COLLECTORS", None)
|
||||||
|
if not isinstance(items, list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for raw in items:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
out.append(
|
||||||
|
CollectorPreset(
|
||||||
|
api_format=str(raw.get("api_format") or "").strip(),
|
||||||
|
task_type=str(raw.get("task_type") or "").strip().lower(),
|
||||||
|
dimension_name=str(raw.get("dimension_name") or "").strip(),
|
||||||
|
source_type=str(raw.get("source_type") or "").strip().lower(),
|
||||||
|
source_path=raw.get("source_path"),
|
||||||
|
value_type=str(raw.get("value_type") or "float").strip().lower(),
|
||||||
|
transform_expression=raw.get("transform_expression"),
|
||||||
|
default_value=raw.get("default_value"),
|
||||||
|
priority=int(raw.get("priority") or 0),
|
||||||
|
is_enabled=bool(raw.get("is_enabled", True)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
CORE_PRESET_PACK = PresetPack(
|
||||||
|
name="aether-core",
|
||||||
|
version="1.0",
|
||||||
|
description="Aether built-in dimension collectors for common api_formats/task_types.",
|
||||||
|
collectors=_discover_collectors(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_preset_packs() -> list[PresetPack]:
|
||||||
|
return [CORE_PRESET_PACK]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PresetApplyResult:
|
||||||
|
preset: str
|
||||||
|
mode: PresetApplyMode
|
||||||
|
created: int
|
||||||
|
updated: int
|
||||||
|
skipped: int
|
||||||
|
errors: list[str]
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"preset": self.preset,
|
||||||
|
"mode": self.mode,
|
||||||
|
"created": self.created,
|
||||||
|
"updated": self.updated,
|
||||||
|
"skipped": self.skipped,
|
||||||
|
"errors": list(self.errors),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BillingPresetService:
|
||||||
|
@staticmethod
|
||||||
|
def apply_preset(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
preset_name: str,
|
||||||
|
mode: PresetApplyMode = "merge",
|
||||||
|
) -> PresetApplyResult:
|
||||||
|
preset_name = (preset_name or "").strip()
|
||||||
|
packs = {p.name: p for p in list_preset_packs()}
|
||||||
|
pack = packs.get(preset_name)
|
||||||
|
if pack is None:
|
||||||
|
available = ", ".join(sorted(packs.keys()))
|
||||||
|
return PresetApplyResult(
|
||||||
|
preset=preset_name,
|
||||||
|
mode=mode,
|
||||||
|
created=0,
|
||||||
|
updated=0,
|
||||||
|
skipped=0,
|
||||||
|
errors=[f"Unknown preset: {preset_name!r}. Available: {available}"],
|
||||||
|
)
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for item in pack.collectors:
|
||||||
|
api_format = _norm_api(item.api_format)
|
||||||
|
task_type = _norm_task(item.task_type)
|
||||||
|
dim = (item.dimension_name or "").strip()
|
||||||
|
|
||||||
|
if not api_format or not task_type or not dim:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing = (
|
||||||
|
db.query(DimensionCollector)
|
||||||
|
.filter(
|
||||||
|
DimensionCollector.api_format == api_format,
|
||||||
|
DimensionCollector.task_type == task_type,
|
||||||
|
DimensionCollector.dimension_name == dim,
|
||||||
|
DimensionCollector.priority == int(item.priority or 0),
|
||||||
|
DimensionCollector.is_enabled == True, # noqa: E712
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(
|
||||||
|
f"Failed to query collector: api_format={api_format} task_type={task_type} dim={dim}: {exc}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if existing is not None:
|
||||||
|
if mode == "overwrite":
|
||||||
|
try:
|
||||||
|
existing.source_type = (item.source_type or "").strip().lower()
|
||||||
|
existing.source_path = item.source_path
|
||||||
|
existing.value_type = (item.value_type or "float").strip().lower()
|
||||||
|
existing.transform_expression = item.transform_expression
|
||||||
|
existing.default_value = item.default_value
|
||||||
|
existing.is_enabled = bool(item.is_enabled)
|
||||||
|
updated += 1
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(
|
||||||
|
f"Failed to update collector {getattr(existing, 'id', None)}: {exc}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
c = DimensionCollector(
|
||||||
|
api_format=api_format,
|
||||||
|
task_type=task_type,
|
||||||
|
dimension_name=dim,
|
||||||
|
source_type=(item.source_type or "").strip().lower(),
|
||||||
|
source_path=item.source_path,
|
||||||
|
value_type=(item.value_type or "float").strip().lower(),
|
||||||
|
transform_expression=item.transform_expression,
|
||||||
|
default_value=item.default_value,
|
||||||
|
priority=int(item.priority or 0),
|
||||||
|
is_enabled=bool(item.is_enabled),
|
||||||
|
)
|
||||||
|
db.add(c)
|
||||||
|
created += 1
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(
|
||||||
|
f"Failed to create collector: api_format={api_format} task_type={task_type} dim={dim}: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback()
|
||||||
|
errors.append(f"DB commit failed: {exc}")
|
||||||
|
|
||||||
|
return PresetApplyResult(
|
||||||
|
preset=pack.name,
|
||||||
|
mode=mode,
|
||||||
|
created=created,
|
||||||
|
updated=updated,
|
||||||
|
skipped=skipped,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
10
src/services/billing/rule_defs/__init__.py
Normal file
10
src/services/billing/rule_defs/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
Billing rule definitions (config-file mode).
|
||||||
|
|
||||||
|
Each module should export:
|
||||||
|
- TEMPLATES: list[CodeBillingRuleTemplate]
|
||||||
|
|
||||||
|
Design goal:
|
||||||
|
- Add a new billing mode by adding a new file here.
|
||||||
|
- No central registry edits required.
|
||||||
|
"""
|
||||||
195
src/services/billing/rule_defs/universal.py
Normal file
195
src/services/billing/rule_defs/universal.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""
|
||||||
|
Universal billing template.
|
||||||
|
|
||||||
|
This is the single unified billing template for all task types.
|
||||||
|
Formula: total = (input_cost + output_cost + cache_creation_cost + cache_read_cost) + request_cost + video_cost
|
||||||
|
|
||||||
|
Each component can be 0 if not applicable for the specific task type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from src.services.billing.default_rules import DefaultBillingRuleGenerator, VirtualBillingRule
|
||||||
|
from src.services.billing.rule_templates import CodeBillingRuleTemplate, RuleTemplateContext
|
||||||
|
|
||||||
|
|
||||||
|
def _get_nested(obj: object | None, path: str) -> object | None:
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
return None
|
||||||
|
cur: object = obj
|
||||||
|
for part in (path or "").split("."):
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
if not isinstance(cur, dict):
|
||||||
|
return None
|
||||||
|
cur = cur.get(part) # type: ignore[assignment]
|
||||||
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
def _as_float(v: object | None) -> float | None:
|
||||||
|
try:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return None
|
||||||
|
return float(v)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_WXH_PATTERN = re.compile(r"^(\d+)x(\d+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_resolution_key(raw: str) -> str:
|
||||||
|
"""
|
||||||
|
Normalize resolution key:
|
||||||
|
- lowercase, remove spaces, × → x
|
||||||
|
- For WxH format, sort dimensions so smaller comes first (1080x720 → 720x1080)
|
||||||
|
"""
|
||||||
|
k = (raw or "").strip().lower().replace(" ", "").replace("×", "x")
|
||||||
|
match = _WXH_PATTERN.match(k)
|
||||||
|
if match:
|
||||||
|
a, b = int(match.group(1)), int(match.group(2))
|
||||||
|
k = f"{a}x{b}" if a <= b else f"{b}x{a}"
|
||||||
|
return k
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_unit_price(ctx: RuleTemplateContext) -> float:
|
||||||
|
"""Get video price per second from config."""
|
||||||
|
if ctx.model is not None:
|
||||||
|
v = _as_float(
|
||||||
|
_get_nested(getattr(ctx.model, "config", None), "billing.video.price_per_second")
|
||||||
|
)
|
||||||
|
if v is not None:
|
||||||
|
return v
|
||||||
|
v = _as_float(
|
||||||
|
_get_nested(getattr(ctx.global_model, "config", None), "billing.video.price_per_second")
|
||||||
|
)
|
||||||
|
if v is not None:
|
||||||
|
return v
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_resolution_price_per_second(ctx: RuleTemplateContext) -> dict[str, float]:
|
||||||
|
"""
|
||||||
|
Resolution (or size) -> price_per_second.
|
||||||
|
"""
|
||||||
|
for conf in (
|
||||||
|
getattr(ctx.model, "config", None) if ctx.model is not None else None,
|
||||||
|
getattr(ctx.global_model, "config", None),
|
||||||
|
):
|
||||||
|
raw = _get_nested(conf, "billing.video.price_per_second_by_resolution")
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
out: dict[str, float] = {}
|
||||||
|
for k, v in raw.items():
|
||||||
|
fk = _normalize_resolution_key(str(k))
|
||||||
|
fv = _as_float(v)
|
||||||
|
if not fk:
|
||||||
|
continue
|
||||||
|
if fv is None:
|
||||||
|
continue
|
||||||
|
out[fk] = fv
|
||||||
|
if out:
|
||||||
|
return out
|
||||||
|
|
||||||
|
# Backward-compat: resolution multipliers
|
||||||
|
base = _effective_unit_price(ctx)
|
||||||
|
if base and base > 0:
|
||||||
|
for conf in (
|
||||||
|
getattr(ctx.model, "config", None) if ctx.model is not None else None,
|
||||||
|
getattr(ctx.global_model, "config", None),
|
||||||
|
):
|
||||||
|
raw = _get_nested(conf, "billing.video.resolution_multipliers")
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
out2: dict[str, float] = {}
|
||||||
|
for k, v in raw.items():
|
||||||
|
fk = _normalize_resolution_key(str(k))
|
||||||
|
mv = _as_float(v)
|
||||||
|
if not fk:
|
||||||
|
continue
|
||||||
|
if mv is None:
|
||||||
|
continue
|
||||||
|
out2[fk] = float(base) * float(mv)
|
||||||
|
if out2:
|
||||||
|
return out2
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def build_universal(ctx: RuleTemplateContext) -> VirtualBillingRule:
|
||||||
|
"""
|
||||||
|
Build the universal billing rule.
|
||||||
|
|
||||||
|
Formula:
|
||||||
|
total = (input_cost + output_cost + cache_creation_cost + cache_read_cost) + request_cost + video_cost
|
||||||
|
|
||||||
|
Each component defaults to 0 if not configured or not applicable.
|
||||||
|
"""
|
||||||
|
# Base rule: token + per-request
|
||||||
|
base = DefaultBillingRuleGenerator.generate_for_model(
|
||||||
|
global_model=ctx.global_model,
|
||||||
|
model=ctx.model,
|
||||||
|
task_type=ctx.task_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
unit_price = _effective_unit_price(ctx)
|
||||||
|
resolution_price_map = _effective_resolution_price_per_second(ctx)
|
||||||
|
|
||||||
|
variables = dict(base.variables or {})
|
||||||
|
|
||||||
|
dimension_mappings = dict(base.dimension_mappings or {})
|
||||||
|
|
||||||
|
# Video duration dimension
|
||||||
|
dimension_mappings["duration_seconds"] = {
|
||||||
|
"source": "dimension",
|
||||||
|
"key": "duration_seconds",
|
||||||
|
"required": False,
|
||||||
|
"allow_zero": True,
|
||||||
|
"default": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Video price per second (resolved from resolution map or fallback to unit price)
|
||||||
|
dimension_mappings["video_price_per_second"] = {
|
||||||
|
"source": "matrix",
|
||||||
|
"key": "video_resolution_key",
|
||||||
|
"required": False,
|
||||||
|
"default": unit_price,
|
||||||
|
"map": resolution_price_map,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Video cost component
|
||||||
|
dimension_mappings["video_cost"] = {
|
||||||
|
"source": "computed",
|
||||||
|
"required": False,
|
||||||
|
"default": 0,
|
||||||
|
"expression": "duration_seconds * video_price_per_second",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Universal formula: token costs + request cost + video cost
|
||||||
|
# base.expression = "input_cost + output_cost + cache_creation_cost + cache_read_cost + request_cost"
|
||||||
|
expression = f"({base.expression}) + video_cost"
|
||||||
|
|
||||||
|
return VirtualBillingRule(
|
||||||
|
id="__default__",
|
||||||
|
name="Universal Billing Rule",
|
||||||
|
task_type=ctx.task_type,
|
||||||
|
expression=expression,
|
||||||
|
variables=variables,
|
||||||
|
dimension_mappings=dimension_mappings,
|
||||||
|
is_virtual=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
CodeBillingRuleTemplate(
|
||||||
|
name="universal",
|
||||||
|
description="Universal billing: (input + output + cache) + request + video. All components default to 0 if not applicable.",
|
||||||
|
task_types={"chat", "cli", "video", "image", "audio"},
|
||||||
|
priority=100, # Highest priority - used for all task types
|
||||||
|
build=build_universal,
|
||||||
|
)
|
||||||
|
]
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
BillingRule 查找逻辑
|
BillingRule 查找逻辑
|
||||||
|
|
||||||
查找顺序(与 .plans/humming-seeking-marble.md 一致):
|
查找顺序(与 .plans/humming-seeking-marble.md 一致):
|
||||||
1) Model(Provider 级)→ 2) GlobalModel(默认)
|
1) 读取 GlobalModel/Model 价格配置 → 2) 使用代码内置计费模板生成规则(config-file mode)
|
||||||
|
|
||||||
注意:
|
注意:
|
||||||
- CLI 在计费域等同于 chat:billing_rules.task_type 不含 "cli"
|
- CLI 在计费域等同于 chat:billing_rules.task_type 不含 "cli"
|
||||||
@@ -11,13 +11,26 @@ BillingRule 查找逻辑
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import Any, Literal, Protocol
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from src.models.database import BillingRule, GlobalModel, Model
|
from src.config.settings import config
|
||||||
|
from src.models.database import GlobalModel, Model
|
||||||
|
from src.services.billing.cache import BillingCache
|
||||||
|
from src.services.billing.default_rules import DefaultBillingRuleGenerator, VirtualBillingRule
|
||||||
|
from src.services.billing.rule_templates import CodeBillingRuleTemplateService
|
||||||
|
|
||||||
TaskType = Literal["chat", "cli", "video", "image", "audio"]
|
TaskType = Literal["chat", "cli", "video", "image", "audio"]
|
||||||
|
BillingRuleScope = Literal["model", "global", "default"]
|
||||||
|
|
||||||
|
|
||||||
|
class BillingRuleLike(Protocol):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
expression: str
|
||||||
|
variables: dict[str, Any]
|
||||||
|
dimension_mappings: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
def effective_rule_task_type(task_type: str) -> str:
|
def effective_rule_task_type(task_type: str) -> str:
|
||||||
@@ -28,8 +41,8 @@ def effective_rule_task_type(task_type: str) -> str:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BillingRuleLookupResult:
|
class BillingRuleLookupResult:
|
||||||
rule: BillingRule
|
rule: BillingRuleLike
|
||||||
scope: Literal["model", "global"]
|
scope: BillingRuleScope
|
||||||
effective_task_type: str
|
effective_task_type: str
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +57,16 @@ class BillingRuleService:
|
|||||||
) -> BillingRuleLookupResult | None:
|
) -> BillingRuleLookupResult | None:
|
||||||
effective_task = effective_rule_task_type(task_type)
|
effective_task = effective_rule_task_type(task_type)
|
||||||
|
|
||||||
|
# Normalize provider_id for cache key to avoid duplicate entries (None vs "").
|
||||||
|
pid = provider_id or ""
|
||||||
|
# Cache must include runtime knobs that affect fallback behavior.
|
||||||
|
cache_key = (
|
||||||
|
f"{pid}:{model_name}:{effective_task}:require={int(config.billing_require_rule)}"
|
||||||
|
)
|
||||||
|
cached = BillingCache.get_rule(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
global_model = (
|
global_model = (
|
||||||
db.query(GlobalModel)
|
db.query(GlobalModel)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -55,7 +78,9 @@ class BillingRuleService:
|
|||||||
if not global_model:
|
if not global_model:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 1) Provider Model 覆盖
|
model_obj: Model | None = None
|
||||||
|
|
||||||
|
# Provider Model(用于覆盖价格配置)
|
||||||
if provider_id:
|
if provider_id:
|
||||||
model_obj = (
|
model_obj = (
|
||||||
db.query(Model)
|
db.query(Model)
|
||||||
@@ -66,36 +91,43 @@ class BillingRuleService:
|
|||||||
)
|
)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if model_obj:
|
|
||||||
rule = (
|
|
||||||
db.query(BillingRule)
|
|
||||||
.filter(
|
|
||||||
BillingRule.model_id == model_obj.id,
|
|
||||||
BillingRule.task_type == effective_task,
|
|
||||||
BillingRule.is_enabled == True, # noqa: E712
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if rule:
|
|
||||||
return BillingRuleLookupResult(
|
|
||||||
rule=rule,
|
|
||||||
scope="model",
|
|
||||||
effective_task_type=effective_task,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2) GlobalModel 默认规则
|
# Code templates (config-file mode)
|
||||||
rule = (
|
code_rule = CodeBillingRuleTemplateService.resolve_rule(
|
||||||
db.query(BillingRule)
|
global_model=global_model,
|
||||||
.filter(
|
model=model_obj,
|
||||||
BillingRule.global_model_id == global_model.id,
|
provider_id=provider_id,
|
||||||
BillingRule.task_type == effective_task,
|
model_name=model_name,
|
||||||
BillingRule.is_enabled == True, # noqa: E712
|
task_type=effective_task,
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
)
|
||||||
if rule:
|
if code_rule is not None:
|
||||||
return BillingRuleLookupResult(
|
result = BillingRuleLookupResult(
|
||||||
rule=rule, scope="global", effective_task_type=effective_task
|
rule=code_rule,
|
||||||
|
scope="default",
|
||||||
|
effective_task_type=effective_task,
|
||||||
)
|
)
|
||||||
|
BillingCache.set_rule(cache_key, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Runtime default rule (backward compatible)
|
||||||
|
#
|
||||||
|
# - Always applies to chat-domain billing (cli is normalized to chat).
|
||||||
|
# - For video/image/audio:
|
||||||
|
# - When BILLING_REQUIRE_RULE=true, caller expects an explicit BillingRule (missing -> no_rule/error).
|
||||||
|
# - When BILLING_REQUIRE_RULE=false, fallback to default rule to preserve legacy pricing semantics
|
||||||
|
# (avoid silent $0 billing due to missing rule).
|
||||||
|
if effective_task == "chat" or not config.billing_require_rule:
|
||||||
|
default_rule = DefaultBillingRuleGenerator.generate_for_model(
|
||||||
|
global_model=global_model,
|
||||||
|
model=model_obj,
|
||||||
|
task_type=effective_task,
|
||||||
|
)
|
||||||
|
result = BillingRuleLookupResult(
|
||||||
|
rule=default_rule,
|
||||||
|
scope="default",
|
||||||
|
effective_task_type=effective_task,
|
||||||
|
)
|
||||||
|
BillingCache.set_rule(cache_key, result)
|
||||||
|
return result
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
129
src/services/billing/rule_templates.py
Normal file
129
src/services/billing/rule_templates.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""
|
||||||
|
Billing rule templates (config-file mode).
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
- Developers define billing rules in code as templates ("模式一/二/三/四/五 ...").
|
||||||
|
- Adding a new billing template should only require adding a new `*.py` file under
|
||||||
|
`src.services.billing.rule_defs` (no DB / no UI).
|
||||||
|
|
||||||
|
How it works:
|
||||||
|
- Each module under `rule_defs/` exports `TEMPLATES: list[CodeBillingRuleTemplate]`.
|
||||||
|
- We dynamically discover all templates at runtime and pick the best one by:
|
||||||
|
- task_type match
|
||||||
|
- optional match(ctx) predicate
|
||||||
|
- highest priority wins
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import pkgutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Iterable
|
||||||
|
|
||||||
|
from src.models.database import GlobalModel, Model
|
||||||
|
from src.services.billing.default_rules import VirtualBillingRule
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RuleTemplateContext:
|
||||||
|
global_model: GlobalModel
|
||||||
|
model: Model | None
|
||||||
|
provider_id: str | None
|
||||||
|
model_name: str
|
||||||
|
task_type: str
|
||||||
|
|
||||||
|
|
||||||
|
MatchFn = Callable[[RuleTemplateContext], bool]
|
||||||
|
BuildFn = Callable[[RuleTemplateContext], VirtualBillingRule]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CodeBillingRuleTemplate:
|
||||||
|
"""
|
||||||
|
A code-defined billing template.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `task_types` are billing-domain task types ("cli" is normalized to "chat" by rule_service).
|
||||||
|
- `build()` must return a VirtualBillingRule-like object (VirtualBillingRule is used here).
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
task_types: set[str]
|
||||||
|
priority: int = 0
|
||||||
|
match: MatchFn | None = None
|
||||||
|
build: BuildFn | None = None
|
||||||
|
|
||||||
|
def supports(self, task_type: str) -> bool:
|
||||||
|
return (task_type or "").lower() in {t.lower() for t in (self.task_types or set())}
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_modules() -> Iterable[str]:
|
||||||
|
try:
|
||||||
|
pkg = importlib.import_module("src.services.billing.rule_defs")
|
||||||
|
pkg_path = getattr(pkg, "__path__", None)
|
||||||
|
if not pkg_path:
|
||||||
|
return []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
out: list[str] = []
|
||||||
|
for mod in pkgutil.iter_modules(pkg_path):
|
||||||
|
if mod.ispkg:
|
||||||
|
continue
|
||||||
|
out.append(f"src.services.billing.rule_defs.{mod.name}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def discover_rule_templates() -> list[CodeBillingRuleTemplate]:
|
||||||
|
templates: list[CodeBillingRuleTemplate] = []
|
||||||
|
for mod_name in _iter_modules():
|
||||||
|
try:
|
||||||
|
m = importlib.import_module(mod_name)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
items = getattr(m, "TEMPLATES", None)
|
||||||
|
if not isinstance(items, list):
|
||||||
|
continue
|
||||||
|
for t in items:
|
||||||
|
if isinstance(t, CodeBillingRuleTemplate):
|
||||||
|
templates.append(t)
|
||||||
|
# higher priority first, stable within same module import order
|
||||||
|
templates.sort(key=lambda x: int(getattr(x, "priority", 0) or 0), reverse=True)
|
||||||
|
return templates
|
||||||
|
|
||||||
|
|
||||||
|
class CodeBillingRuleTemplateService:
|
||||||
|
@staticmethod
|
||||||
|
def resolve_rule(
|
||||||
|
*,
|
||||||
|
global_model: GlobalModel,
|
||||||
|
model: Model | None,
|
||||||
|
provider_id: str | None,
|
||||||
|
model_name: str,
|
||||||
|
task_type: str,
|
||||||
|
) -> VirtualBillingRule | None:
|
||||||
|
ctx = RuleTemplateContext(
|
||||||
|
global_model=global_model,
|
||||||
|
model=model,
|
||||||
|
provider_id=provider_id,
|
||||||
|
model_name=model_name,
|
||||||
|
task_type=(task_type or "").lower(),
|
||||||
|
)
|
||||||
|
for t in discover_rule_templates():
|
||||||
|
if not t.supports(ctx.task_type):
|
||||||
|
continue
|
||||||
|
if t.match is not None:
|
||||||
|
try:
|
||||||
|
if not bool(t.match(ctx)):
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if t.build is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return t.build(ctx)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
@@ -10,14 +10,26 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
BILLING_SNAPSHOT_SCHEMA_VERSION = "1.0"
|
BILLING_SNAPSHOT_SCHEMA_VERSION = "2.0"
|
||||||
|
|
||||||
BillingSnapshotStatus = Literal["complete", "incomplete", "no_rule", "legacy"]
|
BillingSnapshotStatus = Literal["complete", "incomplete", "no_rule", "legacy"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BillingSnapshot:
|
class BillingSnapshot:
|
||||||
"""Stable billing snapshot for audit."""
|
"""
|
||||||
|
Stable billing snapshot for audit.
|
||||||
|
|
||||||
|
v2.0 semantics:
|
||||||
|
- resolved_dimensions: final dimension values used (tokens, request_count, etc.)
|
||||||
|
- resolved_variables: final variables used (prices, tier-resolved values, etc.)
|
||||||
|
- cost_breakdown: itemized costs (quantized)
|
||||||
|
- total_cost: quantized total cost (equals sum(cost_breakdown) when breakdown present)
|
||||||
|
|
||||||
|
Backward compatibility:
|
||||||
|
- dimensions_used aliases resolved_dimensions
|
||||||
|
- cost aliases total_cost
|
||||||
|
"""
|
||||||
|
|
||||||
schema_version: str = BILLING_SNAPSHOT_SCHEMA_VERSION
|
schema_version: str = BILLING_SNAPSHOT_SCHEMA_VERSION
|
||||||
|
|
||||||
@@ -29,29 +41,65 @@ class BillingSnapshot:
|
|||||||
# Rule expression (internal, do not expose to clients)
|
# Rule expression (internal, do not expose to clients)
|
||||||
expression: str | None = None
|
expression: str | None = None
|
||||||
|
|
||||||
# Dimensions
|
# v2: resolved inputs
|
||||||
dimensions_used: dict[str, Any] = field(default_factory=dict)
|
resolved_dimensions: dict[str, Any] = field(default_factory=dict)
|
||||||
|
resolved_variables: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# v2: breakdown and totals
|
||||||
|
cost_breakdown: dict[str, float] = field(default_factory=dict)
|
||||||
|
total_cost: float = 0.0
|
||||||
|
|
||||||
|
# Tier info (optional)
|
||||||
|
tier_index: int | None = None
|
||||||
|
tier_info: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
# Missing dims
|
||||||
missing_required: list[str] = field(default_factory=list)
|
missing_required: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
# Result
|
# Result status
|
||||||
cost: float = 0.0
|
|
||||||
status: BillingSnapshotStatus = "no_rule"
|
status: BillingSnapshotStatus = "no_rule"
|
||||||
|
|
||||||
# Audit
|
# Audit
|
||||||
calculated_at: str = "" # ISO 8601
|
calculated_at: str = "" # ISO 8601
|
||||||
|
engine_version: str = "2.0"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Backward-compatible aliases (v1 fields)
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def dimensions_used(self) -> dict[str, Any]:
|
||||||
|
return self.resolved_dimensions
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cost(self) -> float:
|
||||||
|
return self.total_cost
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Serialize snapshot.
|
||||||
|
|
||||||
|
Includes both v2 keys and v1-compatible keys for safer rollouts.
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
"schema_version": self.schema_version,
|
"schema_version": self.schema_version,
|
||||||
"rule_id": self.rule_id,
|
"rule_id": self.rule_id,
|
||||||
"rule_name": self.rule_name,
|
"rule_name": self.rule_name,
|
||||||
"scope": self.scope,
|
"scope": self.scope,
|
||||||
"expression": self.expression,
|
"expression": self.expression,
|
||||||
"dimensions_used": self.dimensions_used,
|
# v2
|
||||||
|
"resolved_dimensions": self.resolved_dimensions,
|
||||||
|
"resolved_variables": self.resolved_variables,
|
||||||
|
"cost_breakdown": self.cost_breakdown,
|
||||||
|
"total_cost": self.total_cost,
|
||||||
|
"tier_index": self.tier_index,
|
||||||
|
"tier_info": self.tier_info,
|
||||||
"missing_required": self.missing_required,
|
"missing_required": self.missing_required,
|
||||||
"cost": self.cost,
|
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"calculated_at": self.calculated_at,
|
"calculated_at": self.calculated_at,
|
||||||
|
"engine_version": self.engine_version,
|
||||||
|
# v1 compat
|
||||||
|
"dimensions_used": self.resolved_dimensions,
|
||||||
|
"cost": self.total_cost,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -9,8 +10,8 @@ from src.config.settings import config
|
|||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
from src.services.billing.dimension_collector_service import DimensionCollectorService
|
from src.services.billing.dimension_collector_service import DimensionCollectorService
|
||||||
from src.services.billing.formula_engine import BillingIncompleteError, FormulaEngine
|
from src.services.billing.formula_engine import BillingIncompleteError, FormulaEngine
|
||||||
|
from src.services.billing.precision import quantize_cost, to_decimal
|
||||||
from src.services.billing.rule_service import BillingRuleService
|
from src.services.billing.rule_service import BillingRuleService
|
||||||
from src.services.model.cost import ModelCostService
|
|
||||||
|
|
||||||
from .schema import BILLING_SNAPSHOT_SCHEMA_VERSION, BillingSnapshot, CostResult
|
from .schema import BILLING_SNAPSHOT_SCHEMA_VERSION, BillingSnapshot, CostResult
|
||||||
|
|
||||||
@@ -24,10 +25,25 @@ class BillingService:
|
|||||||
- It may read billing rules & collectors from DB.
|
- It may read billing rules & collectors from DB.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# FormulaEngine is stateless and safe to share within a process.
|
||||||
|
_shared_formula_engine: FormulaEngine | None = None
|
||||||
|
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session):
|
||||||
self.db = db
|
self.db = db
|
||||||
self._formula_engine = FormulaEngine()
|
self._formula_engine = self._get_formula_engine()
|
||||||
self._dimension_collector = DimensionCollectorService(db)
|
# Lazy-init: most call sites already provide dimensions (hot path).
|
||||||
|
self._dimension_collector: DimensionCollectorService | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_formula_engine(cls) -> FormulaEngine:
|
||||||
|
if cls._shared_formula_engine is None:
|
||||||
|
cls._shared_formula_engine = FormulaEngine()
|
||||||
|
return cls._shared_formula_engine
|
||||||
|
|
||||||
|
def _get_dimension_collector(self) -> DimensionCollectorService:
|
||||||
|
if self._dimension_collector is None:
|
||||||
|
self._dimension_collector = DimensionCollectorService(self.db)
|
||||||
|
return self._dimension_collector
|
||||||
|
|
||||||
def collect_dimensions(
|
def collect_dimensions(
|
||||||
self,
|
self,
|
||||||
@@ -39,7 +55,7 @@ class BillingService:
|
|||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
base_dimensions: dict[str, Any] | None = None,
|
base_dimensions: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return self._dimension_collector.collect_dimensions(
|
return self._get_dimension_collector().collect_dimensions(
|
||||||
api_format=api_format,
|
api_format=api_format,
|
||||||
task_type=task_type,
|
task_type=task_type,
|
||||||
request=request,
|
request=request,
|
||||||
@@ -68,6 +84,42 @@ class BillingService:
|
|||||||
"""
|
"""
|
||||||
strict = config.billing_strict_mode if strict_mode is None else bool(strict_mode)
|
strict = config.billing_strict_mode if strict_mode is None else bool(strict_mode)
|
||||||
|
|
||||||
|
# Normalize & enrich dimensions (do not mutate caller dict)
|
||||||
|
dims: dict[str, Any] = dict(dimensions or {})
|
||||||
|
|
||||||
|
# Compatibility aliases (legacy fields in some call sites)
|
||||||
|
if "cache_creation_tokens" not in dims and "cache_creation_input_tokens" in dims:
|
||||||
|
dims["cache_creation_tokens"] = dims.get("cache_creation_input_tokens")
|
||||||
|
if "cache_read_tokens" not in dims and "cache_read_input_tokens" in dims:
|
||||||
|
dims["cache_read_tokens"] = dims.get("cache_read_input_tokens")
|
||||||
|
|
||||||
|
# Default request_count=1 for per-request billing
|
||||||
|
if "request_count" not in dims:
|
||||||
|
dims["request_count"] = 1
|
||||||
|
|
||||||
|
# total_input_context is the tier-key for legacy tiered pricing:
|
||||||
|
# default: input_tokens + cache_creation_tokens + cache_read_tokens
|
||||||
|
#
|
||||||
|
# NOTE:
|
||||||
|
# Some adapters (e.g. Claude) include cache_creation tokens in the tier context.
|
||||||
|
# Making this the default avoids per-callsite inconsistency.
|
||||||
|
if "total_input_context" not in dims:
|
||||||
|
try:
|
||||||
|
input_tokens_i = int(float(dims.get("input_tokens") or 0))
|
||||||
|
except Exception:
|
||||||
|
input_tokens_i = 0
|
||||||
|
try:
|
||||||
|
cache_creation_tokens_i = int(float(dims.get("cache_creation_tokens") or 0))
|
||||||
|
except Exception:
|
||||||
|
cache_creation_tokens_i = 0
|
||||||
|
try:
|
||||||
|
cache_read_tokens_i = int(float(dims.get("cache_read_tokens") or 0))
|
||||||
|
except Exception:
|
||||||
|
cache_read_tokens_i = 0
|
||||||
|
dims["total_input_context"] = (
|
||||||
|
input_tokens_i + cache_creation_tokens_i + cache_read_tokens_i
|
||||||
|
)
|
||||||
|
|
||||||
lookup = BillingRuleService.find_rule(
|
lookup = BillingRuleService.find_rule(
|
||||||
self.db,
|
self.db,
|
||||||
provider_id=provider_id,
|
provider_id=provider_id,
|
||||||
@@ -80,49 +132,60 @@ class BillingService:
|
|||||||
result = self._formula_engine.evaluate(
|
result = self._formula_engine.evaluate(
|
||||||
expression=rule.expression,
|
expression=rule.expression,
|
||||||
variables=rule.variables or {},
|
variables=rule.variables or {},
|
||||||
dimensions=dimensions,
|
dimensions=dims,
|
||||||
dimension_mappings=rule.dimension_mappings or {},
|
dimension_mappings=rule.dimension_mappings or {},
|
||||||
strict_mode=strict,
|
strict_mode=strict,
|
||||||
)
|
)
|
||||||
cost = float(result.cost) if result.status == "complete" else 0.0
|
# ------------------------------------------------------------
|
||||||
|
# Quantize: component costs first, then total = sum(components)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
breakdown_dec: dict[str, Decimal] = {
|
||||||
|
k: to_decimal(v) for k, v in (result.cost_breakdown or {}).items()
|
||||||
|
}
|
||||||
|
|
||||||
|
breakdown_quantized: dict[str, Decimal] = {
|
||||||
|
k: quantize_cost(v) for k, v in breakdown_dec.items()
|
||||||
|
}
|
||||||
|
total_dec = (
|
||||||
|
quantize_cost(sum(breakdown_quantized.values(), Decimal("0")))
|
||||||
|
if breakdown_quantized
|
||||||
|
else quantize_cost(to_decimal(result.cost))
|
||||||
|
)
|
||||||
|
|
||||||
|
cost_breakdown = {k: float(v) for k, v in breakdown_quantized.items()}
|
||||||
|
total_cost = float(total_dec) if result.status == "complete" else 0.0
|
||||||
|
|
||||||
|
# Filter resolved_variables for JSON safety + semantics clarity:
|
||||||
|
# - remove dims (they live in resolved_dimensions)
|
||||||
|
# - remove *_cost (they live in cost_breakdown)
|
||||||
|
resolved_vars: dict[str, Any] = {}
|
||||||
|
for k, v in (result.resolved_variables or {}).items():
|
||||||
|
if k in (result.resolved_dimensions or {}):
|
||||||
|
continue
|
||||||
|
if k.endswith("_cost"):
|
||||||
|
continue
|
||||||
|
if isinstance(v, Decimal):
|
||||||
|
resolved_vars[k] = str(v)
|
||||||
|
else:
|
||||||
|
resolved_vars[k] = v
|
||||||
|
|
||||||
snapshot = BillingSnapshot(
|
snapshot = BillingSnapshot(
|
||||||
schema_version=BILLING_SNAPSHOT_SCHEMA_VERSION,
|
schema_version=BILLING_SNAPSHOT_SCHEMA_VERSION,
|
||||||
rule_id=str(rule.id),
|
rule_id=str(rule.id),
|
||||||
rule_name=str(rule.name),
|
rule_name=str(rule.name),
|
||||||
scope=str(getattr(lookup, "scope", None) or ""),
|
scope=str(getattr(lookup, "scope", None) or ""),
|
||||||
expression=str(rule.expression),
|
expression=str(rule.expression),
|
||||||
dimensions_used=dimensions,
|
resolved_dimensions=result.resolved_dimensions or dims,
|
||||||
|
resolved_variables=resolved_vars,
|
||||||
|
cost_breakdown=cost_breakdown,
|
||||||
|
total_cost=total_cost,
|
||||||
|
tier_index=result.tier_index,
|
||||||
|
tier_info=result.tier_info,
|
||||||
missing_required=result.missing_required,
|
missing_required=result.missing_required,
|
||||||
cost=cost,
|
|
||||||
status=result.status,
|
status=result.status,
|
||||||
calculated_at=datetime.now(timezone.utc).isoformat(),
|
calculated_at=datetime.now(timezone.utc).isoformat(),
|
||||||
)
|
)
|
||||||
return CostResult(cost=cost, status=result.status, snapshot=snapshot)
|
return CostResult(cost=total_cost, status=result.status, snapshot=snapshot)
|
||||||
|
|
||||||
# No rule fallback
|
|
||||||
if task_type in ("chat", "cli"):
|
|
||||||
input_tokens = int(dimensions.get("input_tokens") or 0)
|
|
||||||
output_tokens = int(dimensions.get("output_tokens") or 0)
|
|
||||||
cost = float(
|
|
||||||
ModelCostService.calculate_cost(
|
|
||||||
model=model,
|
|
||||||
input_tokens=input_tokens,
|
|
||||||
output_tokens=output_tokens,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
snapshot = BillingSnapshot(
|
|
||||||
schema_version=BILLING_SNAPSHOT_SCHEMA_VERSION,
|
|
||||||
rule_id=None,
|
|
||||||
rule_name=None,
|
|
||||||
scope=None,
|
|
||||||
expression=None,
|
|
||||||
dimensions_used=dimensions,
|
|
||||||
missing_required=[],
|
|
||||||
cost=cost,
|
|
||||||
status="legacy",
|
|
||||||
calculated_at=datetime.now(timezone.utc).isoformat(),
|
|
||||||
)
|
|
||||||
return CostResult(cost=cost, status="legacy", snapshot=snapshot)
|
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"No billing rule for task (task_type={}, model={}, provider_id={})",
|
"No billing rule for task (task_type={}, model={}, provider_id={})",
|
||||||
@@ -136,10 +199,46 @@ class BillingService:
|
|||||||
rule_name=None,
|
rule_name=None,
|
||||||
scope=None,
|
scope=None,
|
||||||
expression=None,
|
expression=None,
|
||||||
dimensions_used=dimensions,
|
resolved_dimensions=dims,
|
||||||
|
resolved_variables={},
|
||||||
|
cost_breakdown={},
|
||||||
|
total_cost=0.0,
|
||||||
missing_required=[],
|
missing_required=[],
|
||||||
cost=0.0,
|
|
||||||
status="no_rule",
|
status="no_rule",
|
||||||
calculated_at=datetime.now(timezone.utc).isoformat(),
|
calculated_at=datetime.now(timezone.utc).isoformat(),
|
||||||
)
|
)
|
||||||
return CostResult(cost=0.0, status="no_rule", snapshot=snapshot)
|
return CostResult(cost=0.0, status="no_rule", snapshot=snapshot)
|
||||||
|
|
||||||
|
def calculate_from_response(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
task_type: str,
|
||||||
|
model: str,
|
||||||
|
provider_id: str,
|
||||||
|
api_format: str | None,
|
||||||
|
request: dict[str, Any] | None = None,
|
||||||
|
response: dict[str, Any] | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
base_dimensions: dict[str, Any] | None = None,
|
||||||
|
strict_mode: bool | None = None,
|
||||||
|
) -> CostResult:
|
||||||
|
"""
|
||||||
|
Convenience wrapper:
|
||||||
|
- collect dimensions from request/response/metadata
|
||||||
|
- run billing calculation
|
||||||
|
"""
|
||||||
|
dimensions = self.collect_dimensions(
|
||||||
|
api_format=api_format,
|
||||||
|
task_type=task_type,
|
||||||
|
request=request,
|
||||||
|
response=response,
|
||||||
|
metadata=metadata,
|
||||||
|
base_dimensions=base_dimensions,
|
||||||
|
)
|
||||||
|
return self.calculate(
|
||||||
|
task_type=task_type,
|
||||||
|
model=model,
|
||||||
|
provider_id=provider_id,
|
||||||
|
dimensions=dimensions,
|
||||||
|
strict_mode=strict_mode,
|
||||||
|
)
|
||||||
|
|||||||
323
src/services/billing/shadow.py
Normal file
323
src/services/billing/shadow.py
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
"""
|
||||||
|
Shadow billing (reconciliation period).
|
||||||
|
|
||||||
|
This module runs the new billing engine alongside the legacy billing outcome.
|
||||||
|
Truth vs Shadow is kept strictly separated:
|
||||||
|
- truth_breakdown: the values written into Usage rows (the "billable truth")
|
||||||
|
- shadow_snapshot: new engine snapshot stored only in request_metadata.billing_shadow
|
||||||
|
|
||||||
|
Runtime switch:
|
||||||
|
- config.billing_engine: legacy | shadow | new_with_fallback | new
|
||||||
|
- config.billing_engine_overrides: JSON mapping of "provider/model" patterns -> mode
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.config.settings import config
|
||||||
|
from src.core.logger import logger
|
||||||
|
from src.core.metrics import (
|
||||||
|
billing_diff_exceeds_threshold_total,
|
||||||
|
billing_fallback_total,
|
||||||
|
billing_invariant_violation_total,
|
||||||
|
billing_requests_total,
|
||||||
|
)
|
||||||
|
from src.services.billing.schema import BillingSnapshot
|
||||||
|
from src.services.billing.service import BillingService
|
||||||
|
|
||||||
|
EngineMode = Literal["legacy", "shadow", "new_with_fallback", "new"]
|
||||||
|
TruthEngine = Literal["legacy", "new"]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=32)
|
||||||
|
def _compile_engine_overrides(overrides_raw: str) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
||||||
|
"""
|
||||||
|
Parse and normalize engine overrides.
|
||||||
|
|
||||||
|
Cached to avoid json.loads + dict walk on every request.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
overrides = json.loads(overrides_raw or "{}")
|
||||||
|
except Exception:
|
||||||
|
overrides = {}
|
||||||
|
|
||||||
|
exact: dict[str, str] = {}
|
||||||
|
patterns: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
if isinstance(overrides, dict):
|
||||||
|
for pattern, mode in overrides.items():
|
||||||
|
p = str(pattern)
|
||||||
|
m = str(mode).strip().lower()
|
||||||
|
# fnmatch supports *, ?, and [] character classes.
|
||||||
|
if any(ch in p for ch in ("*", "?", "[")):
|
||||||
|
patterns.append((p, m))
|
||||||
|
else:
|
||||||
|
exact[p] = m
|
||||||
|
|
||||||
|
return exact, patterns
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=4096)
|
||||||
|
def _resolve_engine_mode_cached(key: str, base_mode: str, overrides_raw: str) -> str:
|
||||||
|
exact, patterns = _compile_engine_overrides(overrides_raw)
|
||||||
|
if key in exact:
|
||||||
|
return exact[key]
|
||||||
|
for pattern, mode in patterns:
|
||||||
|
try:
|
||||||
|
if fnmatch.fnmatch(key, pattern):
|
||||||
|
return mode
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return base_mode
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_engine_mode(provider: str, model: str) -> EngineMode:
|
||||||
|
"""Resolve engine mode with overrides (pure function, no DB)."""
|
||||||
|
base_mode = (config.billing_engine or "legacy").strip().lower()
|
||||||
|
overrides_raw = getattr(config, "billing_engine_overrides", "{}") or "{}"
|
||||||
|
|
||||||
|
key = f"{provider}/{model}"
|
||||||
|
return _resolve_engine_mode_cached(key, base_mode, overrides_raw) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CostBreakdown:
|
||||||
|
"""Cost breakdown written into Usage rows (truth)."""
|
||||||
|
|
||||||
|
input_cost: float
|
||||||
|
output_cost: float
|
||||||
|
cache_creation_cost: float
|
||||||
|
cache_read_cost: float
|
||||||
|
request_cost: float
|
||||||
|
total_cost: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cache_cost(self) -> float:
|
||||||
|
return float(self.cache_creation_cost) + float(self.cache_read_cost)
|
||||||
|
|
||||||
|
def validate(self) -> bool:
|
||||||
|
"""
|
||||||
|
Invariant: total_cost == sum(components) (within tiny tolerance).
|
||||||
|
|
||||||
|
For new engine we quantize and sum components deterministically, so this should be exact.
|
||||||
|
For legacy floats, we allow a tiny epsilon.
|
||||||
|
"""
|
||||||
|
computed_total = (
|
||||||
|
float(self.input_cost)
|
||||||
|
+ float(self.output_cost)
|
||||||
|
+ float(self.cache_creation_cost)
|
||||||
|
+ float(self.cache_read_cost)
|
||||||
|
+ float(self.request_cost)
|
||||||
|
)
|
||||||
|
return abs(computed_total - float(self.total_cost)) < 1e-8
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ShadowBillingResult:
|
||||||
|
# billable truth (written to Usage table)
|
||||||
|
truth_breakdown: CostBreakdown
|
||||||
|
# shadow snapshot (written to request_metadata.billing_shadow only)
|
||||||
|
shadow_snapshot: BillingSnapshot | None
|
||||||
|
# reconciliation information (diffs etc.)
|
||||||
|
comparison: dict[str, Any]
|
||||||
|
|
||||||
|
# policy vs actual
|
||||||
|
engine_mode: EngineMode = "legacy"
|
||||||
|
truth_engine: TruthEngine = "legacy"
|
||||||
|
was_fallback: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ShadowBillingService:
|
||||||
|
"""
|
||||||
|
Shadow billing orchestrator.
|
||||||
|
|
||||||
|
This service does NOT write DB rows. Callers decide how to persist truth and shadow data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.db = db
|
||||||
|
# Lazy init: many call sites only need resolve_engine_mode(), and legacy mode
|
||||||
|
# should not pay the cost of constructing BillingService.
|
||||||
|
self._new_billing: BillingService | None = None
|
||||||
|
|
||||||
|
def _get_new_billing(self) -> BillingService:
|
||||||
|
if self._new_billing is None:
|
||||||
|
self._new_billing = BillingService(self.db)
|
||||||
|
return self._new_billing
|
||||||
|
|
||||||
|
def get_engine_mode(self, provider: str, model: str) -> EngineMode:
|
||||||
|
return resolve_engine_mode(provider, model)
|
||||||
|
|
||||||
|
def calculate_with_shadow(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
provider: str,
|
||||||
|
provider_id: str | None,
|
||||||
|
model: str,
|
||||||
|
task_type: str,
|
||||||
|
api_format: str | None,
|
||||||
|
input_tokens: int,
|
||||||
|
output_tokens: int,
|
||||||
|
cache_creation_input_tokens: int = 0,
|
||||||
|
cache_read_input_tokens: int = 0,
|
||||||
|
cache_ttl_minutes: int | None = None,
|
||||||
|
legacy_truth: CostBreakdown,
|
||||||
|
is_failed_request: bool,
|
||||||
|
) -> ShadowBillingResult:
|
||||||
|
"""
|
||||||
|
Compute shadow billing outcome given the legacy truth.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- When engine_mode is legacy, we skip new engine calculation.
|
||||||
|
- When engine_mode is shadow, we compute new engine snapshot and compare, but keep truth legacy.
|
||||||
|
- new/new_with_fallback are supported for later phases; callers can choose to honor truth_engine.
|
||||||
|
"""
|
||||||
|
engine_mode = resolve_engine_mode(provider, model)
|
||||||
|
|
||||||
|
# Default response (legacy only)
|
||||||
|
if engine_mode == "legacy":
|
||||||
|
billing_requests_total.labels(engine_mode=engine_mode, truth_engine="legacy").inc()
|
||||||
|
return ShadowBillingResult(
|
||||||
|
truth_breakdown=legacy_truth,
|
||||||
|
shadow_snapshot=None,
|
||||||
|
comparison={"engine_mode": engine_mode},
|
||||||
|
engine_mode=engine_mode,
|
||||||
|
truth_engine="legacy",
|
||||||
|
was_fallback=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build dimensions for new engine
|
||||||
|
request_count = 0 if is_failed_request else 1
|
||||||
|
dimensions: dict[str, Any] = {
|
||||||
|
"input_tokens": int(input_tokens or 0),
|
||||||
|
"output_tokens": int(output_tokens or 0),
|
||||||
|
"cache_creation_input_tokens": int(cache_creation_input_tokens or 0),
|
||||||
|
"cache_read_input_tokens": int(cache_read_input_tokens or 0),
|
||||||
|
"request_count": int(request_count),
|
||||||
|
}
|
||||||
|
if cache_ttl_minutes is not None:
|
||||||
|
dimensions["cache_ttl_minutes"] = int(cache_ttl_minutes)
|
||||||
|
|
||||||
|
# Normalize task_type
|
||||||
|
tt = (task_type or "").lower()
|
||||||
|
if tt not in {"chat", "cli", "video", "image", "audio"}:
|
||||||
|
tt = "chat"
|
||||||
|
|
||||||
|
new_result = self._get_new_billing().calculate(
|
||||||
|
task_type=tt,
|
||||||
|
model=model,
|
||||||
|
provider_id=provider_id or "",
|
||||||
|
dimensions=dimensions,
|
||||||
|
strict_mode=None,
|
||||||
|
)
|
||||||
|
shadow_snapshot = new_result.snapshot
|
||||||
|
|
||||||
|
new_breakdown = CostBreakdown(
|
||||||
|
input_cost=float(shadow_snapshot.cost_breakdown.get("input_cost", 0.0)),
|
||||||
|
output_cost=float(shadow_snapshot.cost_breakdown.get("output_cost", 0.0)),
|
||||||
|
cache_creation_cost=float(
|
||||||
|
shadow_snapshot.cost_breakdown.get("cache_creation_cost", 0.0)
|
||||||
|
),
|
||||||
|
cache_read_cost=float(shadow_snapshot.cost_breakdown.get("cache_read_cost", 0.0)),
|
||||||
|
request_cost=float(shadow_snapshot.cost_breakdown.get("request_cost", 0.0)),
|
||||||
|
total_cost=float(shadow_snapshot.total_cost),
|
||||||
|
)
|
||||||
|
|
||||||
|
diff = abs(float(new_breakdown.total_cost) - float(legacy_truth.total_cost))
|
||||||
|
diff_pct = (
|
||||||
|
(diff / float(legacy_truth.total_cost) * 100.0) if legacy_truth.total_cost > 0 else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
comparison = {
|
||||||
|
"engine_mode": engine_mode,
|
||||||
|
"old_total": legacy_truth.total_cost,
|
||||||
|
"new_total": new_breakdown.total_cost,
|
||||||
|
"diff_usd": diff,
|
||||||
|
"diff_pct": diff_pct,
|
||||||
|
"breakdown_diff": {
|
||||||
|
"input_cost": new_breakdown.input_cost - legacy_truth.input_cost,
|
||||||
|
"output_cost": new_breakdown.output_cost - legacy_truth.output_cost,
|
||||||
|
"cache_creation_cost": new_breakdown.cache_creation_cost
|
||||||
|
- legacy_truth.cache_creation_cost,
|
||||||
|
"cache_read_cost": new_breakdown.cache_read_cost - legacy_truth.cache_read_cost,
|
||||||
|
"request_cost": new_breakdown.request_cost - legacy_truth.request_cost,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Diff logging / metrics
|
||||||
|
threshold = float(getattr(config, "billing_diff_threshold_usd", 0.0001) or 0.0001)
|
||||||
|
if diff > threshold:
|
||||||
|
billing_diff_exceeds_threshold_total.labels(engine_mode=engine_mode).inc()
|
||||||
|
log_level = (
|
||||||
|
(getattr(config, "billing_shadow_log_level", "INFO") or "INFO").strip().lower()
|
||||||
|
)
|
||||||
|
log_fn = getattr(logger, log_level, logger.info)
|
||||||
|
log_fn(
|
||||||
|
"Billing diff detected: provider={}, model={}, old={:.8f}, new={:.8f}, diff={:.8f} ({:.4f}%), mode={}",
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
legacy_truth.total_cost,
|
||||||
|
new_breakdown.total_cost,
|
||||||
|
diff,
|
||||||
|
diff_pct,
|
||||||
|
engine_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Invariant monitoring (should be 0)
|
||||||
|
truth_engine: TruthEngine = "legacy"
|
||||||
|
was_fallback = False
|
||||||
|
|
||||||
|
if engine_mode == "shadow":
|
||||||
|
truth_engine = "legacy"
|
||||||
|
truth = legacy_truth
|
||||||
|
elif engine_mode == "new":
|
||||||
|
truth_engine = "new"
|
||||||
|
truth = new_breakdown
|
||||||
|
elif engine_mode == "new_with_fallback":
|
||||||
|
# new is truth unless diff is too large
|
||||||
|
fallback_threshold = threshold * 10.0
|
||||||
|
if diff > fallback_threshold:
|
||||||
|
truth_engine = "legacy"
|
||||||
|
truth = legacy_truth
|
||||||
|
was_fallback = True
|
||||||
|
billing_fallback_total.inc()
|
||||||
|
else:
|
||||||
|
truth_engine = "new"
|
||||||
|
truth = new_breakdown
|
||||||
|
else:
|
||||||
|
# Unknown value -> behave like legacy
|
||||||
|
truth_engine = "legacy"
|
||||||
|
truth = legacy_truth
|
||||||
|
|
||||||
|
billing_requests_total.labels(engine_mode=engine_mode, truth_engine=truth_engine).inc()
|
||||||
|
|
||||||
|
if not truth.validate():
|
||||||
|
billing_invariant_violation_total.labels(
|
||||||
|
engine_mode=engine_mode, truth_engine=truth_engine
|
||||||
|
).inc()
|
||||||
|
logger.warning(
|
||||||
|
"Billing invariant violation: provider={}, model={}, engine_mode={}, truth_engine={}, truth_total={}",
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
engine_mode,
|
||||||
|
truth_engine,
|
||||||
|
truth.total_cost,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ShadowBillingResult(
|
||||||
|
truth_breakdown=truth,
|
||||||
|
shadow_snapshot=(
|
||||||
|
shadow_snapshot if engine_mode in {"shadow", "new_with_fallback", "new"} else None
|
||||||
|
),
|
||||||
|
comparison=comparison,
|
||||||
|
engine_mode=engine_mode,
|
||||||
|
truth_engine=truth_engine,
|
||||||
|
was_fallback=was_fallback,
|
||||||
|
)
|
||||||
41
src/services/cache/aware_scheduler.py
vendored
41
src/services/cache/aware_scheduler.py
vendored
@@ -721,18 +721,11 @@ class CacheAwareScheduler:
|
|||||||
return [], global_model_id
|
return [], global_model_id
|
||||||
|
|
||||||
# 2. 构建候选列表(传入 is_stream 和 capability_requirements 用于过滤)
|
# 2. 构建候选列表(传入 is_stream 和 capability_requirements 用于过滤)
|
||||||
from src.config.settings import config
|
|
||||||
from src.services.system.config import SystemConfigService
|
from src.services.system.config import SystemConfigService
|
||||||
|
|
||||||
# 格式转换总开关(环境变量):关闭时禁止任何跨格式候选进入队列
|
# 格式转换总开关(数据库配置):关闭时禁止任何跨格式候选进入队列
|
||||||
master_conversion_enabled = bool(config.format_conversion_enabled)
|
|
||||||
|
|
||||||
# 全局覆盖开关(数据库):开启时强制允许所有提供商的格式转换(跳过端点格式接受策略)
|
|
||||||
global_conversion_enabled = SystemConfigService.is_format_conversion_enabled(db)
|
global_conversion_enabled = SystemConfigService.is_format_conversion_enabled(db)
|
||||||
|
|
||||||
# 如果环境变量明确禁用,则全局覆盖也视为关闭(并最终禁止跨格式转换)
|
|
||||||
if not master_conversion_enabled:
|
|
||||||
global_conversion_enabled = False
|
|
||||||
candidates = await self._build_candidates(
|
candidates = await self._build_candidates(
|
||||||
db=db,
|
db=db,
|
||||||
providers=providers,
|
providers=providers,
|
||||||
@@ -744,11 +737,10 @@ class CacheAwareScheduler:
|
|||||||
is_stream=is_stream,
|
is_stream=is_stream,
|
||||||
capability_requirements=capability_requirements,
|
capability_requirements=capability_requirements,
|
||||||
global_conversion_enabled=global_conversion_enabled,
|
global_conversion_enabled=global_conversion_enabled,
|
||||||
master_conversion_enabled=master_conversion_enabled,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. 应用优先级模式排序
|
# 3. 应用优先级模式排序
|
||||||
candidates = self._apply_priority_mode_sort(candidates, affinity_key, target_format)
|
candidates = self._apply_priority_mode_sort(candidates, db, affinity_key, target_format)
|
||||||
|
|
||||||
# 更新指标
|
# 更新指标
|
||||||
self._metrics["total_candidates"] += len(candidates)
|
self._metrics["total_candidates"] += len(candidates)
|
||||||
@@ -765,6 +757,7 @@ class CacheAwareScheduler:
|
|||||||
if affinity_key and candidates:
|
if affinity_key and candidates:
|
||||||
candidates = await self._apply_cache_affinity(
|
candidates = await self._apply_cache_affinity(
|
||||||
candidates=candidates,
|
candidates=candidates,
|
||||||
|
db=db,
|
||||||
affinity_key=affinity_key,
|
affinity_key=affinity_key,
|
||||||
api_format=target_format,
|
api_format=target_format,
|
||||||
global_model_id=global_model_id,
|
global_model_id=global_model_id,
|
||||||
@@ -1068,8 +1061,7 @@ class CacheAwareScheduler:
|
|||||||
max_candidates: int | None = None,
|
max_candidates: int | None = None,
|
||||||
is_stream: bool = False,
|
is_stream: bool = False,
|
||||||
capability_requirements: dict[str, bool] | None = None,
|
capability_requirements: dict[str, bool] | None = None,
|
||||||
global_conversion_enabled: bool = False,
|
global_conversion_enabled: bool = True,
|
||||||
master_conversion_enabled: bool = True,
|
|
||||||
) -> list[ProviderCandidate]:
|
) -> list[ProviderCandidate]:
|
||||||
"""
|
"""
|
||||||
构建候选列表
|
构建候选列表
|
||||||
@@ -1086,8 +1078,7 @@ class CacheAwareScheduler:
|
|||||||
max_candidates: 最大候选数
|
max_candidates: 最大候选数
|
||||||
is_stream: 是否是流式请求,如果为 True 则过滤不支持流式的 Provider
|
is_stream: 是否是流式请求,如果为 True 则过滤不支持流式的 Provider
|
||||||
capability_requirements: 能力需求(可选)
|
capability_requirements: 能力需求(可选)
|
||||||
global_conversion_enabled: 全局覆盖开关(DB),开启时跳过端点格式接受策略检查
|
global_conversion_enabled: 格式转换总开关(数据库配置),关闭时禁止任何跨格式转换
|
||||||
master_conversion_enabled: 总开关(ENV),关闭时禁止任何跨格式转换
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
候选列表
|
候选列表
|
||||||
@@ -1179,12 +1170,11 @@ class CacheAwareScheduler:
|
|||||||
|
|
||||||
# 计算格式转换开关状态(三层优先级)
|
# 计算格式转换开关状态(三层优先级)
|
||||||
#
|
#
|
||||||
# 1) 总开关(ENV)关闭 -> 禁止任何跨格式转换
|
# 1) 全局开关(数据库配置)关闭 -> 禁止任何跨格式转换
|
||||||
# 2) 全局覆盖(DB)开启 -> 强制允许(跳过端点检查)
|
# 2) 全局开关开启 -> 允许跨格式转换
|
||||||
# 3) 提供商覆盖(Provider.enable_format_conversion)开启 -> 强制允许(跳过端点检查)
|
# 3) 提供商覆盖(Provider.enable_format_conversion)开启 -> 强制允许(跳过端点检查)
|
||||||
# 4) 否则 -> 由端点配置 format_acceptance_config 决定是否允许
|
# 4) 否则 -> 由端点配置 format_acceptance_config 决定是否允许
|
||||||
provider_allows_conversion = getattr(provider, "enable_format_conversion", True)
|
provider_allows_conversion = getattr(provider, "enable_format_conversion", True)
|
||||||
effective_conversion_enabled = bool(master_conversion_enabled)
|
|
||||||
skip_endpoint_check = global_conversion_enabled or provider_allows_conversion
|
skip_endpoint_check = global_conversion_enabled or provider_allows_conversion
|
||||||
|
|
||||||
is_compatible, needs_conversion, _compat_reason = is_format_compatible(
|
is_compatible, needs_conversion, _compat_reason = is_format_compatible(
|
||||||
@@ -1192,16 +1182,15 @@ class CacheAwareScheduler:
|
|||||||
endpoint_format_str,
|
endpoint_format_str,
|
||||||
getattr(endpoint, "format_acceptance_config", None),
|
getattr(endpoint, "format_acceptance_config", None),
|
||||||
is_stream,
|
is_stream,
|
||||||
effective_conversion_enabled,
|
global_conversion_enabled,
|
||||||
skip_endpoint_check=skip_endpoint_check,
|
skip_endpoint_check=skip_endpoint_check,
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"[Scheduler] Format compatibility: client={}, endpoint={}, compatible={}, "
|
"[Scheduler] Format compatibility: client={}, endpoint={}, compatible={}, "
|
||||||
"master={}, global={}, provider={}, skip_endpoint={}, reason={}",
|
"global={}, provider={}, skip_endpoint={}, reason={}",
|
||||||
client_format_str,
|
client_format_str,
|
||||||
endpoint_format_str,
|
endpoint_format_str,
|
||||||
is_compatible,
|
is_compatible,
|
||||||
master_conversion_enabled,
|
|
||||||
global_conversion_enabled,
|
global_conversion_enabled,
|
||||||
provider_allows_conversion,
|
provider_allows_conversion,
|
||||||
skip_endpoint_check,
|
skip_endpoint_check,
|
||||||
@@ -1302,6 +1291,7 @@ class CacheAwareScheduler:
|
|||||||
async def _apply_cache_affinity(
|
async def _apply_cache_affinity(
|
||||||
self,
|
self,
|
||||||
candidates: list[ProviderCandidate],
|
candidates: list[ProviderCandidate],
|
||||||
|
db: Session,
|
||||||
affinity_key: str,
|
affinity_key: str,
|
||||||
api_format: str,
|
api_format: str,
|
||||||
global_model_id: str,
|
global_model_id: str,
|
||||||
@@ -1334,9 +1324,9 @@ class CacheAwareScheduler:
|
|||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
# 判断候选是否应该被降级(用于分组)
|
# 判断候选是否应该被降级(用于分组)
|
||||||
from src.config.settings import config
|
from src.services.system.config import SystemConfigService
|
||||||
|
|
||||||
global_keep_priority = config.keep_priority_on_conversion
|
global_keep_priority = SystemConfigService.is_keep_priority_on_conversion(db)
|
||||||
|
|
||||||
def should_demote(c: ProviderCandidate) -> bool:
|
def should_demote(c: ProviderCandidate) -> bool:
|
||||||
"""判断候选是否应该被降级"""
|
"""判断候选是否应该被降级"""
|
||||||
@@ -1467,13 +1457,14 @@ class CacheAwareScheduler:
|
|||||||
def _apply_priority_mode_sort(
|
def _apply_priority_mode_sort(
|
||||||
self,
|
self,
|
||||||
candidates: list[ProviderCandidate],
|
candidates: list[ProviderCandidate],
|
||||||
|
db: Session,
|
||||||
affinity_key: str | None = None,
|
affinity_key: str | None = None,
|
||||||
api_format: str | None = None,
|
api_format: str | None = None,
|
||||||
) -> list[ProviderCandidate]:
|
) -> list[ProviderCandidate]:
|
||||||
"""
|
"""
|
||||||
根据优先级模式对候选列表排序(数字越小越优先)
|
根据优先级模式对候选列表排序(数字越小越优先)
|
||||||
|
|
||||||
排序规则(受 KEEP_PRIORITY_ON_CONVERSION 配置影响):
|
排序规则(受 keep_priority_on_conversion 配置影响):
|
||||||
1. 如果全局配置 keep_priority_on_conversion=True,所有候选保持原优先级
|
1. 如果全局配置 keep_priority_on_conversion=True,所有候选保持原优先级
|
||||||
2. 否则,按 needs_conversion 和 provider.keep_priority_on_conversion 分组:
|
2. 否则,按 needs_conversion 和 provider.keep_priority_on_conversion 分组:
|
||||||
- 保持优先级的候选(exact 或 provider.keep_priority_on_conversion=True)按原优先级排序
|
- 保持优先级的候选(exact 或 provider.keep_priority_on_conversion=True)按原优先级排序
|
||||||
@@ -1485,10 +1476,10 @@ class CacheAwareScheduler:
|
|||||||
if not candidates:
|
if not candidates:
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
from src.config.settings import config
|
from src.services.system.config import SystemConfigService
|
||||||
|
|
||||||
# 全局配置:如果开启,所有候选保持原优先级
|
# 全局配置:如果开启,所有候选保持原优先级
|
||||||
global_keep_priority = config.keep_priority_on_conversion
|
global_keep_priority = SystemConfigService.is_keep_priority_on_conversion(db)
|
||||||
|
|
||||||
if global_keep_priority:
|
if global_keep_priority:
|
||||||
# 全局开启:不分组,直接按优先级模式排序
|
# 全局开启:不分组,直接按优先级模式排序
|
||||||
|
|||||||
@@ -479,13 +479,11 @@ class EndpointHealthService:
|
|||||||
fam, kind = normalized.split(":", 1)
|
fam, kind = normalized.split(":", 1)
|
||||||
fam_label = {"claude": "Claude", "openai": "OpenAI", "gemini": "Gemini"}.get(fam, fam)
|
fam_label = {"claude": "Claude", "openai": "OpenAI", "gemini": "Gemini"}.get(fam, fam)
|
||||||
kind_label = {
|
kind_label = {
|
||||||
"chat": "",
|
"chat": "Chat",
|
||||||
"cli": "CLI",
|
"cli": "CLI",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"image": "Image",
|
"image": "Image",
|
||||||
}.get(kind, kind)
|
}.get(kind, kind)
|
||||||
if not kind_label:
|
|
||||||
return fam_label
|
|
||||||
return f"{fam_label} {kind_label}"
|
return f"{fam_label} {kind_label}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -472,4 +472,6 @@ class ModelService:
|
|||||||
global_model_display_name=(
|
global_model_display_name=(
|
||||||
model.global_model.display_name if model.global_model else None
|
model.global_model.display_name if model.global_model else None
|
||||||
),
|
),
|
||||||
|
# 有效配置(合并 Model 和 GlobalModel 的 config)
|
||||||
|
effective_config=model.get_effective_config(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ from sqlalchemy.orm import Session
|
|||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
from src.models.database import Provider, SystemConfig
|
from src.models.database import Provider, SystemConfig
|
||||||
|
|
||||||
|
REQUEST_RECORD_LEVEL_KEY = "request_record_level"
|
||||||
|
_LEGACY_REQUEST_LOG_LEVEL_KEY = "request_log_level"
|
||||||
|
|
||||||
class LogLevel(str, Enum):
|
|
||||||
"""日志记录级别"""
|
class RequestRecordLevel(str, Enum):
|
||||||
|
"""请求记录级别(控制请求/响应详情入库)"""
|
||||||
|
|
||||||
BASIC = "basic" # 仅记录基本信息(tokens、成本等)
|
BASIC = "basic" # 仅记录基本信息(tokens、成本等)
|
||||||
HEADERS = "headers" # 记录基本信息+请求/响应头(敏感信息会脱敏)
|
HEADERS = "headers" # 记录基本信息+请求/响应头(敏感信息会脱敏)
|
||||||
@@ -71,9 +74,9 @@ class SystemConfigService:
|
|||||||
|
|
||||||
# 默认配置
|
# 默认配置
|
||||||
DEFAULT_CONFIGS = {
|
DEFAULT_CONFIGS = {
|
||||||
"request_log_level": {
|
REQUEST_RECORD_LEVEL_KEY: {
|
||||||
"value": LogLevel.BASIC.value,
|
"value": RequestRecordLevel.BASIC.value,
|
||||||
"description": "请求记录级别:basic(基本信息), headers(含请求头), full(完整请求响应)",
|
"description": "请求记录级别:basic(基本信息), headers(含请求/响应头), full(完整请求/响应)",
|
||||||
},
|
},
|
||||||
"max_request_body_size": {
|
"max_request_body_size": {
|
||||||
"value": 5242880, # 5MB
|
"value": 5242880, # 5MB
|
||||||
@@ -136,10 +139,14 @@ class SystemConfigService:
|
|||||||
"value": [],
|
"value": [],
|
||||||
"description": "邮箱后缀列表,配合 email_suffix_mode 使用",
|
"description": "邮箱后缀列表,配合 email_suffix_mode 使用",
|
||||||
},
|
},
|
||||||
# 格式转换开关
|
# 格式转换配置
|
||||||
"enable_format_conversion": {
|
"enable_format_conversion": {
|
||||||
|
"value": True,
|
||||||
|
"description": "格式转换总开关:开启时允许跨格式转换;关闭时禁止任何跨格式转换",
|
||||||
|
},
|
||||||
|
"keep_priority_on_conversion": {
|
||||||
"value": False,
|
"value": False,
|
||||||
"description": "全局格式转换开关:开启时强制允许所有提供商的格式转换;关闭时由各提供商自行决定",
|
"description": "格式转换时保持优先级:开启时需要转换的候选保持原优先级;关闭时降级到不需要转换的候选之后",
|
||||||
},
|
},
|
||||||
"audit_log_retention_days": {
|
"audit_log_retention_days": {
|
||||||
"value": 30,
|
"value": 30,
|
||||||
@@ -183,6 +190,17 @@ class SystemConfigService:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def get_config(cls, db: Session, key: str, default: Any | None = None) -> Any | None:
|
def get_config(cls, db: Session, key: str, default: Any | None = None) -> Any | None:
|
||||||
"""获取系统配置值(带进程内缓存)"""
|
"""获取系统配置值(带进程内缓存)"""
|
||||||
|
# Backward-compatible alias: request_log_level -> request_record_level
|
||||||
|
if key in {REQUEST_RECORD_LEVEL_KEY, _LEGACY_REQUEST_LOG_LEVEL_KEY}:
|
||||||
|
value = cls._get_request_record_level_raw(db)
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
if REQUEST_RECORD_LEVEL_KEY in cls.DEFAULT_CONFIGS:
|
||||||
|
value = cls.DEFAULT_CONFIGS[REQUEST_RECORD_LEVEL_KEY]["value"]
|
||||||
|
_set_cached_config(REQUEST_RECORD_LEVEL_KEY, value)
|
||||||
|
return value
|
||||||
|
return default
|
||||||
|
|
||||||
# 1. 检查进程内缓存
|
# 1. 检查进程内缓存
|
||||||
hit, cached_value = _get_cached_config(key)
|
hit, cached_value = _get_cached_config(key)
|
||||||
if hit:
|
if hit:
|
||||||
@@ -236,6 +254,44 @@ class SystemConfigService:
|
|||||||
db: Session, key: str, value: Any, description: str | None = None
|
db: Session, key: str, value: Any, description: str | None = None
|
||||||
) -> SystemConfig:
|
) -> SystemConfig:
|
||||||
"""设置系统配置值"""
|
"""设置系统配置值"""
|
||||||
|
# Backward-compatible alias: request_log_level -> request_record_level
|
||||||
|
if key in {REQUEST_RECORD_LEVEL_KEY, _LEGACY_REQUEST_LOG_LEVEL_KEY}:
|
||||||
|
config = (
|
||||||
|
db.query(SystemConfig).filter(SystemConfig.key == REQUEST_RECORD_LEVEL_KEY).first()
|
||||||
|
)
|
||||||
|
legacy = (
|
||||||
|
db.query(SystemConfig)
|
||||||
|
.filter(SystemConfig.key == _LEGACY_REQUEST_LOG_LEVEL_KEY)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if config:
|
||||||
|
config.value = value
|
||||||
|
if description:
|
||||||
|
config.description = description
|
||||||
|
# 如果同时存在旧 key,删除它避免混乱
|
||||||
|
if legacy:
|
||||||
|
db.delete(legacy)
|
||||||
|
elif legacy:
|
||||||
|
# 原地迁移旧 key -> 新 key
|
||||||
|
legacy.key = REQUEST_RECORD_LEVEL_KEY
|
||||||
|
legacy.value = value
|
||||||
|
if description:
|
||||||
|
legacy.description = description
|
||||||
|
config = legacy
|
||||||
|
else:
|
||||||
|
config = SystemConfig(
|
||||||
|
key=REQUEST_RECORD_LEVEL_KEY, value=value, description=description
|
||||||
|
)
|
||||||
|
db.add(config)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(config)
|
||||||
|
|
||||||
|
invalidate_config_cache(REQUEST_RECORD_LEVEL_KEY)
|
||||||
|
invalidate_config_cache(_LEGACY_REQUEST_LOG_LEVEL_KEY)
|
||||||
|
return config
|
||||||
|
|
||||||
config = db.query(SystemConfig).filter(SystemConfig.key == key).first()
|
config = db.query(SystemConfig).filter(SystemConfig.key == key).first()
|
||||||
|
|
||||||
if config:
|
if config:
|
||||||
@@ -289,10 +345,20 @@ class SystemConfigService:
|
|||||||
def get_all_configs(cls, db: Session) -> list:
|
def get_all_configs(cls, db: Session) -> list:
|
||||||
"""获取所有系统配置"""
|
"""获取所有系统配置"""
|
||||||
configs = db.query(SystemConfig).all()
|
configs = db.query(SystemConfig).all()
|
||||||
|
by_key = {c.key: c for c in configs}
|
||||||
result = []
|
result = []
|
||||||
for config in configs:
|
for config in configs:
|
||||||
|
# Hide legacy key in list; present as canonical key instead.
|
||||||
|
if config.key == _LEGACY_REQUEST_LOG_LEVEL_KEY:
|
||||||
|
if REQUEST_RECORD_LEVEL_KEY in by_key:
|
||||||
|
continue
|
||||||
|
# Expose as canonical key name
|
||||||
|
config_key = REQUEST_RECORD_LEVEL_KEY
|
||||||
|
else:
|
||||||
|
config_key = config.key
|
||||||
|
|
||||||
item = {
|
item = {
|
||||||
"key": config.key,
|
"key": config_key,
|
||||||
"description": config.description,
|
"description": config.description,
|
||||||
"updated_at": config.updated_at.isoformat(),
|
"updated_at": config.updated_at.isoformat(),
|
||||||
}
|
}
|
||||||
@@ -308,6 +374,24 @@ class SystemConfigService:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def delete_config(cls, db: Session, key: str) -> bool:
|
def delete_config(cls, db: Session, key: str) -> bool:
|
||||||
"""删除系统配置"""
|
"""删除系统配置"""
|
||||||
|
# Backward-compatible alias: request_log_level -> request_record_level
|
||||||
|
if key in {REQUEST_RECORD_LEVEL_KEY, _LEGACY_REQUEST_LOG_LEVEL_KEY}:
|
||||||
|
configs = (
|
||||||
|
db.query(SystemConfig)
|
||||||
|
.filter(
|
||||||
|
SystemConfig.key.in_([REQUEST_RECORD_LEVEL_KEY, _LEGACY_REQUEST_LOG_LEVEL_KEY])
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not configs:
|
||||||
|
return False
|
||||||
|
for c in configs:
|
||||||
|
db.delete(c)
|
||||||
|
db.commit()
|
||||||
|
invalidate_config_cache(REQUEST_RECORD_LEVEL_KEY)
|
||||||
|
invalidate_config_cache(_LEGACY_REQUEST_LOG_LEVEL_KEY)
|
||||||
|
return True
|
||||||
|
|
||||||
config = db.query(SystemConfig).filter(SystemConfig.key == key).first()
|
config = db.query(SystemConfig).filter(SystemConfig.key == key).first()
|
||||||
if config:
|
if config:
|
||||||
db.delete(config)
|
db.delete(config)
|
||||||
@@ -333,24 +417,56 @@ class SystemConfigService:
|
|||||||
logger.info("初始化默认系统配置完成")
|
logger.info("初始化默认系统配置完成")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_log_level(cls, db: Session) -> LogLevel:
|
def _get_request_record_level_raw(cls, db: Session) -> Any | None:
|
||||||
"""获取日志记录级别"""
|
"""Raw value from DB/cache for request record level (supports legacy key)."""
|
||||||
level = cls.get_config(db, "request_log_level", LogLevel.BASIC.value)
|
hit, cached_value = _get_cached_config(REQUEST_RECORD_LEVEL_KEY)
|
||||||
|
if hit:
|
||||||
|
return cached_value
|
||||||
|
|
||||||
|
config = db.query(SystemConfig).filter(SystemConfig.key == REQUEST_RECORD_LEVEL_KEY).first()
|
||||||
|
if config:
|
||||||
|
_set_cached_config(REQUEST_RECORD_LEVEL_KEY, config.value)
|
||||||
|
return config.value
|
||||||
|
|
||||||
|
hit, cached_value = _get_cached_config(_LEGACY_REQUEST_LOG_LEVEL_KEY)
|
||||||
|
if hit:
|
||||||
|
_set_cached_config(REQUEST_RECORD_LEVEL_KEY, cached_value)
|
||||||
|
return cached_value
|
||||||
|
|
||||||
|
legacy = (
|
||||||
|
db.query(SystemConfig).filter(SystemConfig.key == _LEGACY_REQUEST_LOG_LEVEL_KEY).first()
|
||||||
|
)
|
||||||
|
if legacy:
|
||||||
|
_set_cached_config(_LEGACY_REQUEST_LOG_LEVEL_KEY, legacy.value)
|
||||||
|
_set_cached_config(REQUEST_RECORD_LEVEL_KEY, legacy.value)
|
||||||
|
return legacy.value
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_request_record_level(cls, db: Session) -> RequestRecordLevel:
|
||||||
|
"""获取请求记录级别(控制请求/响应详情入库)"""
|
||||||
|
level = cls.get_config(db, REQUEST_RECORD_LEVEL_KEY, RequestRecordLevel.BASIC.value)
|
||||||
if isinstance(level, str):
|
if isinstance(level, str):
|
||||||
return LogLevel(level)
|
return RequestRecordLevel(level)
|
||||||
return level
|
return level
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_log_level(cls, db: Session) -> RequestRecordLevel:
|
||||||
|
"""Deprecated: use get_request_record_level."""
|
||||||
|
return cls.get_request_record_level(db)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def should_log_headers(cls, db: Session) -> bool:
|
def should_log_headers(cls, db: Session) -> bool:
|
||||||
"""是否应该记录请求头"""
|
"""是否应该记录请求头"""
|
||||||
log_level = cls.get_log_level(db)
|
level = cls.get_request_record_level(db)
|
||||||
return log_level in [LogLevel.HEADERS, LogLevel.FULL]
|
return level in [RequestRecordLevel.HEADERS, RequestRecordLevel.FULL]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def should_log_body(cls, db: Session) -> bool:
|
def should_log_body(cls, db: Session) -> bool:
|
||||||
"""是否应该记录请求体和响应体"""
|
"""是否应该记录请求体和响应体"""
|
||||||
log_level = cls.get_log_level(db)
|
level = cls.get_request_record_level(db)
|
||||||
return log_level == LogLevel.FULL
|
return level == RequestRecordLevel.FULL
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def should_mask_sensitive_data(cls, db: Session) -> bool:
|
def should_mask_sensitive_data(cls, db: Session) -> bool:
|
||||||
@@ -368,6 +484,11 @@ class SystemConfigService:
|
|||||||
"""检查全局格式转换是否启用"""
|
"""检查全局格式转换是否启用"""
|
||||||
return bool(cls.get_config(db, "enable_format_conversion", True))
|
return bool(cls.get_config(db, "enable_format_conversion", True))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_keep_priority_on_conversion(cls, db: Session) -> bool:
|
||||||
|
"""检查格式转换时是否保持优先级"""
|
||||||
|
return bool(cls.get_config(db, "keep_priority_on_conversion", False))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def mask_sensitive_headers(cls, db: Session, headers: dict[str, Any]) -> dict[str, Any]:
|
def mask_sensitive_headers(cls, db: Session, headers: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""脱敏敏感请求头"""
|
"""脱敏敏感请求头"""
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import and_, func
|
from sqlalchemy import and_, case, func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
@@ -61,12 +61,30 @@ class StatsAggregatorService:
|
|||||||
"""计算指定业务日期的统计数据(不写入数据库)"""
|
"""计算指定业务日期的统计数据(不写入数据库)"""
|
||||||
day_start, day_end = _get_business_day_range(date)
|
day_start, day_end = _get_business_day_range(date)
|
||||||
|
|
||||||
base_query = db.query(Usage).filter(
|
error_cond = (Usage.status_code >= 400) | (Usage.error_message.isnot(None))
|
||||||
and_(Usage.created_at >= day_start, Usage.created_at < day_end)
|
aggregated = (
|
||||||
|
db.query(
|
||||||
|
func.count(Usage.id).label("total_requests"),
|
||||||
|
func.sum(case((error_cond, 1), else_=0)).label("error_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.sum(Usage.actual_total_cost_usd).label("actual_total_cost"),
|
||||||
|
func.sum(Usage.input_cost_usd).label("input_cost"),
|
||||||
|
func.sum(Usage.output_cost_usd).label("output_cost"),
|
||||||
|
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
|
||||||
|
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
|
||||||
|
func.avg(Usage.response_time_ms).label("avg_response_time"),
|
||||||
|
func.count(func.distinct(Usage.model)).label("unique_models"),
|
||||||
|
func.count(func.distinct(Usage.provider_name)).label("unique_providers"),
|
||||||
|
)
|
||||||
|
.filter(and_(Usage.created_at >= day_start, Usage.created_at < day_end))
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
total_requests = base_query.count()
|
total_requests = int(getattr(aggregated, "total_requests", 0) or 0)
|
||||||
|
|
||||||
if total_requests == 0:
|
if total_requests == 0:
|
||||||
return {
|
return {
|
||||||
"day_start": day_start,
|
"day_start": day_start,
|
||||||
@@ -89,27 +107,7 @@ class StatsAggregatorService:
|
|||||||
"unique_providers": 0,
|
"unique_providers": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
error_requests = base_query.filter(
|
error_requests = int(getattr(aggregated, "error_requests", 0) or 0)
|
||||||
(Usage.status_code >= 400) | (Usage.error_message.isnot(None))
|
|
||||||
).count()
|
|
||||||
|
|
||||||
aggregated = (
|
|
||||||
db.query(
|
|
||||||
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.sum(Usage.actual_total_cost_usd).label("actual_total_cost"),
|
|
||||||
func.sum(Usage.input_cost_usd).label("input_cost"),
|
|
||||||
func.sum(Usage.output_cost_usd).label("output_cost"),
|
|
||||||
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
|
|
||||||
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
|
|
||||||
func.avg(Usage.response_time_ms).label("avg_response_time"),
|
|
||||||
)
|
|
||||||
.filter(and_(Usage.created_at >= day_start, Usage.created_at < day_end))
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fallback 统计 (执行候选数 > 1 的请求数)
|
# Fallback 统计 (执行候选数 > 1 的请求数)
|
||||||
fallback_subquery = (
|
fallback_subquery = (
|
||||||
@@ -135,42 +133,25 @@ class StatsAggregatorService:
|
|||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
|
|
||||||
unique_models = (
|
|
||||||
db.query(func.count(func.distinct(Usage.model)))
|
|
||||||
.filter(and_(Usage.created_at >= day_start, Usage.created_at < day_end))
|
|
||||||
.scalar()
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
unique_providers = (
|
|
||||||
db.query(func.count(func.distinct(Usage.provider_name)))
|
|
||||||
.filter(and_(Usage.created_at >= day_start, Usage.created_at < day_end))
|
|
||||||
.scalar()
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"day_start": day_start,
|
"day_start": day_start,
|
||||||
"total_requests": total_requests,
|
"total_requests": total_requests,
|
||||||
"success_requests": total_requests - error_requests,
|
"success_requests": total_requests - error_requests,
|
||||||
"error_requests": error_requests,
|
"error_requests": error_requests,
|
||||||
"input_tokens": int(aggregated.input_tokens or 0) if aggregated else 0,
|
"input_tokens": int(getattr(aggregated, "input_tokens", 0) or 0),
|
||||||
"output_tokens": int(aggregated.output_tokens or 0) if aggregated else 0,
|
"output_tokens": int(getattr(aggregated, "output_tokens", 0) or 0),
|
||||||
"cache_creation_tokens": (
|
"cache_creation_tokens": (int(getattr(aggregated, "cache_creation_tokens", 0) or 0)),
|
||||||
int(aggregated.cache_creation_tokens or 0) if aggregated else 0
|
"cache_read_tokens": int(getattr(aggregated, "cache_read_tokens", 0) or 0),
|
||||||
),
|
"total_cost": float(getattr(aggregated, "total_cost", 0) or 0.0),
|
||||||
"cache_read_tokens": int(aggregated.cache_read_tokens or 0) if aggregated else 0,
|
"actual_total_cost": float(getattr(aggregated, "actual_total_cost", 0) or 0.0),
|
||||||
"total_cost": float(aggregated.total_cost or 0) if aggregated else 0.0,
|
"input_cost": float(getattr(aggregated, "input_cost", 0) or 0.0),
|
||||||
"actual_total_cost": float(aggregated.actual_total_cost or 0) if aggregated else 0.0,
|
"output_cost": float(getattr(aggregated, "output_cost", 0) or 0.0),
|
||||||
"input_cost": float(aggregated.input_cost or 0) if aggregated else 0.0,
|
"cache_creation_cost": (float(getattr(aggregated, "cache_creation_cost", 0) or 0.0)),
|
||||||
"output_cost": float(aggregated.output_cost or 0) if aggregated else 0.0,
|
"cache_read_cost": float(getattr(aggregated, "cache_read_cost", 0) or 0.0),
|
||||||
"cache_creation_cost": (
|
"avg_response_time_ms": float(getattr(aggregated, "avg_response_time", 0) or 0.0),
|
||||||
float(aggregated.cache_creation_cost or 0) if aggregated else 0.0
|
|
||||||
),
|
|
||||||
"cache_read_cost": float(aggregated.cache_read_cost or 0) if aggregated else 0.0,
|
|
||||||
"avg_response_time_ms": float(aggregated.avg_response_time or 0) if aggregated else 0.0,
|
|
||||||
"fallback_count": fallback_count,
|
"fallback_count": fallback_count,
|
||||||
"unique_models": unique_models,
|
"unique_models": int(getattr(aggregated, "unique_models", 0) or 0),
|
||||||
"unique_providers": unique_providers,
|
"unique_providers": int(getattr(aggregated, "unique_providers", 0) or 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -426,38 +407,11 @@ class StatsAggregatorService:
|
|||||||
else:
|
else:
|
||||||
stats = StatsUserDaily(id=str(uuid.uuid4()), user_id=user_id, date=day_start)
|
stats = StatsUserDaily(id=str(uuid.uuid4()), user_id=user_id, date=day_start)
|
||||||
|
|
||||||
# 用户请求统计
|
error_cond = (Usage.status_code >= 400) | (Usage.error_message.isnot(None))
|
||||||
base_query = db.query(Usage).filter(
|
|
||||||
and_(
|
|
||||||
Usage.user_id == user_id,
|
|
||||||
Usage.created_at >= day_start,
|
|
||||||
Usage.created_at < day_end,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
total_requests = base_query.count()
|
|
||||||
|
|
||||||
if total_requests == 0:
|
|
||||||
stats.total_requests = 0
|
|
||||||
stats.success_requests = 0
|
|
||||||
stats.error_requests = 0
|
|
||||||
stats.input_tokens = 0
|
|
||||||
stats.output_tokens = 0
|
|
||||||
stats.cache_creation_tokens = 0
|
|
||||||
stats.cache_read_tokens = 0
|
|
||||||
stats.total_cost = 0.0
|
|
||||||
|
|
||||||
if not existing:
|
|
||||||
db.add(stats)
|
|
||||||
db.commit()
|
|
||||||
return stats
|
|
||||||
|
|
||||||
error_requests = base_query.filter(
|
|
||||||
(Usage.status_code >= 400) | (Usage.error_message.isnot(None))
|
|
||||||
).count()
|
|
||||||
|
|
||||||
aggregated = (
|
aggregated = (
|
||||||
db.query(
|
db.query(
|
||||||
|
func.count(Usage.id).label("total_requests"),
|
||||||
|
func.sum(case((error_cond, 1), else_=0)).label("error_requests"),
|
||||||
func.sum(Usage.input_tokens).label("input_tokens"),
|
func.sum(Usage.input_tokens).label("input_tokens"),
|
||||||
func.sum(Usage.output_tokens).label("output_tokens"),
|
func.sum(Usage.output_tokens).label("output_tokens"),
|
||||||
func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"),
|
func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"),
|
||||||
@@ -474,14 +428,32 @@ class StatsAggregatorService:
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
total_requests = int(getattr(aggregated, "total_requests", 0) or 0)
|
||||||
|
if total_requests == 0:
|
||||||
|
stats.total_requests = 0
|
||||||
|
stats.success_requests = 0
|
||||||
|
stats.error_requests = 0
|
||||||
|
stats.input_tokens = 0
|
||||||
|
stats.output_tokens = 0
|
||||||
|
stats.cache_creation_tokens = 0
|
||||||
|
stats.cache_read_tokens = 0
|
||||||
|
stats.total_cost = 0.0
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
db.add(stats)
|
||||||
|
db.commit()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
error_requests = int(getattr(aggregated, "error_requests", 0) or 0)
|
||||||
|
|
||||||
stats.total_requests = total_requests
|
stats.total_requests = total_requests
|
||||||
stats.success_requests = total_requests - error_requests
|
stats.success_requests = total_requests - error_requests
|
||||||
stats.error_requests = error_requests
|
stats.error_requests = error_requests
|
||||||
stats.input_tokens = int(aggregated.input_tokens or 0)
|
stats.input_tokens = int(getattr(aggregated, "input_tokens", 0) or 0)
|
||||||
stats.output_tokens = int(aggregated.output_tokens or 0)
|
stats.output_tokens = int(getattr(aggregated, "output_tokens", 0) or 0)
|
||||||
stats.cache_creation_tokens = int(aggregated.cache_creation_tokens or 0)
|
stats.cache_creation_tokens = int(getattr(aggregated, "cache_creation_tokens", 0) or 0)
|
||||||
stats.cache_read_tokens = int(aggregated.cache_read_tokens or 0)
|
stats.cache_read_tokens = int(getattr(aggregated, "cache_read_tokens", 0) or 0)
|
||||||
stats.total_cost = float(aggregated.total_cost or 0)
|
stats.total_cost = float(getattr(aggregated, "total_cost", 0) or 0.0)
|
||||||
|
|
||||||
if not existing:
|
if not existing:
|
||||||
db.add(stats)
|
db.add(stats)
|
||||||
@@ -571,10 +543,26 @@ class StatsAggregatorService:
|
|||||||
# 转换为 UTC 用于查询
|
# 转换为 UTC 用于查询
|
||||||
today_utc = today_local.astimezone(timezone.utc)
|
today_utc = today_local.astimezone(timezone.utc)
|
||||||
|
|
||||||
base_query = db.query(Usage).filter(Usage.created_at >= today_utc)
|
error_cond = (Usage.status_code >= 400) | (Usage.error_message.isnot(None))
|
||||||
|
aggregated = (
|
||||||
total_requests = base_query.count()
|
db.query(
|
||||||
|
func.count(Usage.id).label("total_requests"),
|
||||||
|
func.sum(case((error_cond, 1), else_=0)).label("error_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.sum(Usage.actual_total_cost_usd).label("actual_total_cost"),
|
||||||
|
func.avg(Usage.response_time_ms).label("avg_response_time"),
|
||||||
|
func.count(func.distinct(Usage.model)).label("unique_models"),
|
||||||
|
func.count(func.distinct(Usage.provider_name)).label("unique_providers"),
|
||||||
|
)
|
||||||
|
.filter(Usage.created_at >= today_utc)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
total_requests = int(getattr(aggregated, "total_requests", 0) or 0)
|
||||||
if total_requests == 0:
|
if total_requests == 0:
|
||||||
return {
|
return {
|
||||||
"total_requests": 0,
|
"total_requests": 0,
|
||||||
@@ -586,42 +574,33 @@ class StatsAggregatorService:
|
|||||||
"cache_read_tokens": 0,
|
"cache_read_tokens": 0,
|
||||||
"total_cost": 0.0,
|
"total_cost": 0.0,
|
||||||
"actual_total_cost": 0.0,
|
"actual_total_cost": 0.0,
|
||||||
|
"avg_response_time_ms": 0.0,
|
||||||
|
"unique_models": 0,
|
||||||
|
"unique_providers": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
error_requests = base_query.filter(
|
error_requests = int(getattr(aggregated, "error_requests", 0) or 0)
|
||||||
(Usage.status_code >= 400) | (Usage.error_message.isnot(None))
|
|
||||||
).count()
|
|
||||||
|
|
||||||
aggregated = (
|
|
||||||
db.query(
|
|
||||||
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.sum(Usage.actual_total_cost_usd).label("actual_total_cost"),
|
|
||||||
)
|
|
||||||
.filter(Usage.created_at >= today_utc)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_requests": total_requests,
|
"total_requests": total_requests,
|
||||||
"success_requests": total_requests - error_requests,
|
"success_requests": total_requests - error_requests,
|
||||||
"error_requests": error_requests,
|
"error_requests": error_requests,
|
||||||
"input_tokens": int(aggregated.input_tokens or 0),
|
"input_tokens": int(getattr(aggregated, "input_tokens", 0) or 0),
|
||||||
"output_tokens": int(aggregated.output_tokens or 0),
|
"output_tokens": int(getattr(aggregated, "output_tokens", 0) or 0),
|
||||||
"cache_creation_tokens": int(aggregated.cache_creation_tokens or 0),
|
"cache_creation_tokens": int(getattr(aggregated, "cache_creation_tokens", 0) or 0),
|
||||||
"cache_read_tokens": int(aggregated.cache_read_tokens or 0),
|
"cache_read_tokens": int(getattr(aggregated, "cache_read_tokens", 0) or 0),
|
||||||
"total_cost": float(aggregated.total_cost or 0),
|
"total_cost": float(getattr(aggregated, "total_cost", 0) or 0.0),
|
||||||
"actual_total_cost": float(aggregated.actual_total_cost or 0),
|
"actual_total_cost": float(getattr(aggregated, "actual_total_cost", 0) or 0.0),
|
||||||
|
"avg_response_time_ms": float(getattr(aggregated, "avg_response_time", 0) or 0.0),
|
||||||
|
"unique_models": int(getattr(aggregated, "unique_models", 0) or 0),
|
||||||
|
"unique_providers": int(getattr(aggregated, "unique_providers", 0) or 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_combined_stats(db: Session) -> dict:
|
def get_combined_stats(db: Session, today_stats: dict | None = None) -> dict:
|
||||||
"""获取合并后的统计数据(预聚合 + 今日实时)"""
|
"""获取合并后的统计数据(预聚合 + 今日实时)"""
|
||||||
summary = db.query(StatsSummary).first()
|
summary = db.query(StatsSummary).first()
|
||||||
today_stats = StatsAggregatorService.get_today_realtime_stats(db)
|
today_stats = today_stats or StatsAggregatorService.get_today_realtime_stats(db)
|
||||||
|
|
||||||
if not summary:
|
if not summary:
|
||||||
# 如果没有预聚合数据,返回今日数据
|
# 如果没有预聚合数据,返回今日数据
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ API密钥统计同步服务
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@ class SyncStatsService:
|
|||||||
else:
|
else:
|
||||||
# 分页处理,避免一次加载所有数据
|
# 分页处理,避免一次加载所有数据
|
||||||
offset = 0
|
offset = 0
|
||||||
api_keys = []
|
api_keys: list[ApiKey] = []
|
||||||
while True:
|
while True:
|
||||||
batch = db.query(ApiKey).offset(offset).limit(SyncStatsService.BATCH_SIZE).all()
|
batch = db.query(ApiKey).offset(offset).limit(SyncStatsService.BATCH_SIZE).all()
|
||||||
if not batch:
|
if not batch:
|
||||||
@@ -47,28 +49,53 @@ class SyncStatsService:
|
|||||||
api_keys.extend(batch)
|
api_keys.extend(batch)
|
||||||
offset += SyncStatsService.BATCH_SIZE
|
offset += SyncStatsService.BATCH_SIZE
|
||||||
|
|
||||||
|
# Pre-aggregate Usage stats in ONE query to avoid per-key N+1 scans.
|
||||||
|
# This is critical for large datasets (DB CPU killer otherwise).
|
||||||
|
usage_stats_map: dict[str, dict[str, Any]] = {}
|
||||||
|
if not api_key_id:
|
||||||
|
rows = (
|
||||||
|
db.query(
|
||||||
|
Usage.api_key_id,
|
||||||
|
func.count(Usage.id).label("requests"),
|
||||||
|
func.sum(Usage.total_cost_usd).label("cost"),
|
||||||
|
func.max(Usage.created_at).label("last_used"),
|
||||||
|
)
|
||||||
|
.filter(Usage.api_key_id.isnot(None))
|
||||||
|
.group_by(Usage.api_key_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
usage_stats_map = {
|
||||||
|
str(r.api_key_id): {
|
||||||
|
"requests": int(r.requests or 0),
|
||||||
|
"cost": float(r.cost or 0),
|
||||||
|
"last_used": r.last_used,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
if r.api_key_id is not None
|
||||||
|
}
|
||||||
|
|
||||||
for api_key in api_keys:
|
for api_key in api_keys:
|
||||||
try:
|
try:
|
||||||
# 计算实际的使用统计
|
if api_key_id:
|
||||||
stats = (
|
# 单 key 路径:直接查(数据量小)
|
||||||
db.query(
|
stats = (
|
||||||
func.count(Usage.id).label("requests"),
|
db.query(
|
||||||
func.sum(Usage.total_cost_usd).label("cost"),
|
func.count(Usage.id).label("requests"),
|
||||||
|
func.sum(Usage.total_cost_usd).label("cost"),
|
||||||
|
func.max(Usage.created_at).label("last_used"),
|
||||||
|
)
|
||||||
|
.filter(Usage.api_key_id == api_key.id)
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
.filter(Usage.api_key_id == api_key.id)
|
actual_requests = int(stats.requests or 0) if stats else 0
|
||||||
.first()
|
actual_cost = float(stats.cost or 0) if stats else 0.0
|
||||||
)
|
last_used_at = stats.last_used if stats else None
|
||||||
|
else:
|
||||||
actual_requests = stats.requests or 0
|
# 批量路径:使用预聚合结果
|
||||||
actual_cost = float(stats.cost or 0)
|
s = usage_stats_map.get(str(api_key.id)) or {}
|
||||||
|
actual_requests = int(s.get("requests") or 0)
|
||||||
# 获取最后使用时间
|
actual_cost = float(s.get("cost") or 0.0)
|
||||||
last_usage = (
|
last_used_at = s.get("last_used")
|
||||||
db.query(Usage.created_at)
|
|
||||||
.filter(Usage.api_key_id == api_key.id)
|
|
||||||
.order_by(Usage.created_at.desc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
# 检查是否需要更新
|
# 检查是否需要更新
|
||||||
needs_update = False
|
needs_update = False
|
||||||
@@ -86,8 +113,8 @@ class SyncStatsService:
|
|||||||
api_key.total_cost_usd = actual_cost
|
api_key.total_cost_usd = actual_cost
|
||||||
needs_update = True
|
needs_update = True
|
||||||
|
|
||||||
if last_usage and api_key.last_used_at != last_usage[0]:
|
if last_used_at and api_key.last_used_at != last_used_at:
|
||||||
api_key.last_used_at = last_usage[0]
|
api_key.last_used_at = last_used_at
|
||||||
needs_update = True
|
needs_update = True
|
||||||
|
|
||||||
result["synced"] += 1
|
result["synced"] += 1
|
||||||
|
|||||||
@@ -255,6 +255,8 @@ class VideoTaskPollerAdapter:
|
|||||||
task.progress_percent = 100
|
task.progress_percent = 100
|
||||||
if result.video_urls:
|
if result.video_urls:
|
||||||
task.video_urls = result.video_urls
|
task.video_urls = result.video_urls
|
||||||
|
if result.video_duration_seconds is not None:
|
||||||
|
task.video_duration_seconds = result.video_duration_seconds
|
||||||
self._attach_poll_raw_response(task, result)
|
self._attach_poll_raw_response(task, result)
|
||||||
elif result.status == VideoStatus.FAILED:
|
elif result.status == VideoStatus.FAILED:
|
||||||
task.status = VideoStatus.FAILED.value
|
task.status = VideoStatus.FAILED.value
|
||||||
@@ -376,6 +378,8 @@ class VideoTaskPollerAdapter:
|
|||||||
task.progress_percent = 100
|
task.progress_percent = 100
|
||||||
if result.video_urls:
|
if result.video_urls:
|
||||||
task.video_urls = result.video_urls
|
task.video_urls = result.video_urls
|
||||||
|
if result.video_duration_seconds is not None:
|
||||||
|
task.video_duration_seconds = result.video_duration_seconds
|
||||||
self._attach_poll_raw_response(task, result)
|
self._attach_poll_raw_response(task, result)
|
||||||
elif result.status == VideoStatus.FAILED:
|
elif result.status == VideoStatus.FAILED:
|
||||||
task.status = VideoStatus.FAILED.value
|
task.status = VideoStatus.FAILED.value
|
||||||
|
|||||||
@@ -1041,8 +1041,10 @@ class TaskService:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 2. master switch
|
# 2. global switch (from database config)
|
||||||
if not config.format_conversion_enabled:
|
from src.services.system.config import SystemConfigService
|
||||||
|
|
||||||
|
if not SystemConfigService.is_format_conversion_enabled(self.db):
|
||||||
skip_reason = "format_conversion_disabled"
|
skip_reason = "format_conversion_disabled"
|
||||||
candidate_info.update(
|
candidate_info.update(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -756,6 +757,67 @@ class UsageService:
|
|||||||
cache_ttl_minutes=cache_ttl_minutes,
|
cache_ttl_minutes=cache_ttl_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Metadata pruning configuration (ordered by priority - drop first to last)
|
||||||
|
_METADATA_PRUNE_KEYS: tuple[str, ...] = (
|
||||||
|
"raw_response_ref",
|
||||||
|
"poll_raw_response",
|
||||||
|
"trace",
|
||||||
|
"debug",
|
||||||
|
"dimensions",
|
||||||
|
"provider_response_headers",
|
||||||
|
"client_response_headers",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keys to preserve even under aggressive pruning
|
||||||
|
_METADATA_KEEP_KEYS: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"billing_snapshot",
|
||||||
|
"billing_shadow",
|
||||||
|
"billing_updated_at",
|
||||||
|
"_metadata_truncated",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _sanitize_request_metadata(cls, metadata: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Best-effort metadata pruning to reduce DB/CPU/memory pressure.
|
||||||
|
|
||||||
|
This is called right before persisting Usage rows (or updating request_metadata).
|
||||||
|
Pruning order is defined by `_METADATA_PRUNE_KEYS` (first key is dropped first).
|
||||||
|
"""
|
||||||
|
if not isinstance(metadata, dict) or not metadata:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
from src.config.settings import config
|
||||||
|
|
||||||
|
# Enforce global metadata size limit (best-effort)
|
||||||
|
max_bytes = int(getattr(config, "usage_metadata_max_bytes", 0) or 0)
|
||||||
|
if max_bytes <= 0:
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
def _size(d: dict[str, Any]) -> int:
|
||||||
|
try:
|
||||||
|
return len(json.dumps(d, ensure_ascii=False, default=str))
|
||||||
|
except Exception:
|
||||||
|
return len(str(d))
|
||||||
|
|
||||||
|
if _size(metadata) <= max_bytes:
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
# Progressive pruning (configurable order)
|
||||||
|
metadata["_metadata_truncated"] = True
|
||||||
|
|
||||||
|
for k in cls._METADATA_PRUNE_KEYS:
|
||||||
|
if k in metadata:
|
||||||
|
metadata.pop(k, None)
|
||||||
|
if _size(metadata) <= max_bytes:
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
# Fallback: keep only billing-related metadata
|
||||||
|
reduced = {k: metadata.get(k) for k in cls._METADATA_KEEP_KEYS if k in metadata}
|
||||||
|
return reduced
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _prepare_usage_record(
|
async def _prepare_usage_record(
|
||||||
cls,
|
cls,
|
||||||
@@ -779,35 +841,169 @@ class UsageService:
|
|||||||
params.db, params.provider_api_key_id, params.provider_id, params.api_format
|
params.db, params.provider_api_key_id, params.provider_id, params.api_format
|
||||||
)
|
)
|
||||||
|
|
||||||
# 计算成本
|
metadata = dict(params.metadata or {})
|
||||||
is_failed_request = params.status_code >= 400 or params.error_message is not None
|
is_failed_request = params.status_code >= 400 or params.error_message is not None
|
||||||
(
|
|
||||||
input_price,
|
# Resolve engine mode early to avoid unnecessary legacy computations.
|
||||||
output_price,
|
from src.services.billing.shadow import resolve_engine_mode
|
||||||
cache_creation_price,
|
|
||||||
cache_read_price,
|
engine_mode = resolve_engine_mode(params.provider, params.model)
|
||||||
request_price,
|
|
||||||
input_cost,
|
# Helper: compute billing task_type (billing domain)
|
||||||
output_cost,
|
billing_task_type = (params.request_type or "").lower()
|
||||||
cache_creation_cost,
|
if billing_task_type not in {"chat", "cli", "video", "image", "audio"}:
|
||||||
cache_read_cost,
|
billing_task_type = "chat"
|
||||||
cache_cost,
|
|
||||||
request_cost,
|
# Defaults (filled by either legacy or new path)
|
||||||
total_cost,
|
input_price: float = 0.0
|
||||||
_tier_index,
|
output_price: float = 0.0
|
||||||
) = await cls._calculate_costs(
|
cache_creation_price: float | None = None
|
||||||
db=params.db,
|
cache_read_price: float | None = None
|
||||||
provider=params.provider,
|
request_price: float | None = None
|
||||||
model=params.model,
|
|
||||||
input_tokens=params.input_tokens,
|
input_cost: float = 0.0
|
||||||
output_tokens=params.output_tokens,
|
output_cost: float = 0.0
|
||||||
cache_creation_input_tokens=params.cache_creation_input_tokens,
|
cache_creation_cost: float = 0.0
|
||||||
cache_read_input_tokens=params.cache_read_input_tokens,
|
cache_read_cost: float = 0.0
|
||||||
api_format=params.api_format,
|
cache_cost: float = 0.0
|
||||||
cache_ttl_minutes=params.cache_ttl_minutes,
|
request_cost: float = 0.0
|
||||||
use_tiered_pricing=params.use_tiered_pricing,
|
total_cost: float = 0.0
|
||||||
is_failed_request=is_failed_request,
|
|
||||||
)
|
# ------------------------------------------------------------------
|
||||||
|
# NEW: new engine as truth (no reconciliation)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
if engine_mode == "new":
|
||||||
|
from src.services.billing.service import BillingService
|
||||||
|
|
||||||
|
request_count = 0 if is_failed_request else 1
|
||||||
|
dims: dict[str, Any] = {
|
||||||
|
"input_tokens": params.input_tokens,
|
||||||
|
"output_tokens": params.output_tokens,
|
||||||
|
"cache_creation_input_tokens": params.cache_creation_input_tokens,
|
||||||
|
"cache_read_input_tokens": params.cache_read_input_tokens,
|
||||||
|
"request_count": request_count,
|
||||||
|
}
|
||||||
|
if params.cache_ttl_minutes is not None:
|
||||||
|
dims["cache_ttl_minutes"] = params.cache_ttl_minutes
|
||||||
|
# If tiered pricing is disabled, force first tier by using tier-key=0.
|
||||||
|
if not params.use_tiered_pricing:
|
||||||
|
dims["total_input_context"] = 0
|
||||||
|
|
||||||
|
billing = BillingService(params.db)
|
||||||
|
result = billing.calculate(
|
||||||
|
task_type=billing_task_type,
|
||||||
|
model=params.model,
|
||||||
|
provider_id=params.provider_id or "",
|
||||||
|
dimensions=dims,
|
||||||
|
strict_mode=None,
|
||||||
|
)
|
||||||
|
snap = result.snapshot
|
||||||
|
|
||||||
|
breakdown = snap.cost_breakdown or {}
|
||||||
|
input_cost = float(breakdown.get("input_cost", 0.0))
|
||||||
|
output_cost = float(breakdown.get("output_cost", 0.0))
|
||||||
|
cache_creation_cost = float(breakdown.get("cache_creation_cost", 0.0))
|
||||||
|
cache_read_cost = float(breakdown.get("cache_read_cost", 0.0))
|
||||||
|
request_cost = float(breakdown.get("request_cost", 0.0))
|
||||||
|
cache_cost = cache_creation_cost + cache_read_cost
|
||||||
|
total_cost = float(snap.total_cost or 0.0)
|
||||||
|
|
||||||
|
rv = snap.resolved_variables or {}
|
||||||
|
|
||||||
|
def _as_float(v: Any, d: float | None) -> float | None:
|
||||||
|
try:
|
||||||
|
if v is None:
|
||||||
|
return d
|
||||||
|
return float(v)
|
||||||
|
except Exception:
|
||||||
|
return d
|
||||||
|
|
||||||
|
input_price = _as_float(rv.get("input_price_per_1m"), 0.0) or 0.0
|
||||||
|
output_price = _as_float(rv.get("output_price_per_1m"), 0.0) or 0.0
|
||||||
|
cache_creation_price = _as_float(rv.get("cache_creation_price_per_1m"), None)
|
||||||
|
cache_read_price = _as_float(rv.get("cache_read_price_per_1m"), None)
|
||||||
|
request_price = _as_float(rv.get("price_per_request"), None)
|
||||||
|
|
||||||
|
# Audit snapshot for new engine (pruned later by _sanitize_request_metadata)
|
||||||
|
metadata["billing_snapshot"] = snap.to_dict()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# LEGACY truth (legacy or shadow or new_with_fallback)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
else:
|
||||||
|
(
|
||||||
|
input_price,
|
||||||
|
output_price,
|
||||||
|
cache_creation_price,
|
||||||
|
cache_read_price,
|
||||||
|
request_price,
|
||||||
|
input_cost,
|
||||||
|
output_cost,
|
||||||
|
cache_creation_cost,
|
||||||
|
cache_read_cost,
|
||||||
|
cache_cost,
|
||||||
|
request_cost,
|
||||||
|
total_cost,
|
||||||
|
_tier_index,
|
||||||
|
) = await cls._calculate_costs(
|
||||||
|
db=params.db,
|
||||||
|
provider=params.provider,
|
||||||
|
model=params.model,
|
||||||
|
input_tokens=params.input_tokens,
|
||||||
|
output_tokens=params.output_tokens,
|
||||||
|
cache_creation_input_tokens=params.cache_creation_input_tokens,
|
||||||
|
cache_read_input_tokens=params.cache_read_input_tokens,
|
||||||
|
api_format=params.api_format,
|
||||||
|
cache_ttl_minutes=params.cache_ttl_minutes,
|
||||||
|
use_tiered_pricing=params.use_tiered_pricing,
|
||||||
|
is_failed_request=is_failed_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Shadow mode: compute new snapshot and store in metadata.billing_shadow only.
|
||||||
|
if engine_mode == "shadow":
|
||||||
|
try:
|
||||||
|
from src.services.billing.shadow import CostBreakdown as ShadowCostBreakdown
|
||||||
|
from src.services.billing.shadow import (
|
||||||
|
ShadowBillingService,
|
||||||
|
)
|
||||||
|
|
||||||
|
legacy_truth = ShadowCostBreakdown(
|
||||||
|
input_cost=input_cost,
|
||||||
|
output_cost=output_cost,
|
||||||
|
cache_creation_cost=cache_creation_cost,
|
||||||
|
cache_read_cost=cache_read_cost,
|
||||||
|
request_cost=request_cost,
|
||||||
|
total_cost=total_cost,
|
||||||
|
)
|
||||||
|
|
||||||
|
shadow = ShadowBillingService(params.db)
|
||||||
|
shadow_result = shadow.calculate_with_shadow(
|
||||||
|
provider=params.provider,
|
||||||
|
provider_id=params.provider_id,
|
||||||
|
model=params.model,
|
||||||
|
task_type=billing_task_type,
|
||||||
|
api_format=params.api_format,
|
||||||
|
input_tokens=params.input_tokens,
|
||||||
|
output_tokens=params.output_tokens,
|
||||||
|
cache_creation_input_tokens=params.cache_creation_input_tokens,
|
||||||
|
cache_read_input_tokens=params.cache_read_input_tokens,
|
||||||
|
cache_ttl_minutes=params.cache_ttl_minutes,
|
||||||
|
legacy_truth=legacy_truth,
|
||||||
|
is_failed_request=is_failed_request,
|
||||||
|
)
|
||||||
|
if shadow_result.shadow_snapshot is not None:
|
||||||
|
metadata["billing_shadow"] = {
|
||||||
|
"engine_mode": shadow_result.engine_mode,
|
||||||
|
"truth_engine": shadow_result.truth_engine,
|
||||||
|
"was_fallback": shadow_result.was_fallback,
|
||||||
|
"comparison": shadow_result.comparison,
|
||||||
|
"snapshot": shadow_result.shadow_snapshot.to_dict(),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Shadow billing skipped/failed: {}", str(exc))
|
||||||
|
|
||||||
|
# Best-effort prune metadata to reduce DB/memory pressure.
|
||||||
|
metadata = cls._sanitize_request_metadata(metadata)
|
||||||
|
|
||||||
# 构建 Usage 参数
|
# 构建 Usage 参数
|
||||||
usage_params = cls._build_usage_params(
|
usage_params = cls._build_usage_params(
|
||||||
@@ -829,7 +1025,7 @@ class UsageService:
|
|||||||
first_byte_time_ms=params.first_byte_time_ms,
|
first_byte_time_ms=params.first_byte_time_ms,
|
||||||
status_code=params.status_code,
|
status_code=params.status_code,
|
||||||
error_message=params.error_message,
|
error_message=params.error_message,
|
||||||
metadata=params.metadata,
|
metadata=metadata,
|
||||||
request_headers=params.request_headers,
|
request_headers=params.request_headers,
|
||||||
request_body=params.request_body,
|
request_body=params.request_body,
|
||||||
provider_request_headers=params.provider_request_headers,
|
provider_request_headers=params.provider_request_headers,
|
||||||
@@ -2367,7 +2563,7 @@ class UsageService:
|
|||||||
metadata["billing_snapshot"] = billing_snapshot
|
metadata["billing_snapshot"] = billing_snapshot
|
||||||
if extra_metadata:
|
if extra_metadata:
|
||||||
metadata.update(extra_metadata)
|
metadata.update(extra_metadata)
|
||||||
usage.request_metadata = metadata
|
usage.request_metadata = cls._sanitize_request_metadata(metadata)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -2555,7 +2751,7 @@ class UsageService:
|
|||||||
if extra_metadata:
|
if extra_metadata:
|
||||||
metadata.update(extra_metadata)
|
metadata.update(extra_metadata)
|
||||||
metadata["billing_updated_at"] = now.isoformat()
|
metadata["billing_updated_at"] = now.isoformat()
|
||||||
usage.request_metadata = metadata
|
usage.request_metadata = cls._sanitize_request_metadata(metadata)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -61,10 +61,32 @@ class QueueTelemetryWriter(TelemetryWriter):
|
|||||||
request_id: str,
|
request_id: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
api_key_id: str,
|
api_key_id: str,
|
||||||
|
log_level: str = "basic",
|
||||||
|
sensitive_headers: list[str] | None = None,
|
||||||
|
max_request_body_size: int = 0,
|
||||||
|
max_response_body_size: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.request_id = request_id
|
self.request_id = request_id
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
self.api_key_id = api_key_id
|
self.api_key_id = api_key_id
|
||||||
|
self.log_level = (log_level or "basic").strip().lower()
|
||||||
|
self._sensitive_headers = sensitive_headers or [
|
||||||
|
"authorization",
|
||||||
|
"x-api-key",
|
||||||
|
"api-key",
|
||||||
|
"cookie",
|
||||||
|
"set-cookie",
|
||||||
|
]
|
||||||
|
self._max_request_body_size = int(max_request_body_size or 0)
|
||||||
|
self._max_response_body_size = int(max_response_body_size or 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def include_headers(self) -> bool:
|
||||||
|
return self.log_level in {"headers", "full"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def include_bodies(self) -> bool:
|
||||||
|
return self.log_level == "full"
|
||||||
|
|
||||||
async def record_success(self, **kwargs: Any) -> None:
|
async def record_success(self, **kwargs: Any) -> None:
|
||||||
await self._publish_event(UsageEventType.COMPLETED, **kwargs)
|
await self._publish_event(UsageEventType.COMPLETED, **kwargs)
|
||||||
@@ -101,20 +123,50 @@ class QueueTelemetryWriter(TelemetryWriter):
|
|||||||
logger.error(f"[usage-queue] XADD failed: {exc}")
|
logger.error(f"[usage-queue] XADD failed: {exc}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _truncate_body(self, value: Any) -> str | None:
|
def _mask_headers(self, headers: Any) -> Any:
|
||||||
"""将 body 序列化为字符串,超长时截断并添加标记"""
|
"""Mask sensitive headers before putting them into Redis."""
|
||||||
|
if not isinstance(headers, dict) or not headers:
|
||||||
|
return headers
|
||||||
|
sensitive = {h.lower() for h in self._sensitive_headers if isinstance(h, str) and h}
|
||||||
|
if not sensitive:
|
||||||
|
return headers
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for k, v in headers.items():
|
||||||
|
key = str(k)
|
||||||
|
if key.lower() in sensitive:
|
||||||
|
s = str(v)
|
||||||
|
if len(s) > 8:
|
||||||
|
out[key] = s[:4] + "****" + s[-4:]
|
||||||
|
else:
|
||||||
|
out[key] = "****"
|
||||||
|
else:
|
||||||
|
out[key] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _truncate_body(self, value: Any, *, max_size: int, is_request: bool) -> Any:
|
||||||
|
"""Best-effort truncate body based on SystemConfigService max_*_body_size."""
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
try:
|
limit = int(max_size or 0)
|
||||||
raw = json.dumps(value, ensure_ascii=False)
|
if limit <= 0:
|
||||||
except TypeError:
|
return value
|
||||||
raw = str(value)
|
|
||||||
max_bytes = config.usage_queue_body_max_bytes
|
body_str = json.dumps(value) if isinstance(value, (dict, list)) else str(value)
|
||||||
if max_bytes > 0 and len(raw) > max_bytes:
|
if len(body_str) <= limit:
|
||||||
# 截断并添加标记,预留 15 字符给标记
|
return value
|
||||||
truncate_at = max(0, max_bytes - 15)
|
|
||||||
raw = raw[:truncate_at] + "...[truncated]"
|
# Match SystemConfigService.truncate_body contract.
|
||||||
return raw
|
if isinstance(value, (dict, list)):
|
||||||
|
return {
|
||||||
|
"_truncated": True,
|
||||||
|
"_original_size": len(body_str),
|
||||||
|
"_content": body_str[:limit],
|
||||||
|
}
|
||||||
|
kind = "request" if is_request else "response"
|
||||||
|
return (
|
||||||
|
body_str[:limit]
|
||||||
|
+ f"\n... (truncated {kind} body, original size: {len(body_str)} bytes)"
|
||||||
|
)
|
||||||
|
|
||||||
def _build_event_data(self, **kwargs: Any) -> dict[str, Any]:
|
def _build_event_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
# 必需字段
|
# 必需字段
|
||||||
@@ -189,24 +241,36 @@ class QueueTelemetryWriter(TelemetryWriter):
|
|||||||
if kwargs.get("metadata"):
|
if kwargs.get("metadata"):
|
||||||
data["metadata"] = kwargs["metadata"]
|
data["metadata"] = kwargs["metadata"]
|
||||||
|
|
||||||
# 可选:Headers
|
# Optional: Headers (masked)
|
||||||
if config.usage_queue_include_headers:
|
if self.include_headers:
|
||||||
if kwargs.get("request_headers"):
|
if kwargs.get("request_headers"):
|
||||||
data["request_headers"] = kwargs["request_headers"]
|
data["request_headers"] = self._mask_headers(kwargs["request_headers"])
|
||||||
if kwargs.get("provider_request_headers"):
|
if kwargs.get("provider_request_headers"):
|
||||||
data["provider_request_headers"] = kwargs["provider_request_headers"]
|
data["provider_request_headers"] = self._mask_headers(
|
||||||
|
kwargs["provider_request_headers"]
|
||||||
|
)
|
||||||
if kwargs.get("response_headers"):
|
if kwargs.get("response_headers"):
|
||||||
data["response_headers"] = kwargs["response_headers"]
|
data["response_headers"] = self._mask_headers(kwargs["response_headers"])
|
||||||
if kwargs.get("client_response_headers"):
|
if kwargs.get("client_response_headers"):
|
||||||
data["client_response_headers"] = kwargs["client_response_headers"]
|
data["client_response_headers"] = self._mask_headers(
|
||||||
|
kwargs["client_response_headers"]
|
||||||
|
)
|
||||||
|
|
||||||
# 可选:Bodies
|
# Optional: Bodies (truncated)
|
||||||
if config.usage_queue_include_bodies:
|
if self.include_bodies:
|
||||||
request_body = self._truncate_body(kwargs.get("request_body"))
|
request_body = self._truncate_body(
|
||||||
response_body = self._truncate_body(kwargs.get("response_body"))
|
kwargs.get("request_body"),
|
||||||
if request_body:
|
max_size=self._max_request_body_size,
|
||||||
|
is_request=True,
|
||||||
|
)
|
||||||
|
response_body = self._truncate_body(
|
||||||
|
kwargs.get("response_body"),
|
||||||
|
max_size=self._max_response_body_size,
|
||||||
|
is_request=False,
|
||||||
|
)
|
||||||
|
if request_body is not None:
|
||||||
data["request_body"] = request_body
|
data["request_body"] = request_body
|
||||||
if response_body:
|
if response_body is not None:
|
||||||
data["response_body"] = response_body
|
data["response_body"] = response_body
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""缓存装饰器工具"""
|
"""缓存装饰器工具"""
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -24,7 +25,22 @@ def _is_api_context(obj: Any) -> bool:
|
|||||||
return hasattr(obj, "user") and hasattr(obj, "db")
|
return hasattr(obj, "user") and hasattr(obj, "db")
|
||||||
|
|
||||||
|
|
||||||
def cache_result(key_prefix: str, ttl: int = 60, user_specific: bool = True) -> Callable:
|
def _hash_vary(vary: dict[str, Any]) -> str:
|
||||||
|
"""Build a short stable hash for cache key variations."""
|
||||||
|
try:
|
||||||
|
raw = json.dumps(vary, sort_keys=True, ensure_ascii=False, default=str)
|
||||||
|
except Exception:
|
||||||
|
raw = str(vary)
|
||||||
|
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def cache_result(
|
||||||
|
key_prefix: str,
|
||||||
|
ttl: int = 60,
|
||||||
|
user_specific: bool = True,
|
||||||
|
*,
|
||||||
|
vary_by: list[str] | None = None,
|
||||||
|
) -> Callable:
|
||||||
"""
|
"""
|
||||||
缓存函数结果的装饰器
|
缓存函数结果的装饰器
|
||||||
|
|
||||||
@@ -68,13 +84,22 @@ def cache_result(key_prefix: str, ttl: int = 60, user_specific: bool = True) ->
|
|||||||
else:
|
else:
|
||||||
cache_key = f"{key_prefix}:global"
|
cache_key = f"{key_prefix}:global"
|
||||||
|
|
||||||
# 如果有额外的参数(如 days),添加到键中
|
# If there are extra parameters, include them into the key.
|
||||||
# 从 adapter_self 获取(dataclass 属性)
|
# - When vary_by is provided: hash the selected attributes to keep key short.
|
||||||
|
# - Otherwise keep backward-compatible "days/limit" suffix behavior.
|
||||||
if adapter_self and hasattr(adapter_self, "__dict__"):
|
if adapter_self and hasattr(adapter_self, "__dict__"):
|
||||||
for attr_name in ["days", "limit"]:
|
if vary_by:
|
||||||
if hasattr(adapter_self, attr_name):
|
vary: dict[str, Any] = {}
|
||||||
attr_value = getattr(adapter_self, attr_name)
|
for attr_name in vary_by:
|
||||||
cache_key += f":{attr_name}:{attr_value}"
|
if hasattr(adapter_self, attr_name):
|
||||||
|
vary[attr_name] = getattr(adapter_self, attr_name)
|
||||||
|
if vary:
|
||||||
|
cache_key += f":v:{_hash_vary(vary)}"
|
||||||
|
else:
|
||||||
|
for attr_name in ["days", "limit"]:
|
||||||
|
if hasattr(adapter_self, attr_name):
|
||||||
|
attr_value = getattr(adapter_self, attr_name)
|
||||||
|
cache_key += f":{attr_name}:{attr_value}"
|
||||||
|
|
||||||
# 尝试从缓存获取
|
# 尝试从缓存获取
|
||||||
cached = await redis_client.get(cache_key)
|
cached = await redis_client.get(cache_key)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ def test_cli_format_convertible_when_converter_supports_full() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_global_switch_disabled_blocks_conversion() -> None:
|
def test_global_switch_disabled_blocks_conversion() -> None:
|
||||||
"""全局开关关闭时(环境变量 FORMAT_CONVERSION_ENABLED=false)阻止转换"""
|
"""全局开关关闭时阻止转换"""
|
||||||
ok, needs_conv, reason = is_format_compatible(
|
ok, needs_conv, reason = is_format_compatible(
|
||||||
"claude:chat",
|
"claude:chat",
|
||||||
"openai:chat",
|
"openai:chat",
|
||||||
@@ -57,7 +57,7 @@ def test_global_switch_disabled_blocks_conversion() -> None:
|
|||||||
)
|
)
|
||||||
assert ok is False
|
assert ok is False
|
||||||
assert needs_conv is False
|
assert needs_conv is False
|
||||||
assert reason and ("全局" in reason or "FORMAT_CONVERSION_ENABLED" in reason)
|
assert reason and "格式转换已禁用" in reason
|
||||||
|
|
||||||
|
|
||||||
def test_endpoint_config_none_blocks_conversion() -> None:
|
def test_endpoint_config_none_blocks_conversion() -> None:
|
||||||
@@ -223,7 +223,7 @@ def test_claude_cli_to_claude_blocked_when_global_switch_disabled() -> None:
|
|||||||
registry=MagicMock(),
|
registry=MagicMock(),
|
||||||
)
|
)
|
||||||
assert ok is False
|
assert ok is False
|
||||||
assert reason and ("全局" in reason or "FORMAT_CONVERSION_ENABLED" in reason)
|
assert reason and "格式转换已禁用" in reason
|
||||||
|
|
||||||
|
|
||||||
def test_claude_cli_to_claude_blocked_when_endpoint_not_configured() -> None:
|
def test_claude_cli_to_claude_blocked_when_endpoint_not_configured() -> None:
|
||||||
@@ -313,7 +313,7 @@ def test_openai_cli_to_openai_fails_without_converter() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_openai_cli_to_openai_blocked_when_global_switch_disabled() -> None:
|
def test_openai_cli_to_openai_blocked_when_global_switch_disabled() -> None:
|
||||||
"""同族转换(OPENAI/OPENAI_CLI)也受全局开关限制(环境变量 FORMAT_CONVERSION_ENABLED=false)"""
|
"""同族转换(OPENAI/OPENAI_CLI)也受全局开关限制"""
|
||||||
registry = MagicMock()
|
registry = MagicMock()
|
||||||
registry.can_convert_full.return_value = True
|
registry.can_convert_full.return_value = True
|
||||||
|
|
||||||
@@ -327,7 +327,7 @@ def test_openai_cli_to_openai_blocked_when_global_switch_disabled() -> None:
|
|||||||
)
|
)
|
||||||
assert ok is False
|
assert ok is False
|
||||||
assert needs_conv is False
|
assert needs_conv is False
|
||||||
assert reason and ("全局" in reason or "FORMAT_CONVERSION_ENABLED" in reason)
|
assert reason and "格式转换已禁用" in reason
|
||||||
|
|
||||||
|
|
||||||
def test_openai_cli_to_openai_blocked_when_endpoint_disabled() -> None:
|
def test_openai_cli_to_openai_blocked_when_endpoint_disabled() -> None:
|
||||||
|
|||||||
301
tests/services/billing/test_default_rules.py
Normal file
301
tests/services/billing/test_default_rules.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from src.models.database import GlobalModel, Model
|
||||||
|
from src.services.billing.default_rules import DefaultBillingRuleGenerator
|
||||||
|
from src.services.billing.formula_engine import FormulaEngine
|
||||||
|
from src.services.billing.rule_service import BillingRuleService
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultBillingRuleGenerator:
|
||||||
|
def test_default_rule_basic_chat_cost(self) -> None:
|
||||||
|
global_model = GlobalModel(
|
||||||
|
name="test-model",
|
||||||
|
display_name="Test Model",
|
||||||
|
is_active=True,
|
||||||
|
default_price_per_request=0.01,
|
||||||
|
default_tiered_pricing={
|
||||||
|
"tiers": [
|
||||||
|
{
|
||||||
|
"up_to": None,
|
||||||
|
"input_price_per_1m": 3.0,
|
||||||
|
"output_price_per_1m": 15.0,
|
||||||
|
# cache prices intentionally omitted (legacy derives from input price)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
rule = DefaultBillingRuleGenerator.generate_for_model(
|
||||||
|
global_model=global_model,
|
||||||
|
model=None,
|
||||||
|
task_type="chat",
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = FormulaEngine()
|
||||||
|
result = engine.evaluate(
|
||||||
|
expression=rule.expression,
|
||||||
|
variables=rule.variables,
|
||||||
|
dimensions={
|
||||||
|
"input_tokens": 1000,
|
||||||
|
"output_tokens": 500,
|
||||||
|
"cache_creation_tokens": 200,
|
||||||
|
"cache_read_tokens": 300,
|
||||||
|
"request_count": 1,
|
||||||
|
# tier key
|
||||||
|
"total_input_context": 1000 + 300,
|
||||||
|
},
|
||||||
|
dimension_mappings=rule.dimension_mappings,
|
||||||
|
strict_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "complete"
|
||||||
|
assert abs(float(result.cost) - 0.02134) < 1e-9
|
||||||
|
|
||||||
|
def test_default_rule_tiered_pricing_uses_total_input_context(self) -> None:
|
||||||
|
global_model = GlobalModel(
|
||||||
|
name="tiered-model",
|
||||||
|
display_name="Tiered Model",
|
||||||
|
is_active=True,
|
||||||
|
default_price_per_request=None,
|
||||||
|
default_tiered_pricing={
|
||||||
|
"tiers": [
|
||||||
|
{"up_to": 200000, "input_price_per_1m": 3.0, "output_price_per_1m": 15.0},
|
||||||
|
{"up_to": None, "input_price_per_1m": 1.5, "output_price_per_1m": 7.5},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
rule = DefaultBillingRuleGenerator.generate_for_model(
|
||||||
|
global_model=global_model,
|
||||||
|
model=None,
|
||||||
|
task_type="chat",
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = FormulaEngine()
|
||||||
|
result = engine.evaluate(
|
||||||
|
expression=rule.expression,
|
||||||
|
variables=rule.variables,
|
||||||
|
dimensions={
|
||||||
|
"input_tokens": 250000,
|
||||||
|
"output_tokens": 10000,
|
||||||
|
"cache_creation_tokens": 0,
|
||||||
|
"cache_read_tokens": 0,
|
||||||
|
"request_count": 1,
|
||||||
|
"total_input_context": 250000,
|
||||||
|
},
|
||||||
|
dimension_mappings=rule.dimension_mappings,
|
||||||
|
strict_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 250000 * 1.5 / 1M = 0.375
|
||||||
|
# 10000 * 7.5 / 1M = 0.075
|
||||||
|
assert result.status == "complete"
|
||||||
|
assert abs(float(result.cost) - 0.45) < 1e-9
|
||||||
|
|
||||||
|
def test_default_rule_cache_ttl_pricing_overrides_cache_read_price(self) -> None:
|
||||||
|
global_model = GlobalModel(
|
||||||
|
name="ttl-model",
|
||||||
|
display_name="TTL Model",
|
||||||
|
is_active=True,
|
||||||
|
default_price_per_request=0.0,
|
||||||
|
default_tiered_pricing={
|
||||||
|
"tiers": [
|
||||||
|
{
|
||||||
|
"up_to": None,
|
||||||
|
"input_price_per_1m": 3.0,
|
||||||
|
"output_price_per_1m": 15.0,
|
||||||
|
"cache_read_price_per_1m": 0.3,
|
||||||
|
"cache_ttl_pricing": [
|
||||||
|
{"ttl_minutes": 5, "cache_read_price_per_1m": 0.3},
|
||||||
|
{"ttl_minutes": 60, "cache_read_price_per_1m": 0.5},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
rule = DefaultBillingRuleGenerator.generate_for_model(
|
||||||
|
global_model=global_model,
|
||||||
|
model=None,
|
||||||
|
task_type="chat",
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = FormulaEngine()
|
||||||
|
result = engine.evaluate(
|
||||||
|
expression=rule.expression,
|
||||||
|
variables=rule.variables,
|
||||||
|
dimensions={
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"cache_creation_tokens": 0,
|
||||||
|
"cache_read_tokens": 1000,
|
||||||
|
"cache_ttl_minutes": 60,
|
||||||
|
"request_count": 1,
|
||||||
|
"total_input_context": 0 + 1000,
|
||||||
|
},
|
||||||
|
dimension_mappings=rule.dimension_mappings,
|
||||||
|
strict_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# TTL=60 should use cache_read_price_per_1m=0.5
|
||||||
|
# 1000 * 0.5 / 1M = 0.0005
|
||||||
|
assert result.status == "complete"
|
||||||
|
assert abs(float(result.cost) - 0.0005) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
class TestBillingRuleServiceDefaultFallback:
|
||||||
|
def test_find_rule_returns_default_for_chat_when_no_db_rule(self) -> None:
|
||||||
|
from src.services.billing.cache import BillingCache
|
||||||
|
|
||||||
|
BillingCache.invalidate_all()
|
||||||
|
|
||||||
|
global_model = GlobalModel(
|
||||||
|
id="gm-1",
|
||||||
|
name="test-model",
|
||||||
|
display_name="Test Model",
|
||||||
|
is_active=True,
|
||||||
|
default_price_per_request=0.0,
|
||||||
|
default_tiered_pricing={
|
||||||
|
"tiers": [{"up_to": None, "input_price_per_1m": 3.0, "output_price_per_1m": 15.0}]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
model_obj = Model(
|
||||||
|
id="m-1",
|
||||||
|
provider_id="p-1",
|
||||||
|
global_model_id="gm-1",
|
||||||
|
provider_model_name="provider-test-model",
|
||||||
|
is_active=True,
|
||||||
|
tiered_pricing=None,
|
||||||
|
price_per_request=None,
|
||||||
|
)
|
||||||
|
model_obj.global_model = global_model
|
||||||
|
|
||||||
|
# Build a mock Session with deterministic query().filter().first() chain.
|
||||||
|
q_global = MagicMock()
|
||||||
|
q_global.filter.return_value.first.return_value = global_model
|
||||||
|
|
||||||
|
q_model = MagicMock()
|
||||||
|
q_model.filter.return_value.first.return_value = model_obj
|
||||||
|
|
||||||
|
q_rule_model = MagicMock()
|
||||||
|
q_rule_model.filter.return_value.first.return_value = None
|
||||||
|
|
||||||
|
q_rule_global = MagicMock()
|
||||||
|
q_rule_global.filter.return_value.first.return_value = None
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
db.query.side_effect = [q_global, q_model, q_rule_model, q_rule_global]
|
||||||
|
|
||||||
|
lookup = BillingRuleService.find_rule(
|
||||||
|
db,
|
||||||
|
provider_id="p-1",
|
||||||
|
model_name="test-model",
|
||||||
|
task_type="chat",
|
||||||
|
)
|
||||||
|
assert lookup is not None
|
||||||
|
assert lookup.scope == "default"
|
||||||
|
assert lookup.rule.id == "__default__"
|
||||||
|
assert lookup.effective_task_type == "chat"
|
||||||
|
|
||||||
|
# Cached: second call should not touch db.query again.
|
||||||
|
db.query.reset_mock()
|
||||||
|
lookup2 = BillingRuleService.find_rule(
|
||||||
|
db,
|
||||||
|
provider_id="p-1",
|
||||||
|
model_name="test-model",
|
||||||
|
task_type="chat",
|
||||||
|
)
|
||||||
|
assert lookup2 is not None
|
||||||
|
assert lookup2.scope == "default"
|
||||||
|
assert db.query.call_count == 0
|
||||||
|
|
||||||
|
def test_find_rule_returns_default_for_video_when_require_rule_false(self) -> None:
|
||||||
|
from src.config.settings import config
|
||||||
|
from src.services.billing.cache import BillingCache
|
||||||
|
|
||||||
|
BillingCache.invalidate_all()
|
||||||
|
old_require = config.billing_require_rule
|
||||||
|
config.billing_require_rule = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
global_model = GlobalModel(
|
||||||
|
id="gm-2",
|
||||||
|
name="video-model",
|
||||||
|
display_name="Video Model",
|
||||||
|
is_active=True,
|
||||||
|
default_price_per_request=0.0,
|
||||||
|
default_tiered_pricing={
|
||||||
|
"tiers": [
|
||||||
|
{"up_to": None, "input_price_per_1m": 3.0, "output_price_per_1m": 15.0}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
q_global = MagicMock()
|
||||||
|
q_global.filter.return_value.first.return_value = global_model
|
||||||
|
|
||||||
|
q_rule_global = MagicMock()
|
||||||
|
q_rule_global.filter.return_value.first.return_value = None
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
# provider_id omitted -> only GlobalModel query + global BillingRule query
|
||||||
|
db.query.side_effect = [q_global, q_rule_global]
|
||||||
|
|
||||||
|
lookup = BillingRuleService.find_rule(
|
||||||
|
db,
|
||||||
|
provider_id=None,
|
||||||
|
model_name="video-model",
|
||||||
|
task_type="video",
|
||||||
|
)
|
||||||
|
assert lookup is not None
|
||||||
|
assert lookup.scope == "default"
|
||||||
|
assert lookup.rule.id == "__default__"
|
||||||
|
assert lookup.effective_task_type == "video"
|
||||||
|
finally:
|
||||||
|
config.billing_require_rule = old_require
|
||||||
|
|
||||||
|
def test_find_rule_returns_template_for_video_when_require_rule_true(self) -> None:
|
||||||
|
from src.config.settings import config
|
||||||
|
from src.services.billing.cache import BillingCache
|
||||||
|
|
||||||
|
BillingCache.invalidate_all()
|
||||||
|
old_require = config.billing_require_rule
|
||||||
|
config.billing_require_rule = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
global_model = GlobalModel(
|
||||||
|
id="gm-3",
|
||||||
|
name="video-model-2",
|
||||||
|
display_name="Video Model 2",
|
||||||
|
is_active=True,
|
||||||
|
default_price_per_request=0.0,
|
||||||
|
default_tiered_pricing={
|
||||||
|
"tiers": [
|
||||||
|
{"up_to": None, "input_price_per_1m": 3.0, "output_price_per_1m": 15.0}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
q_global = MagicMock()
|
||||||
|
q_global.filter.return_value.first.return_value = global_model
|
||||||
|
|
||||||
|
q_rule_global = MagicMock()
|
||||||
|
q_rule_global.filter.return_value.first.return_value = None
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
db.query.side_effect = [q_global, q_rule_global]
|
||||||
|
|
||||||
|
lookup = BillingRuleService.find_rule(
|
||||||
|
db,
|
||||||
|
provider_id=None,
|
||||||
|
model_name="video-model-2",
|
||||||
|
task_type="video",
|
||||||
|
)
|
||||||
|
assert lookup is not None
|
||||||
|
assert lookup.scope == "default"
|
||||||
|
assert lookup.rule.id == "__default__"
|
||||||
|
# Universal Billing Rule is now used for all task types
|
||||||
|
assert lookup.rule.name == "Universal Billing Rule"
|
||||||
|
finally:
|
||||||
|
config.billing_require_rule = old_require
|
||||||
@@ -34,7 +34,7 @@ class TestDimensionCollectorRuntime:
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
dims = runtime.collect(
|
dims = runtime.collect(
|
||||||
collectors=collectors,
|
collectors=collectors, # type: ignore[arg-type]
|
||||||
inp=DimensionCollectInput(
|
inp=DimensionCollectInput(
|
||||||
response={"usageMetadata": {"promptTokenCount": 123}},
|
response={"usageMetadata": {"promptTokenCount": 123}},
|
||||||
),
|
),
|
||||||
@@ -57,7 +57,7 @@ class TestDimensionCollectorRuntime:
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
dims = runtime.collect(
|
dims = runtime.collect(
|
||||||
collectors=collectors,
|
collectors=collectors, # type: ignore[arg-type]
|
||||||
inp=DimensionCollectInput(metadata={"result": {"file_size_bytes": 1048576}}),
|
inp=DimensionCollectInput(metadata={"result": {"file_size_bytes": 1048576}}),
|
||||||
)
|
)
|
||||||
assert abs(dims["file_size_mb"] - 1.0) < 1e-9
|
assert abs(dims["file_size_mb"] - 1.0) < 1e-9
|
||||||
@@ -98,7 +98,7 @@ class TestDimensionCollectorRuntime:
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
dims = runtime.collect(
|
dims = runtime.collect(
|
||||||
collectors=collectors,
|
collectors=collectors, # type: ignore[arg-type]
|
||||||
inp=DimensionCollectInput(
|
inp=DimensionCollectInput(
|
||||||
request={"usage": {"input_tokens": 100, "cache_read_tokens": 20}}
|
request={"usage": {"input_tokens": 100, "cache_read_tokens": 20}}
|
||||||
),
|
),
|
||||||
@@ -110,52 +110,13 @@ class TestDimensionCollectorRuntime:
|
|||||||
|
|
||||||
class TestDimensionCollectorService:
|
class TestDimensionCollectorService:
|
||||||
def test_video_fallback_merges_base_collectors(self) -> None:
|
def test_video_fallback_merges_base_collectors(self) -> None:
|
||||||
|
from src.services.billing.cache import BillingCache
|
||||||
|
|
||||||
|
BillingCache.invalidate_all()
|
||||||
|
|
||||||
|
# code-only: openai:video should fall back to openai:chat video collectors shipped in code
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
|
||||||
video_collectors = [
|
|
||||||
DimensionCollector(
|
|
||||||
api_format="openai:video",
|
|
||||||
task_type="video",
|
|
||||||
dimension_name="duration_seconds",
|
|
||||||
source_type="metadata",
|
|
||||||
source_path="task.duration_seconds",
|
|
||||||
value_type="int",
|
|
||||||
priority=0,
|
|
||||||
is_enabled=True,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
base_collectors = [
|
|
||||||
# Should be kept (dimension not present in video_collectors)
|
|
||||||
DimensionCollector(
|
|
||||||
api_format="openai:chat",
|
|
||||||
task_type="video",
|
|
||||||
dimension_name="resolution",
|
|
||||||
source_type="metadata",
|
|
||||||
source_path="task.resolution",
|
|
||||||
value_type="string",
|
|
||||||
priority=0,
|
|
||||||
is_enabled=True,
|
|
||||||
),
|
|
||||||
# Should be ignored (dimension already present in video_collectors)
|
|
||||||
DimensionCollector(
|
|
||||||
api_format="openai:chat",
|
|
||||||
task_type="video",
|
|
||||||
dimension_name="duration_seconds",
|
|
||||||
source_type="metadata",
|
|
||||||
source_path="task.duration_seconds",
|
|
||||||
value_type="int",
|
|
||||||
priority=0,
|
|
||||||
is_enabled=True,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
q1 = MagicMock()
|
|
||||||
q1.filter.return_value.all.return_value = video_collectors
|
|
||||||
q2 = MagicMock()
|
|
||||||
q2.filter.return_value.all.return_value = base_collectors
|
|
||||||
db.query.side_effect = [q1, q2]
|
|
||||||
|
|
||||||
svc = DimensionCollectorService(db)
|
svc = DimensionCollectorService(db)
|
||||||
result = svc.list_enabled_collectors(api_format="openai:video", task_type="video")
|
result = svc.list_enabled_collectors(api_format="openai:video", task_type="video")
|
||||||
|
|
||||||
assert [c.dimension_name for c in result] == ["duration_seconds", "resolution"]
|
assert "video_size_bytes" in [c.dimension_name for c in result]
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class TestFormulaEngine:
|
|||||||
)
|
)
|
||||||
assert result.status == "complete"
|
assert result.status == "complete"
|
||||||
assert result.missing_required == []
|
assert result.missing_required == []
|
||||||
assert abs(result.cost - 0.25) < 1e-9
|
assert abs(float(result.cost) - 0.25) < 1e-9
|
||||||
|
|
||||||
def test_required_dimension_missing_non_strict(self) -> None:
|
def test_required_dimension_missing_non_strict(self) -> None:
|
||||||
engine = FormulaEngine()
|
engine = FormulaEngine()
|
||||||
@@ -72,7 +72,7 @@ class TestFormulaEngine:
|
|||||||
strict_mode=False,
|
strict_mode=False,
|
||||||
)
|
)
|
||||||
assert result.status == "incomplete"
|
assert result.status == "incomplete"
|
||||||
assert result.cost == 0.0
|
assert float(result.cost) == 0.0
|
||||||
assert result.missing_required == ["duration_seconds"]
|
assert result.missing_required == ["duration_seconds"]
|
||||||
|
|
||||||
def test_required_dimension_missing_strict_raises(self) -> None:
|
def test_required_dimension_missing_strict_raises(self) -> None:
|
||||||
@@ -127,7 +127,7 @@ class TestFormulaEngine:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert result.status == "complete"
|
assert result.status == "complete"
|
||||||
assert result.cost == 0.0
|
assert float(result.cost) == 0.0
|
||||||
|
|
||||||
def test_tiered_mapping(self) -> None:
|
def test_tiered_mapping(self) -> None:
|
||||||
engine = FormulaEngine()
|
engine = FormulaEngine()
|
||||||
@@ -148,7 +148,7 @@ class TestFormulaEngine:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert result.status == "complete"
|
assert result.status == "complete"
|
||||||
assert result.cost == 3.0
|
assert float(result.cost) == 3.0
|
||||||
|
|
||||||
result = engine.evaluate(
|
result = engine.evaluate(
|
||||||
expression="input_price",
|
expression="input_price",
|
||||||
@@ -166,4 +166,4 @@ class TestFormulaEngine:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert result.status == "complete"
|
assert result.status == "complete"
|
||||||
assert result.cost == 1.5
|
assert float(result.cost) == 1.5
|
||||||
|
|||||||
112
tests/services/billing/test_shadow_billing.py
Normal file
112
tests/services/billing/test_shadow_billing.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.config.settings import config
|
||||||
|
from src.services.billing.schema import BillingSnapshot, CostResult
|
||||||
|
from src.services.billing.shadow import CostBreakdown, ShadowBillingService
|
||||||
|
|
||||||
|
|
||||||
|
class TestShadowBillingServiceModeResolution:
|
||||||
|
def test_get_engine_mode_exact_and_wildcard(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(config, "billing_engine", "legacy", raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
config,
|
||||||
|
"billing_engine_overrides",
|
||||||
|
'{"anthropic/*": "shadow", "openai/gpt-4o": "new"}',
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
svc = ShadowBillingService(MagicMock())
|
||||||
|
assert svc.get_engine_mode("openai", "gpt-4o") == "new"
|
||||||
|
assert svc.get_engine_mode("anthropic", "claude-3-5-sonnet") == "shadow"
|
||||||
|
assert svc.get_engine_mode("other", "x") == "legacy"
|
||||||
|
|
||||||
|
|
||||||
|
class TestShadowBillingServiceExecution:
|
||||||
|
def test_legacy_mode_skips_new_engine(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(config, "billing_engine", "legacy", raising=False)
|
||||||
|
monkeypatch.setattr(config, "billing_engine_overrides", "{}", raising=False)
|
||||||
|
|
||||||
|
svc = ShadowBillingService(MagicMock())
|
||||||
|
# Guard: if new engine calculate gets called, fail.
|
||||||
|
svc._new_billing = MagicMock()
|
||||||
|
svc._new_billing.calculate.side_effect = AssertionError(
|
||||||
|
"new engine should not run in legacy mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
legacy_truth = CostBreakdown(
|
||||||
|
input_cost=0.1,
|
||||||
|
output_cost=0.2,
|
||||||
|
cache_creation_cost=0.0,
|
||||||
|
cache_read_cost=0.0,
|
||||||
|
request_cost=0.0,
|
||||||
|
total_cost=0.3,
|
||||||
|
)
|
||||||
|
|
||||||
|
res = svc.calculate_with_shadow(
|
||||||
|
provider="openai",
|
||||||
|
provider_id="p-1",
|
||||||
|
model="gpt-4o",
|
||||||
|
task_type="chat",
|
||||||
|
api_format="openai:chat",
|
||||||
|
input_tokens=1,
|
||||||
|
output_tokens=1,
|
||||||
|
legacy_truth=legacy_truth,
|
||||||
|
is_failed_request=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.engine_mode == "legacy"
|
||||||
|
assert res.truth_engine == "legacy"
|
||||||
|
assert res.shadow_snapshot is None
|
||||||
|
assert res.truth_breakdown.total_cost == 0.3
|
||||||
|
|
||||||
|
def test_shadow_mode_returns_snapshot_and_keeps_legacy_truth(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(config, "billing_engine", "shadow", raising=False)
|
||||||
|
monkeypatch.setattr(config, "billing_engine_overrides", "{}", raising=False)
|
||||||
|
monkeypatch.setattr(config, "billing_diff_threshold_usd", 0.0001, raising=False)
|
||||||
|
|
||||||
|
svc = ShadowBillingService(MagicMock())
|
||||||
|
|
||||||
|
# Stub new engine output
|
||||||
|
snapshot = BillingSnapshot(
|
||||||
|
resolved_dimensions={"input_tokens": 1},
|
||||||
|
resolved_variables={"input_price_per_1m": "3.0"},
|
||||||
|
cost_breakdown={"input_cost": 0.003},
|
||||||
|
total_cost=0.003,
|
||||||
|
status="complete",
|
||||||
|
calculated_at="2026-02-02T00:00:00Z",
|
||||||
|
)
|
||||||
|
svc._new_billing = MagicMock()
|
||||||
|
svc._new_billing.calculate.return_value = CostResult(
|
||||||
|
cost=0.003, status="complete", snapshot=snapshot
|
||||||
|
)
|
||||||
|
|
||||||
|
legacy_truth = CostBreakdown(
|
||||||
|
input_cost=0.004,
|
||||||
|
output_cost=0.0,
|
||||||
|
cache_creation_cost=0.0,
|
||||||
|
cache_read_cost=0.0,
|
||||||
|
request_cost=0.0,
|
||||||
|
total_cost=0.004,
|
||||||
|
)
|
||||||
|
|
||||||
|
res = svc.calculate_with_shadow(
|
||||||
|
provider="openai",
|
||||||
|
provider_id="p-1",
|
||||||
|
model="gpt-4o",
|
||||||
|
task_type="chat",
|
||||||
|
api_format="openai:chat",
|
||||||
|
input_tokens=1,
|
||||||
|
output_tokens=0,
|
||||||
|
legacy_truth=legacy_truth,
|
||||||
|
is_failed_request=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.engine_mode == "shadow"
|
||||||
|
assert res.truth_engine == "legacy"
|
||||||
|
assert res.shadow_snapshot is not None
|
||||||
|
assert res.truth_breakdown.total_cost == 0.004
|
||||||
|
assert "diff_usd" in res.comparison
|
||||||
@@ -54,8 +54,7 @@ async def test_build_candidates_allows_cross_format_when_endpoint_accepts_and_ov
|
|||||||
client_format="claude:chat",
|
client_format="claude:chat",
|
||||||
model_name="dummy-model",
|
model_name="dummy-model",
|
||||||
affinity_key=None,
|
affinity_key=None,
|
||||||
global_conversion_enabled=False, # DB 全局覆盖关闭
|
global_conversion_enabled=True, # 全局开关开启
|
||||||
master_conversion_enabled=True, # ENV 总闸开启(默认)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(candidates) == 1
|
assert len(candidates) == 1
|
||||||
@@ -88,8 +87,7 @@ async def test_build_candidates_blocks_cross_format_when_master_switch_off() ->
|
|||||||
client_format="claude:chat",
|
client_format="claude:chat",
|
||||||
model_name="dummy-model",
|
model_name="dummy-model",
|
||||||
affinity_key=None,
|
affinity_key=None,
|
||||||
global_conversion_enabled=False,
|
global_conversion_enabled=False, # 全局开关关闭
|
||||||
master_conversion_enabled=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert candidates == []
|
assert candidates == []
|
||||||
@@ -118,8 +116,7 @@ async def test_build_candidates_includes_cross_format_when_enabled() -> None:
|
|||||||
client_format="claude:chat",
|
client_format="claude:chat",
|
||||||
model_name="dummy-model",
|
model_name="dummy-model",
|
||||||
affinity_key=None,
|
affinity_key=None,
|
||||||
global_conversion_enabled=True, # DB 全局覆盖开启:跳过端点检查
|
global_conversion_enabled=True, # 全局开关开启:跳过端点检查
|
||||||
master_conversion_enabled=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(candidates) == 1
|
assert len(candidates) == 1
|
||||||
@@ -157,8 +154,7 @@ async def test_exact_matches_rank_before_convertible() -> None:
|
|||||||
client_format="claude:chat",
|
client_format="claude:chat",
|
||||||
model_name="dummy-model",
|
model_name="dummy-model",
|
||||||
affinity_key=None,
|
affinity_key=None,
|
||||||
global_conversion_enabled=False,
|
global_conversion_enabled=True, # 全局开关开启
|
||||||
master_conversion_enabled=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(candidates) == 2
|
assert len(candidates) == 2
|
||||||
|
|||||||
@@ -62,18 +62,15 @@ async def test_queue_writer_publishes_event(monkeypatch):
|
|||||||
|
|
||||||
old_stream_key = config.usage_queue_stream_key
|
old_stream_key = config.usage_queue_stream_key
|
||||||
old_maxlen = config.usage_queue_stream_maxlen
|
old_maxlen = config.usage_queue_stream_maxlen
|
||||||
old_include_headers = config.usage_queue_include_headers
|
|
||||||
old_include_bodies = config.usage_queue_include_bodies
|
|
||||||
try:
|
try:
|
||||||
config.usage_queue_stream_key = "usage:events:test"
|
config.usage_queue_stream_key = "usage:events:test"
|
||||||
config.usage_queue_stream_maxlen = 0
|
config.usage_queue_stream_maxlen = 0
|
||||||
config.usage_queue_include_headers = False
|
|
||||||
config.usage_queue_include_bodies = False
|
|
||||||
|
|
||||||
writer = QueueTelemetryWriter(
|
writer = QueueTelemetryWriter(
|
||||||
request_id="req-2",
|
request_id="req-2",
|
||||||
user_id="user-1",
|
user_id="user-1",
|
||||||
api_key_id="key-1",
|
api_key_id="key-1",
|
||||||
|
log_level="basic",
|
||||||
)
|
)
|
||||||
await writer.record_success(
|
await writer.record_success(
|
||||||
provider="test",
|
provider="test",
|
||||||
@@ -86,8 +83,6 @@ async def test_queue_writer_publishes_event(monkeypatch):
|
|||||||
finally:
|
finally:
|
||||||
config.usage_queue_stream_key = old_stream_key
|
config.usage_queue_stream_key = old_stream_key
|
||||||
config.usage_queue_stream_maxlen = old_maxlen
|
config.usage_queue_stream_maxlen = old_maxlen
|
||||||
config.usage_queue_include_headers = old_include_headers
|
|
||||||
config.usage_queue_include_bodies = old_include_bodies
|
|
||||||
|
|
||||||
assert dummy.calls
|
assert dummy.calls
|
||||||
key, fields, _, _ = dummy.calls[0]
|
key, fields, _, _ = dummy.calls[0]
|
||||||
@@ -310,31 +305,28 @@ async def test_queue_writer_include_headers_bodies(monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr("src.services.usage.telemetry_writer.get_redis_client", _get_redis_client)
|
monkeypatch.setattr("src.services.usage.telemetry_writer.get_redis_client", _get_redis_client)
|
||||||
|
|
||||||
old_include_headers = config.usage_queue_include_headers
|
writer = QueueTelemetryWriter(
|
||||||
old_include_bodies = config.usage_queue_include_bodies
|
request_id="req-full",
|
||||||
try:
|
user_id="user-1",
|
||||||
config.usage_queue_include_headers = True
|
api_key_id="key-1",
|
||||||
config.usage_queue_include_bodies = True
|
log_level="full",
|
||||||
|
sensitive_headers=["authorization"],
|
||||||
writer = QueueTelemetryWriter(
|
max_request_body_size=0,
|
||||||
request_id="req-full",
|
max_response_body_size=0,
|
||||||
user_id="user-1",
|
)
|
||||||
api_key_id="key-1",
|
await writer.record_success(
|
||||||
)
|
provider="test",
|
||||||
await writer.record_success(
|
model="model",
|
||||||
provider="test",
|
request_headers={"Authorization": "Bearer xxx"},
|
||||||
model="model",
|
response_headers={"Content-Type": "application/json"},
|
||||||
request_headers={"Authorization": "Bearer xxx"},
|
request_body={"messages": [{"role": "user", "content": "hi"}]},
|
||||||
response_headers={"Content-Type": "application/json"},
|
response_body={"choices": [{"message": {"content": "hello"}}]},
|
||||||
request_body={"messages": [{"role": "user", "content": "hi"}]},
|
)
|
||||||
response_body={"choices": [{"message": {"content": "hello"}}]},
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
config.usage_queue_include_headers = old_include_headers
|
|
||||||
config.usage_queue_include_bodies = old_include_bodies
|
|
||||||
|
|
||||||
event = UsageEvent.from_stream_fields(dummy.calls[0][1])
|
event = UsageEvent.from_stream_fields(dummy.calls[0][1])
|
||||||
assert event.data["request_headers"]["Authorization"] == "Bearer xxx"
|
# Sensitive header should be masked before going into Redis
|
||||||
|
assert event.data["request_headers"]["Authorization"].startswith("Bear")
|
||||||
|
assert "****" in event.data["request_headers"]["Authorization"]
|
||||||
assert "request_body" in event.data
|
assert "request_body" in event.data
|
||||||
assert "response_body" in event.data
|
assert "response_body" in event.data
|
||||||
|
|
||||||
@@ -378,31 +370,26 @@ async def test_queue_writer_body_truncation(monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr("src.services.usage.telemetry_writer.get_redis_client", _get_redis_client)
|
monkeypatch.setattr("src.services.usage.telemetry_writer.get_redis_client", _get_redis_client)
|
||||||
|
|
||||||
old_include_bodies = config.usage_queue_include_bodies
|
writer = QueueTelemetryWriter(
|
||||||
old_max_bytes = config.usage_queue_body_max_bytes
|
request_id="req-trunc",
|
||||||
try:
|
user_id="user-1",
|
||||||
config.usage_queue_include_bodies = True
|
api_key_id="key-1",
|
||||||
config.usage_queue_body_max_bytes = 50
|
log_level="full",
|
||||||
|
max_request_body_size=50,
|
||||||
writer = QueueTelemetryWriter(
|
max_response_body_size=0,
|
||||||
request_id="req-trunc",
|
)
|
||||||
user_id="user-1",
|
long_body = {"content": "x" * 1000}
|
||||||
api_key_id="key-1",
|
await writer.record_success(
|
||||||
)
|
provider="test",
|
||||||
long_body = {"content": "x" * 1000}
|
model="model",
|
||||||
await writer.record_success(
|
request_body=long_body,
|
||||||
provider="test",
|
)
|
||||||
model="model",
|
|
||||||
request_body=long_body,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
config.usage_queue_include_bodies = old_include_bodies
|
|
||||||
config.usage_queue_body_max_bytes = old_max_bytes
|
|
||||||
|
|
||||||
event = UsageEvent.from_stream_fields(dummy.calls[0][1])
|
event = UsageEvent.from_stream_fields(dummy.calls[0][1])
|
||||||
body_str = event.data["request_body"]
|
body = event.data["request_body"]
|
||||||
assert len(body_str) <= 50
|
assert isinstance(body, dict)
|
||||||
assert body_str.endswith("...[truncated]")
|
assert body.get("_truncated") is True
|
||||||
|
assert len(body.get("_content") or "") <= 50
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
203
tests/test_sora.py
Normal file
203
tests/test_sora.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Sora Video Generation Test Script
|
||||||
|
|
||||||
|
This script demonstrates video generation using OpenAI's Sora API.
|
||||||
|
It sends a request to generate a video, polls for completion, and downloads the result.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
export OPENAI_API_KEY="your-api-key"
|
||||||
|
python test_sora.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# OpenAI API Base URL
|
||||||
|
BASE_URL = "http://localhost:8084/v1"
|
||||||
|
|
||||||
|
# Default polling interval in seconds
|
||||||
|
POLL_INTERVAL = 10
|
||||||
|
|
||||||
|
os.environ["OPENAI_API_KEY"] = "sk-PCr5oXZNKb9HcyzYqTIMvr8zXsIBK3WS"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_video(
|
||||||
|
api_key: str,
|
||||||
|
prompt: str,
|
||||||
|
model: str = "sora-2",
|
||||||
|
size: str = "1920x1080",
|
||||||
|
duration: int = 10,
|
||||||
|
n: int = 1,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Send a request to generate a video and return the video job ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: OpenAI API key
|
||||||
|
prompt: Text prompt for video generation
|
||||||
|
model: Model name to use (default: sora-2)
|
||||||
|
size: Video resolution (default: 1920x1080)
|
||||||
|
duration: Video duration in seconds (default: 10)
|
||||||
|
n: Number of videos to generate (default: 1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Video job ID for polling status
|
||||||
|
"""
|
||||||
|
url = f"{BASE_URL}/videos"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"size": size,
|
||||||
|
"duration": str(duration),
|
||||||
|
"n": n,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"Sending video generation request to {model}...")
|
||||||
|
print(f" Size: {size}, Duration: {duration}s")
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
video_id = data.get("id")
|
||||||
|
|
||||||
|
if not video_id:
|
||||||
|
raise ValueError(f"No video ID in response: {data}")
|
||||||
|
|
||||||
|
print(f"Video job started: {video_id}")
|
||||||
|
print(f" Status: {data.get('status')}")
|
||||||
|
return video_id
|
||||||
|
|
||||||
|
|
||||||
|
def poll_video(api_key: str, video_id: str, poll_interval: int = POLL_INTERVAL) -> dict:
|
||||||
|
"""
|
||||||
|
Poll the video job status until the video is ready.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: OpenAI API key
|
||||||
|
video_id: Video job ID from generate_video
|
||||||
|
poll_interval: Seconds between polls (default: 10)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Final response dict containing the video metadata
|
||||||
|
"""
|
||||||
|
url = f"{BASE_URL}/videos/{video_id}"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"Polling video job status (every {poll_interval}s)...")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
response = requests.get(url, headers=headers, timeout=60)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
status = data.get("status", "unknown")
|
||||||
|
progress = data.get("progress", 0)
|
||||||
|
|
||||||
|
print(f" Status: {status}, Progress: {progress}%")
|
||||||
|
|
||||||
|
if status == "completed":
|
||||||
|
print("Video generation completed!")
|
||||||
|
print(f" Duration: {data.get('seconds')}s")
|
||||||
|
print(f" Size: {data.get('size')}")
|
||||||
|
print(f" Expires at: {data.get('expires_at')}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
if status == "failed":
|
||||||
|
error = data.get("error", {})
|
||||||
|
raise RuntimeError(f"Video generation failed: {error.get('message', error)}")
|
||||||
|
|
||||||
|
if status == "cancelled":
|
||||||
|
raise RuntimeError("Video generation was cancelled")
|
||||||
|
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
|
||||||
|
def download_video(api_key: str, video_id: str, output_path: str = "sora_output.mp4", variant: str | None = None) -> str:
|
||||||
|
"""
|
||||||
|
Download the generated video content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: OpenAI API key
|
||||||
|
video_id: Video job ID
|
||||||
|
output_path: Path to save the video (default: sora_output.mp4)
|
||||||
|
variant: Optional variant to download (defaults to MP4 video)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the downloaded video
|
||||||
|
"""
|
||||||
|
url = f"{BASE_URL}/videos/{video_id}/content"
|
||||||
|
if variant:
|
||||||
|
url = f"{url}?variant={variant}"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"Downloading video content...")
|
||||||
|
response = requests.get(url, headers=headers, allow_redirects=True, timeout=300, stream=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
file_size = os.path.getsize(output_path)
|
||||||
|
print(f"Video saved to: {output_path} ({file_size / 1024 / 1024:.2f} MB)")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Main entry point."""
|
||||||
|
# Get API key from environment
|
||||||
|
api_key = os.environ.get("OPENAI_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
print("Error: OPENAI_API_KEY environment variable not set", file=sys.stderr)
|
||||||
|
print("Usage: export OPENAI_API_KEY='your-api-key' && python test_sora.py", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Default prompt
|
||||||
|
prompt = "A calico cat playing a piano on stage"
|
||||||
|
|
||||||
|
# Allow custom prompt via command line argument
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
prompt = " ".join(sys.argv[1:])
|
||||||
|
print(f"Using custom prompt: {prompt}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Step 1: Start video generation
|
||||||
|
video_id = generate_video(api_key, prompt)
|
||||||
|
|
||||||
|
# Step 2: Poll until complete
|
||||||
|
final_response = poll_video(api_key, video_id)
|
||||||
|
|
||||||
|
# Step 3: Download video content
|
||||||
|
download_video(api_key, video_id)
|
||||||
|
|
||||||
|
print("\nVideo generation complete!")
|
||||||
|
print(f"Video ID: {video_id}")
|
||||||
|
print(f"Model: {final_response.get('model')}")
|
||||||
|
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
print(f"HTTP Error: {e}", file=sys.stderr)
|
||||||
|
if e.response is not None:
|
||||||
|
print(f"Response: {e.response.text}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
207
tests/test_veo.py
Normal file
207
tests/test_veo.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Veo Video Generation Test Script
|
||||||
|
|
||||||
|
This script demonstrates video generation using Google's Veo API.
|
||||||
|
It sends a request to generate a video, polls for completion, and downloads the result.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
export GEMINI_API_KEY="your-api-key"
|
||||||
|
python test_veo.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Gemini API Base URL
|
||||||
|
BASE_URL = "http://localhost:8084/v1beta"
|
||||||
|
|
||||||
|
# Default polling interval in seconds
|
||||||
|
POLL_INTERVAL = 10
|
||||||
|
|
||||||
|
os.environ["GEMINI_API_KEY"] = "sk-PCr5oXZNKb9HcyzYqTIMvr8zXsIBK3WS"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_video(api_key: str, prompt: str, model: str = "veo-3.1-fast-generate-preview") -> str:
|
||||||
|
"""
|
||||||
|
Send a request to generate a video and return the operation name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: Gemini API key
|
||||||
|
prompt: Text prompt for video generation
|
||||||
|
model: Model name to use (default: veo-3.1-generate-preview)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Operation name for polling status
|
||||||
|
"""
|
||||||
|
url = f"{BASE_URL}/models/{model}:predictLongRunning"
|
||||||
|
headers = {
|
||||||
|
"x-goog-api-key": api_key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"instances": [
|
||||||
|
{
|
||||||
|
"prompt": prompt,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"Sending video generation request to {model}...")
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
operation_name = data.get("name")
|
||||||
|
|
||||||
|
if not operation_name:
|
||||||
|
raise ValueError(f"No operation name in response: {data}")
|
||||||
|
|
||||||
|
print(f"Operation started: {operation_name}")
|
||||||
|
return operation_name
|
||||||
|
|
||||||
|
|
||||||
|
def poll_operation(api_key: str, operation_name: str, poll_interval: int = POLL_INTERVAL) -> dict:
|
||||||
|
"""
|
||||||
|
Poll the operation status until the video is ready.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: Gemini API key
|
||||||
|
operation_name: Operation name from generate_video
|
||||||
|
poll_interval: Seconds between polls (default: 10)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Final response dict containing the video URI
|
||||||
|
"""
|
||||||
|
url = f"{BASE_URL}/{operation_name}"
|
||||||
|
headers = {
|
||||||
|
"x-goog-api-key": api_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"Polling operation status (every {poll_interval}s)...")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
response = requests.get(url, headers=headers, timeout=60)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
is_done = data.get("done", False)
|
||||||
|
|
||||||
|
if is_done:
|
||||||
|
print("Operation completed!")
|
||||||
|
|
||||||
|
# Check for errors
|
||||||
|
if "error" in data:
|
||||||
|
error = data["error"]
|
||||||
|
raise RuntimeError(f"Operation failed: {error.get('message', error)}")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
# Show progress if available
|
||||||
|
metadata = data.get("metadata", {})
|
||||||
|
if metadata:
|
||||||
|
progress = metadata.get("progress", "unknown")
|
||||||
|
print(f" Progress: {progress}%")
|
||||||
|
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
|
||||||
|
def download_video(api_key: str, video_uri: str, output_path: str = "dialogue_example.mp4") -> str:
|
||||||
|
"""
|
||||||
|
Download the generated video.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: Gemini API key
|
||||||
|
video_uri: URI of the generated video
|
||||||
|
output_path: Path to save the video (default: dialogue_example.mp4)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the downloaded video
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
"x-goog-api-key": api_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"Downloading video from: {video_uri}")
|
||||||
|
response = requests.get(video_uri, headers=headers, allow_redirects=True, timeout=300, stream=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
file_size = os.path.getsize(output_path)
|
||||||
|
print(f"Video saved to: {output_path} ({file_size / 1024 / 1024:.2f} MB)")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def extract_video_uri(response: dict) -> str:
|
||||||
|
"""
|
||||||
|
Extract the video URI from the operation response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response: Final operation response dict
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Video download URI
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
video_response = response["response"]["generateVideoResponse"]
|
||||||
|
samples = video_response["generatedSamples"]
|
||||||
|
video_uri = samples[0]["video"]["uri"]
|
||||||
|
return video_uri
|
||||||
|
except (KeyError, IndexError) as e:
|
||||||
|
raise ValueError(f"Could not extract video URI from response: {response}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Main entry point."""
|
||||||
|
# Get API key from environment
|
||||||
|
api_key = os.environ.get("GEMINI_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
print("Error: GEMINI_API_KEY environment variable not set", file=sys.stderr)
|
||||||
|
print("Usage: export GEMINI_API_KEY='your-api-key' && python test_veo.py", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Default prompt (same as the bash script)
|
||||||
|
prompt = (
|
||||||
|
"A close up of two people staring at a cryptic drawing on a wall, "
|
||||||
|
"torchlight flickering. A man murmurs, \"This must be it. That's the secret code.\" "
|
||||||
|
"The woman looks at him and whispering excitedly, \"What did you find?\""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Allow custom prompt via command line argument
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
prompt = " ".join(sys.argv[1:])
|
||||||
|
print(f"Using custom prompt: {prompt}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Step 1: Start video generation
|
||||||
|
operation_name = generate_video(api_key, prompt)
|
||||||
|
|
||||||
|
# Step 2: Poll until complete
|
||||||
|
final_response = poll_operation(api_key, operation_name)
|
||||||
|
|
||||||
|
# Step 3: Extract video URI and download
|
||||||
|
video_uri = extract_video_uri(final_response)
|
||||||
|
download_video(api_key, video_uri)
|
||||||
|
|
||||||
|
print("\nVideo generation complete!")
|
||||||
|
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
print(f"HTTP Error: {e}", file=sys.stderr)
|
||||||
|
if e.response is not None:
|
||||||
|
print(f"Response: {e.response.text}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user