feat: 视频计费增强与影子计费系统

This commit is contained in:
fawney19
2026-02-03 18:48:39 +08:00
parent ed68aebfb0
commit 00442c41fa
84 changed files with 6507 additions and 1678 deletions

View File

@@ -49,14 +49,23 @@ ADMIN_PASSWORD=admin123456
# #
# required 维度缺失时是否拒绝请求/标记任务失败(默认 falsecost=0 + 标记 incomplete # required 维度缺失时是否拒绝请求/标记任务失败(默认 falsecost=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

View File

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

View 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")

View File

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

View File

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

View File

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

View File

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

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

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

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

View 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'

View File

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

View File

@@ -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')
// 处理背景点击 // 处理背景点击

View File

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

View File

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

View File

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

View File

@@ -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: [] }
] ]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
# ==================== 缓存亲和性分析 ==================== # ==================== 缓存亲和性分析 ====================

View File

@@ -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
# 尝试从多个来源获取 tokenquery 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
), ),

View File

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

View File

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

View File

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

View File

@@ -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={}: {}",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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__ = [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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))
# 存储 (可选) # 存储 (可选)

View File

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

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

View 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]] = []

View 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,
},
]

View 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,
},
]

View 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,
},
]

View 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,
},
]

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

View File

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

View File

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

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

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

View 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.
"""

View 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,
)
]

View File

@@ -2,7 +2,7 @@
BillingRule 查找逻辑 BillingRule 查找逻辑
查找顺序(与 .plans/humming-seeking-marble.md 一致): 查找顺序(与 .plans/humming-seeking-marble.md 一致):
1) ModelProvider 级)→ 2) GlobalModel默认 1) 读取 GlobalModel/Model 价格配置 → 2) 使用代码内置计费模板生成规则config-file mode
注意: 注意:
- CLI 在计费域等同于 chatbilling_rules.task_type 不含 "cli" - CLI 在计费域等同于 chatbilling_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

View 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

View File

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

View File

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

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

View File

@@ -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:
# 全局开启:不分组,直接按优先级模式排序 # 全局开启:不分组,直接按优先级模式排序

View File

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

View File

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

View File

@@ -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]:
"""脱敏敏感请求头""" """脱敏敏感请求头"""

View File

@@ -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:
# 如果没有预聚合数据,返回今日数据 # 如果没有预聚合数据,返回今日数据

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View 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

View File

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

View File

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