refactor: 继续拆分大型模块并增强模块注册健壮性

后端:
- chat_handler_base 错误处理函数提取到 chat_error_utils 子模块
- CLI mixin 引入 CliHandlerProtocol 协议类改善类型标注
- aware_scheduler 拆分为 _candidate_builder 和 _candidate_sorter 子模块
- usage recording 拆分为 _billing_integration 和 _recording_helpers 子模块
- ModuleRegistry 添加循环依赖检测,将写操作从查询方法分离到 reconcile_module_state
- 修正 plugin manager 入度注释

前端:
- 路由守卫逻辑拆分为独立 guards 模块
- ProviderManagement 拆分为 TableHeader/TableRow/BalanceCell/MobileCard 子组件
- SystemSettings 拆分为多个 Section 子组件和 composables
- 提取 useEndpointStatus/useProviderBalance/useProviderFilters composables

测试适配重构后的子模块结构
This commit is contained in:
fawney19
2026-02-14 20:06:38 +08:00
parent 676e918edc
commit 8a670f5524
51 changed files with 7095 additions and 5359 deletions

View File

@@ -0,0 +1,150 @@
<template>
<!-- 余额正在加载中 -->
<div
v-if="provider.ops_configured && isBalanceLoading(provider.id)"
class="flex items-center gap-1.5 text-xs text-muted-foreground"
>
<Loader2 class="h-3 w-3 animate-spin" />
<span>加载中...</span>
</div>
<!-- 显示从上游 API 查询的余额 -->
<div
v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
class="flex items-center gap-2 text-xs"
>
<!-- 余额文字balance + points 分开显示或普通余额 -->
<template
v-for="(bd, idx) in [getProviderBalanceBreakdown(provider.id)]"
:key="idx"
>
<div
v-if="bd"
class="min-w-[4.5rem] tabular-nums leading-tight"
>
<div class="font-semibold text-foreground/90">
${{ bd.balance.toFixed(2) }}
</div>
<div class="text-muted-foreground/70 text-[10px]">
${{ bd.points.toFixed(2) }}
</div>
</div>
<span
v-else
class="font-semibold text-foreground/90 min-w-[4.5rem] tabular-nums"
>
{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}
</span>
</template>
<!-- 窗口限额 + 签到状态 + Cookie 失效警告 -->
<div
v-if="getProviderBalanceExtra(provider.id, provider.ops_architecture_id).length > 0 || getProviderCheckin(provider.id) || getProviderCookieExpired(provider.id)"
class="text-muted-foreground/70 space-y-0.5"
>
<!-- 限额进度条 + 倒计时每行一个 -->
<template
v-for="item in getProviderBalanceExtra(provider.id, provider.ops_architecture_id)"
:key="item.label"
>
<div
:title="item.tooltip"
class="flex items-center gap-1"
>
<span class="text-[10px] text-muted-foreground/60 w-4">{{ item.label }}</span>
<div class="w-12 h-1.5 bg-border rounded-full overflow-hidden">
<div
class="h-full rounded-full"
:class="[
item.percent !== undefined && item.percent >= 50 ? 'bg-green-500' :
item.percent !== undefined && item.percent >= 20 ? 'bg-amber-500' : 'bg-red-500'
]"
:style="{ width: `${item.percent ?? 0}%` }"
/>
</div>
<span class="text-[10px] text-muted-foreground/50 w-7 text-right tabular-nums">{{ item.value }}</span>
<span
v-if="item.resetsAt"
class="text-[10px] text-muted-foreground/40 w-14 text-right tabular-nums"
>{{ formatResetCountdown(item.resetsAt) }}</span>
</div>
</template>
<!-- Cookie 失效警告 -->
<div
v-if="getProviderCookieExpired(provider.id)"
class="flex items-center gap-1"
>
<span
class="text-[10px] text-amber-600 dark:text-amber-500"
:title="getProviderCookieExpired(provider.id)?.message"
>签到 Cookie 已失效</span>
</div>
<!-- 签到状态 -->
<div
v-else-if="getProviderCheckin(provider.id)"
class="flex items-center gap-1.5"
>
<span
v-if="getProviderCheckin(provider.id)?.success !== false"
class="text-[10px] text-muted-foreground/60"
:title="getProviderCheckin(provider.id)?.message"
>已签到</span>
<span
v-else
class="text-[10px] text-destructive/70"
:title="getProviderCheckin(provider.id)?.message"
>签到失败</span>
</div>
</div>
</div>
<!-- 余额查询失败时显示错误 -->
<div
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
class="text-xs text-destructive/80"
:title="getProviderBalanceError(provider.id)?.message"
>
{{ getProviderBalanceError(provider.id)?.message }}
</div>
<!-- 显示本地配置的月度配额 -->
<div
v-else-if="provider.billing_type === 'monthly_quota'"
class="space-y-0.5 text-xs"
>
<Badge
variant="outline"
class="text-[10px] font-normal border-border/50"
>
{{ formatBillingType(provider.billing_type) }}
</Badge>
<div class="text-muted-foreground/70 pt-0.5">
<span
class="font-semibold"
:class="getQuotaUsedColorClass(provider)"
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / <span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
</div>
</div>
<span
v-else
class="text-xs text-muted-foreground/50"
>-</span>
</template>
<script setup lang="ts">
import { Loader2 } from 'lucide-vue-next'
import Badge from '@/components/ui/badge.vue'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import { formatBillingType } from '@/utils/format'
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
defineProps<{
provider: ProviderWithEndpointsSummary
isBalanceLoading: (providerId: string) => boolean
getProviderBalance: (providerId: string) => { available: number | null; currency: string } | null
getProviderBalanceBreakdown: (providerId: string) => { balance: number; points: number; currency: string } | null
getProviderBalanceError: (providerId: string) => { status: string; message: string } | null
getProviderCheckin: (providerId: string) => { success: boolean | null; message: string } | null
getProviderCookieExpired: (providerId: string) => { expired: boolean; message: string } | null
getProviderBalanceExtra: (providerId: string, architectureId?: string) => BalanceExtraItem[]
formatBalanceDisplay: (balance: { available: number | null; currency: string } | null) => string
formatResetCountdown: (resetsAt: number) => string
getQuotaUsedColorClass: (provider: ProviderWithEndpointsSummary) => string
}>()
</script>

View File

@@ -0,0 +1,305 @@
<template>
<div
class="p-4 space-y-3 hover:bg-muted/20 transition-colors cursor-pointer"
@click="$emit('viewDetail', provider.id)"
>
<!-- 第一行名称 + 状态 + 操作 -->
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0 space-y-0.5">
<div class="flex items-center gap-1.5">
<span class="font-medium text-foreground truncate">{{ provider.name }}</span>
<a
v-if="provider.website"
:href="provider.website"
target="_blank"
rel="noopener noreferrer"
class="text-muted-foreground hover:text-primary transition-colors shrink-0"
:title="provider.website"
@click.stop
>
<ExternalLink class="w-3.5 h-3.5" />
</a>
<Badge
:variant="provider.is_active ? 'success' : 'secondary'"
class="text-xs shrink-0"
>
{{ provider.is_active ? '活跃' : '停用' }}
</Badge>
</div>
<!-- 内联编辑备注 (移动端) -->
<div
v-if="editingDescriptionId === provider.id"
data-desc-editor
class="flex items-center gap-1 max-w-[180px]"
@click.stop
>
<input
v-model="localDescriptionValue"
v-auto-focus
class="flex-1 min-w-0 text-xs px-1.5 py-0.5 rounded border border-border bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
placeholder="输入备注..."
@keydown="handleDescriptionKeydown"
>
<button
class="shrink-0 p-0.5 rounded hover:bg-muted text-primary"
title="保存"
@click="handleSave"
>
<Check class="w-3.5 h-3.5" />
</button>
<button
class="shrink-0 p-0.5 rounded hover:bg-muted text-muted-foreground"
title="取消"
@click="handleCancel"
>
<X class="w-3.5 h-3.5" />
</button>
</div>
<span
v-else-if="provider.description"
class="text-xs text-muted-foreground truncate block max-w-[120px] group/desc cursor-pointer hover:text-foreground/70 transition-colors"
:title="provider.description"
@click="handleStartEdit"
>{{ provider.description }} <Pencil class="w-3 h-3 inline-block opacity-0 group-hover/desc:opacity-50 transition-opacity" /></span>
<span
v-else
class="text-xs text-muted-foreground cursor-pointer hover:text-foreground/70 transition-colors"
@click="handleStartEdit"
>添加备注</span>
</div>
<div
class="flex items-center gap-0.5 shrink-0"
@click.stop
>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="查看详情"
@click="$emit('viewDetail', provider.id)"
>
<Eye class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="编辑"
@click="$emit('editProvider', provider)"
>
<Edit class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="扩展操作配置"
@click="$emit('openOpsConfig', provider)"
>
<KeyRound class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('toggleStatus', provider)"
>
<Power class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('deleteProvider', provider)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<!-- 第二行计费类型 + 余额/配额 + 资源统计 -->
<div class="flex flex-wrap items-center gap-3 text-xs">
<Badge
variant="outline"
class="text-xs font-normal border-border/50"
>
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
</Badge>
<!-- 余额加载中 -->
<span
v-if="provider.ops_configured && isBalanceLoading(provider.id)"
class="text-muted-foreground flex items-center gap-1"
>
<Loader2 class="h-3 w-3 animate-spin" />
加载中...
</span>
<!-- 余额从上游 API 查询 -->
<span
v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
class="text-muted-foreground"
>
余额 <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span>
<!-- Cookie 失效警告 -->
<span
v-if="getProviderCookieExpired(provider.id)"
class="ml-1 text-amber-600 dark:text-amber-500"
:title="getProviderCookieExpired(provider.id)?.message"
>签到 Cookie 已失效</span>
<!-- 签到状态显示 -->
<span
v-else-if="getProviderCheckin(provider.id) && getProviderCheckin(provider.id)?.success !== false"
class="ml-1 text-muted-foreground"
:title="getProviderCheckin(provider.id)?.message"
>已签到</span>
<span
v-else-if="getProviderCheckin(provider.id)?.success === false"
class="ml-1 text-destructive/70"
:title="getProviderCheckin(provider.id)?.message"
>签到失败</span>
</span>
<!-- 余额查询失败时显示错误 -->
<span
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
class="text-destructive/80"
:title="getProviderBalanceError(provider.id)?.message"
>
{{ getProviderBalanceError(provider.id)?.message }}
</span>
<!-- 本地配额 -->
<span
v-else-if="provider.billing_type === 'monthly_quota'"
class="text-muted-foreground"
>
配额 <span
class="font-semibold"
:class="getQuotaUsedColorClass(provider)"
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span>/<span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
</span>
<span class="text-muted-foreground">
端点 {{ provider.active_endpoints }}/{{ provider.total_endpoints }}
</span>
<span class="text-muted-foreground">
密钥 {{ provider.active_keys }}/{{ provider.total_keys }}
</span>
<span class="text-muted-foreground">
模型 {{ provider.active_models }}/{{ provider.total_models }}
</span>
</div>
<!-- 第三行端点健康 -->
<div
v-if="provider.endpoint_health_details && provider.endpoint_health_details.length > 0"
class="grid grid-cols-3 gap-x-3 gap-y-2 max-w-[240px]"
>
<div
v-for="endpoint in sortEndpoints(provider.endpoint_health_details)"
:key="endpoint.api_format"
class="flex flex-col gap-1.5"
:title="getEndpointTooltip(endpoint)"
>
<!-- 上排缩写 + 百分比 -->
<div class="flex items-center justify-between text-[10px] leading-none">
<span class="font-medium text-muted-foreground/80">
{{ API_FORMAT_SHORT[endpoint.api_format] || endpoint.api_format.substring(0,2) }}
</span>
<span class="font-medium text-muted-foreground/80">
{{ isEndpointAvailable(endpoint) ? `${(endpoint.health_score * 100).toFixed(0)}%` : '-' }}
</span>
</div>
<!-- 下排进度条 -->
<div class="h-1.5 w-full bg-border dark:bg-border/80 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:class="getEndpointDotColor(endpoint)"
:style="{ width: isEndpointAvailable(endpoint) ? `${Math.max(endpoint.health_score * 100, 5)}%` : '100%' }"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import {
Edit,
Eye,
Trash2,
Power,
KeyRound,
ExternalLink,
Pencil,
Check,
X,
Loader2,
} from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import { type ProviderWithEndpointsSummary, API_FORMAT_SHORT } from '@/api/endpoints'
import { formatBillingType } from '@/utils/format'
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
const props = defineProps<{
provider: ProviderWithEndpointsSummary
editingDescriptionId: string | null
// Balance functions
isBalanceLoading: (providerId: string) => boolean
getProviderBalance: (providerId: string) => { available: number | null; currency: string } | null
getProviderBalanceError: (providerId: string) => { status: string; message: string } | null
getProviderCheckin: (providerId: string) => { success: boolean | null; message: string } | null
getProviderCookieExpired: (providerId: string) => { expired: boolean; message: string } | null
formatBalanceDisplay: (balance: { available: number | null; currency: string } | null) => string
getQuotaUsedColorClass: (provider: ProviderWithEndpointsSummary) => string
}>()
const emit = defineEmits<{
'viewDetail': [providerId: string]
'editProvider': [provider: ProviderWithEndpointsSummary]
'openOpsConfig': [provider: ProviderWithEndpointsSummary]
'toggleStatus': [provider: ProviderWithEndpointsSummary]
'deleteProvider': [provider: ProviderWithEndpointsSummary]
'startEditDescription': [event: Event, provider: ProviderWithEndpointsSummary]
'saveDescription': [event: Event, provider: ProviderWithEndpointsSummary, value: string]
'cancelEditDescription': [event?: Event]
}>()
const vAutoFocus = {
mounted: (el: HTMLElement) => el.focus(),
}
const localDescriptionValue = ref('')
watch(
() => props.editingDescriptionId,
(newId) => {
if (newId === props.provider.id) {
localDescriptionValue.value = props.provider.description || ''
}
},
)
function handleStartEdit(event: Event) {
event.stopPropagation()
emit('startEditDescription', event, props.provider)
}
function handleSave(event: Event) {
event.stopPropagation()
emit('saveDescription', event, props.provider, localDescriptionValue.value)
}
function handleCancel(event?: Event) {
event?.stopPropagation()
emit('cancelEditDescription', event)
}
function handleDescriptionKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault()
handleSave(event)
} else if (event.key === 'Escape') {
handleCancel(event)
}
}
</script>

View File

@@ -0,0 +1,162 @@
<template>
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/50">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<!-- 左侧标题 -->
<h3 class="text-sm sm:text-base font-semibold text-foreground shrink-0">
提供商管理
</h3>
<!-- 右侧操作区 -->
<div class="flex flex-wrap items-center gap-2">
<!-- 搜索框 -->
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/70 z-10 pointer-events-none" />
<Input
id="provider-search"
:model-value="searchQuery"
type="text"
placeholder="搜索提供商..."
class="w-32 sm:w-44 pl-8 pr-3 h-8 text-sm bg-muted/30 border-border/50 focus:border-primary/50 transition-colors"
@update:model-value="$emit('update:searchQuery', $event)"
/>
</div>
<!-- 状态筛选 -->
<Select
:model-value="filterStatus"
@update:model-value="$emit('update:filterStatus', $event)"
>
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="status in statusFilters"
:key="status.value"
:value="status.value"
>
{{ status.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- API 格式筛选 -->
<Select
:model-value="filterApiFormat"
@update:model-value="$emit('update:filterApiFormat', $event)"
>
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部格式" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="fmt in apiFormatFilters"
:key="fmt.value"
:value="fmt.value"
>
{{ fmt.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- 模型筛选 -->
<Select
:model-value="filterModel"
@update:model-value="$emit('update:filterModel', $event)"
>
<SelectTrigger class="w-20 sm:w-36 h-8 text-xs border-border/60">
<SelectValue placeholder="全部模型" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="model in modelFilters"
:key="model.value"
:value="model.value"
>
{{ model.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- 重置筛选 -->
<Button
v-if="hasActiveFilters"
variant="ghost"
size="icon"
class="h-8 w-8"
title="重置筛选"
@click="$emit('resetFilters')"
>
<FilterX class="w-3.5 h-3.5" />
</Button>
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 调度策略 -->
<button
class="group inline-flex items-center gap-1.5 px-2.5 h-8 rounded-md border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-primary/40 transition-all duration-200 text-xs"
title="点击调整调度策略"
@click="$emit('openPriorityDialog')"
>
<span class="text-muted-foreground/80 hidden sm:inline">调度:</span>
<span class="font-medium text-foreground/90">{{ priorityModeLabel }}</span>
<ChevronDown class="w-3 h-3 text-muted-foreground/70 group-hover:text-foreground transition-colors" />
</button>
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 操作按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="新增提供商"
@click="$emit('addProvider')"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<RefreshButton
:loading="loading"
@click="$emit('refresh')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Search, Plus, ChevronDown, FilterX } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import RefreshButton from '@/components/ui/refresh-button.vue'
import type { FilterOption } from '@/features/providers/composables/useProviderFilters'
defineProps<{
searchQuery: string
filterStatus: string
filterApiFormat: string
filterModel: string
statusFilters: FilterOption[]
apiFormatFilters: FilterOption[]
modelFilters: FilterOption[]
hasActiveFilters: boolean
priorityModeLabel: string
loading: boolean
}>()
defineEmits<{
'update:searchQuery': [value: string]
'update:filterStatus': [value: string]
'update:filterApiFormat': [value: string]
'update:filterModel': [value: string]
'resetFilters': []
'openPriorityDialog': []
'addProvider': []
'refresh': []
}>()
</script>

View File

@@ -0,0 +1,288 @@
<template>
<TableRow
class="border-b border-border/30 hover:bg-muted/20 transition-colors cursor-pointer"
@mousedown="$emit('mousedown', $event)"
@click="$emit('rowClick', $event, provider.id)"
>
<TableCell class="py-3.5">
<div class="space-y-0.5">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-foreground">{{ provider.name }}</span>
<a
v-if="provider.website"
:href="provider.website"
target="_blank"
rel="noopener noreferrer"
class="text-muted-foreground hover:text-primary transition-colors shrink-0"
:title="provider.website"
@click.stop
>
<ExternalLink class="w-3.5 h-3.5" />
</a>
</div>
<!-- 内联编辑备注 -->
<div
v-if="editingDescriptionId === provider.id"
data-desc-editor
class="flex items-center gap-1 max-w-[220px]"
@click.stop
>
<input
v-model="localDescriptionValue"
v-auto-focus
class="flex-1 min-w-0 text-xs px-1.5 py-0.5 rounded border border-border bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
placeholder="输入备注..."
@keydown="handleDescriptionKeydown"
>
<button
class="shrink-0 p-0.5 rounded hover:bg-muted text-primary"
title="保存"
@click="handleSave"
>
<Check class="w-3.5 h-3.5" />
</button>
<button
class="shrink-0 p-0.5 rounded hover:bg-muted text-muted-foreground"
title="取消"
@click="handleCancel"
>
<X class="w-3.5 h-3.5" />
</button>
</div>
<span
v-else-if="provider.description"
class="text-xs text-muted-foreground truncate block max-w-[200px] group/desc cursor-pointer hover:text-foreground/70 transition-colors"
:title="provider.description"
@click="handleStartEdit"
>{{ provider.description }} <Pencil class="w-3 h-3 inline-block opacity-0 group-hover/desc:opacity-50 transition-opacity" /></span>
<span
v-else
class="text-xs text-muted-foreground cursor-pointer hover:text-foreground/70 transition-colors"
@click="handleStartEdit"
>添加备注</span>
</div>
</TableCell>
<TableCell class="py-3.5">
<ProviderBalanceCell
:provider="provider"
:is-balance-loading="isBalanceLoading"
:get-provider-balance="getProviderBalance"
:get-provider-balance-breakdown="getProviderBalanceBreakdown"
:get-provider-balance-error="getProviderBalanceError"
:get-provider-checkin="getProviderCheckin"
:get-provider-cookie-expired="getProviderCookieExpired"
:get-provider-balance-extra="getProviderBalanceExtra"
:format-balance-display="formatBalanceDisplay"
:format-reset-countdown="formatResetCountdown"
:get-quota-used-color-class="getQuotaUsedColorClass"
/>
</TableCell>
<TableCell class="py-3.5 text-center">
<div class="space-y-0.5 text-xs">
<div class="flex items-center justify-center gap-1.5">
<span class="text-muted-foreground/70">端点:</span>
<span class="font-medium text-foreground/90">{{ provider.active_endpoints }}</span>
<span class="text-muted-foreground/50">/{{ provider.total_endpoints }}</span>
</div>
<div class="flex items-center justify-center gap-1.5">
<span class="text-muted-foreground/70">密钥:</span>
<span class="font-medium text-foreground/90">{{ provider.active_keys }}</span>
<span class="text-muted-foreground/50">/{{ provider.total_keys }}</span>
</div>
<div class="flex items-center justify-center gap-1.5">
<span class="text-muted-foreground/70">模型:</span>
<span class="font-medium text-foreground/90">{{ provider.active_models }}</span>
<span class="text-muted-foreground/50">/{{ provider.total_models }}</span>
</div>
</div>
</TableCell>
<TableCell class="py-3.5 align-middle">
<div
v-if="provider.endpoint_health_details && provider.endpoint_health_details.length > 0"
class="grid grid-cols-3 gap-x-3 gap-y-2 max-w-[240px]"
>
<div
v-for="endpoint in sortEndpoints(provider.endpoint_health_details)"
:key="endpoint.api_format"
class="flex flex-col gap-1.5"
:title="getEndpointTooltip(endpoint)"
>
<!-- 上排缩写 + 百分比 -->
<div class="flex items-center justify-between text-[10px] leading-none">
<span class="font-medium text-muted-foreground/80">
{{ API_FORMAT_SHORT[endpoint.api_format] || endpoint.api_format.substring(0,2) }}
</span>
<span class="font-medium text-muted-foreground/80">
{{ isEndpointAvailable(endpoint) ? `${(endpoint.health_score * 100).toFixed(0)}%` : '-' }}
</span>
</div>
<!-- 下排进度条 -->
<div class="h-1.5 w-full bg-border dark:bg-border/80 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:class="getEndpointDotColor(endpoint)"
:style="{ width: isEndpointAvailable(endpoint) ? `${Math.max(endpoint.health_score * 100, 5)}%` : '100%' }"
/>
</div>
</div>
</div>
<span
v-else
class="text-xs text-muted-foreground/50"
>暂无端点</span>
</TableCell>
<TableCell class="py-3.5 text-center">
<Badge
:variant="provider.is_active ? 'success' : 'secondary'"
class="text-xs"
>
{{ provider.is_active ? '活跃' : '已停用' }}
</Badge>
</TableCell>
<TableCell
class="py-3.5"
@click.stop
>
<div class="flex items-center justify-center gap-0.5">
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
title="查看详情"
@click="$emit('viewDetail', provider.id)"
>
<Eye class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
title="编辑提供商"
@click="$emit('editProvider', provider)"
>
<Edit class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
title="扩展操作配置"
@click="$emit('openOpsConfig', provider)"
>
<KeyRound class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
:title="provider.is_active ? '停用提供商' : '启用提供商'"
@click="$emit('toggleStatus', provider)"
>
<Power class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive"
title="删除提供商"
@click="$emit('deleteProvider', provider)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import {
Edit,
Eye,
Trash2,
Power,
KeyRound,
ExternalLink,
Pencil,
Check,
X,
} from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import TableRow from '@/components/ui/table-row.vue'
import TableCell from '@/components/ui/table-cell.vue'
import ProviderBalanceCell from './ProviderBalanceCell.vue'
import { type ProviderWithEndpointsSummary, API_FORMAT_SHORT } from '@/api/endpoints'
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
const props = defineProps<{
provider: ProviderWithEndpointsSummary
editingDescriptionId: string | null
// Balance functions
isBalanceLoading: (providerId: string) => boolean
getProviderBalance: (providerId: string) => { available: number | null; currency: string } | null
getProviderBalanceBreakdown: (providerId: string) => { balance: number; points: number; currency: string } | null
getProviderBalanceError: (providerId: string) => { status: string; message: string } | null
getProviderCheckin: (providerId: string) => { success: boolean | null; message: string } | null
getProviderCookieExpired: (providerId: string) => { expired: boolean; message: string } | null
getProviderBalanceExtra: (providerId: string, architectureId?: string) => BalanceExtraItem[]
formatBalanceDisplay: (balance: { available: number | null; currency: string } | null) => string
formatResetCountdown: (resetsAt: number) => string
getQuotaUsedColorClass: (provider: ProviderWithEndpointsSummary) => string
}>()
const emit = defineEmits<{
'mousedown': [event: MouseEvent]
'rowClick': [event: MouseEvent, providerId: string]
'viewDetail': [providerId: string]
'editProvider': [provider: ProviderWithEndpointsSummary]
'openOpsConfig': [provider: ProviderWithEndpointsSummary]
'toggleStatus': [provider: ProviderWithEndpointsSummary]
'deleteProvider': [provider: ProviderWithEndpointsSummary]
'startEditDescription': [event: Event, provider: ProviderWithEndpointsSummary]
'saveDescription': [event: Event, provider: ProviderWithEndpointsSummary, value: string]
'cancelEditDescription': [event?: Event]
}>()
const vAutoFocus = {
mounted: (el: HTMLElement) => el.focus(),
}
const localDescriptionValue = ref('')
// 当进入编辑模式时,同步 props 的 description
watch(
() => props.editingDescriptionId,
(newId) => {
if (newId === props.provider.id) {
localDescriptionValue.value = props.provider.description || ''
}
},
)
function handleStartEdit(event: Event) {
event.stopPropagation()
emit('startEditDescription', event, props.provider)
}
function handleSave(event: Event) {
event.stopPropagation()
emit('saveDescription', event, props.provider, localDescriptionValue.value)
}
function handleCancel(event?: Event) {
event?.stopPropagation()
emit('cancelEditDescription', event)
}
function handleDescriptionKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault()
handleSave(event)
} else if (event.key === 'Escape') {
handleCancel(event)
}
}
</script>

View File

@@ -0,0 +1,90 @@
import type { EndpointHealthDetail } from '@/api/endpoints'
// 端点状态枚举
export type EndpointStatus = 'disabled' | 'no_keys' | 'keys_disabled' | 'available'
const ENDPOINT_SORT_ORDER = [
'claude:chat',
'claude:cli',
'openai:chat',
'openai:cli',
'gemini:chat',
'gemini:cli',
'openai:video',
'gemini:video',
]
/**
* 端点排序
*/
export function sortEndpoints<T extends { api_format: string }>(endpoints: T[]): T[] {
return [...endpoints].sort((a, b) => {
return ENDPOINT_SORT_ORDER.indexOf(a.api_format) - ENDPOINT_SORT_ORDER.indexOf(b.api_format)
})
}
/**
* 获取端点状态
*/
export function getEndpointStatus(endpoint: EndpointHealthDetail): EndpointStatus {
if (endpoint.is_active === false) {
return 'disabled'
}
if ((endpoint.active_keys ?? 0) === 0) {
return (endpoint.total_keys ?? 0) > 0 ? 'keys_disabled' : 'no_keys'
}
return 'available'
}
/**
* 判断端点是否可用
*/
export function isEndpointAvailable(endpoint: EndpointHealthDetail): boolean {
return getEndpointStatus(endpoint) === 'available'
}
/**
* 根据健康分数获取颜色
*/
export function getHealthScoreColor(score: number | undefined | null): string {
if (score === undefined || score === null) {
return 'bg-muted-foreground/40'
}
if (score >= 0.8) return 'bg-green-500'
if (score >= 0.5) return 'bg-amber-500'
return 'bg-red-500'
}
/**
* 端点不可用时进度条颜色
*/
export function getEndpointDotColor(endpoint: EndpointHealthDetail): string {
if (!isEndpointAvailable(endpoint)) {
return 'bg-muted-foreground/40'
}
return getHealthScoreColor(endpoint.health_score)
}
/**
* 端点提示文本
*/
export function getEndpointTooltip(endpoint: EndpointHealthDetail): string {
const format = endpoint.api_format
const status = getEndpointStatus(endpoint)
switch (status) {
case 'disabled':
return `${format}: 端点已禁用`
case 'no_keys':
return `${format}: 未配置密钥`
case 'keys_disabled':
return `${format}: 无可用密钥`
case 'available': {
const score = endpoint.health_score
if (score === undefined || score === null) {
return `${format}: 暂无健康数据`
}
return `${format}: 健康度 ${(score * 100).toFixed(0)}%`
}
}
}

View File

@@ -0,0 +1,340 @@
import { ref, onUnmounted } from 'vue'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import { batchQueryBalance, getArchitectures, type ActionResultResponse, type ArchitectureInfo } from '@/api/providerOps'
import { formatBalanceExtraFromSchema, type CredentialsSchema } from '@/features/providers/auth-templates/schema-utils'
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
const MAX_BALANCE_RETRIES = 3
export function useProviderBalance() {
// 余额数据缓存 {providerId: ActionResultResponse}
const balanceCache = ref<Record<string, ActionResultResponse>>({})
// 余额加载请求版本计数器(用于防止竞态条件)
let balanceLoadVersion = 0
// 追踪待处理的定时器,用于组件卸载时清理
const pendingTimers = new Set<ReturnType<typeof setTimeout>>()
// 架构 schema 缓存(用于 balance extra 格式化)
const architectureSchemas = ref<Record<string, CredentialsSchema>>({})
const architectureSchemasLoaded = ref(false)
// 用于触发倒计时更新的响应式计数器
const tickCounter = ref(0)
let tickInterval: ReturnType<typeof setInterval> | null = null
function startTick() {
if (tickInterval) return
tickInterval = setInterval(() => {
tickCounter.value++
}, 1000)
}
function stopTick() {
if (tickInterval) {
clearInterval(tickInterval)
tickInterval = null
}
}
/** 加载架构 schema 缓存 */
async function loadArchitectureSchemas() {
if (architectureSchemasLoaded.value) return
try {
const archs: ArchitectureInfo[] = await getArchitectures()
const schemas: Record<string, CredentialsSchema> = {}
for (const arch of archs) {
if (arch.credentials_schema) {
schemas[arch.architecture_id] = arch.credentials_schema as CredentialsSchema
}
}
architectureSchemas.value = schemas
architectureSchemasLoaded.value = true
} catch {
// 加载失败不影响主流程
}
}
// 异步加载余额数据(使用批量接口)
async function loadBalances(providers: ProviderWithEndpointsSummary[]) {
// 清空旧的余额缓存,避免数据累积
balanceCache.value = {}
const currentVersion = ++balanceLoadVersion
try {
const opsProviderIds = providers.filter(p => p.ops_configured).map(p => p.id)
if (opsProviderIds.length === 0) return
const results = await batchQueryBalance(opsProviderIds)
// 检查是否有新的请求已经开始,如果有则丢弃当前结果
if (currentVersion !== balanceLoadVersion) return
// 收集需要重试的 provider IDs
const pendingProviderIds: string[] = []
// 将结果存入缓存(包括 pending 状态)
for (const [providerId, result] of Object.entries(results)) {
// 存入缓存success, auth_expired (带有效数据), pending
if (result.status === 'success' || result.status === 'auth_expired' || result.status === 'pending') {
balanceCache.value[providerId] = result
}
// 收集 pending 状态的 provider稍后重试
if (result.status === 'pending') {
pendingProviderIds.push(providerId)
}
}
// 如果有 pending 状态的 provider3秒后自动重试
if (pendingProviderIds.length > 0) {
const timerId = setTimeout(() => {
pendingTimers.delete(timerId)
// 检查版本号,确保没有新的加载请求
if (currentVersion === balanceLoadVersion) {
retryPendingBalances(pendingProviderIds, currentVersion, 0)
}
}, 3000)
pendingTimers.add(timerId)
}
} catch (e) {
console.warn('[loadBalances] 加载余额数据失败:', e)
}
}
// 重试加载 pending 状态的余额
async function retryPendingBalances(providerIds: string[], loadVersion: number, retryCount: number) {
try {
const results = await batchQueryBalance(providerIds)
const stillPending: string[] = []
for (const [providerId, result] of Object.entries(results)) {
if (result.status !== 'pending') {
balanceCache.value[providerId] = result
} else {
stillPending.push(providerId)
}
}
// 如果还有 pending 且未达到最大重试次数,继续重试(指数退避)
if (stillPending.length > 0 && retryCount < MAX_BALANCE_RETRIES) {
const delay = 3000 * Math.pow(1.5, retryCount) // 3s, 4.5s, 6.75s
const timerId = setTimeout(() => {
pendingTimers.delete(timerId)
// 检查版本号,确保没有新的加载请求
if (loadVersion === balanceLoadVersion) {
retryPendingBalances(stillPending, loadVersion, retryCount + 1)
}
}, delay)
pendingTimers.add(timerId)
}
} catch (e) {
console.warn('[retryPendingBalances] 重试加载余额失败:', e)
}
}
/**
* 类型守卫:检查是否为 BalanceInfo简化版
*/
function isBalanceInfo(data: unknown): data is { total_available: number | null; currency: string } {
if (typeof data !== 'object' || data === null) return false
if (!('total_available' in data) || !('currency' in data)) return false
const d = data as Record<string, unknown>
if (d.total_available !== null && typeof d.total_available !== 'number') return false
if (typeof d.currency !== 'string') return false
return true
}
// 获取 provider 的余额显示
function getProviderBalance(providerId: string): { available: number | null; currency: string } | null {
const result = balanceCache.value[providerId]
// auth_expired 时余额数据仍有效(只是签到 Cookie 失效)
if (!result || (result.status !== 'success' && result.status !== 'auth_expired') || !result.data) {
return null
}
if (!isBalanceInfo(result.data)) {
return null
}
return {
available: result.data.total_available,
currency: result.data.currency || 'USD',
}
}
// 获取 provider 余额明细balance + points 分开显示)
function getProviderBalanceBreakdown(providerId: string): { balance: number; points: number; currency: string } | null {
const result = balanceCache.value[providerId]
if (!result || (result.status !== 'success' && result.status !== 'auth_expired') || !result.data) {
return null
}
const data = result.data as Record<string, any>
const extra = data.extra
if (!extra || extra.balance === undefined || extra.points === undefined) {
return null
}
return {
balance: extra.balance,
points: extra.points,
currency: data.currency || 'USD',
}
}
// 获取 provider 余额查询的错误状态
function getProviderBalanceError(providerId: string): { status: string; message: string } | null {
const result = balanceCache.value[providerId]
if (!result) {
return null
}
// pending 状态不是错误,正在加载中
if (result.status === 'pending') {
return null
}
// 认证失败或过期
if (result.status === 'auth_failed' || result.status === 'auth_expired') {
return {
status: result.status,
message: result.message || '认证失败',
}
}
// 其他错误
if (result.status !== 'success') {
return {
status: result.status,
message: result.message || '查询失败',
}
}
return null
}
// 检查余额是否正在加载中
function isBalanceLoading(providerId: string): boolean {
const result = balanceCache.value[providerId]
return result?.status === 'pending'
}
// 获取 provider 的签到信息(从 extra 字段)
function getProviderCheckin(providerId: string): { success: boolean | null; message: string } | null {
const result = balanceCache.value[providerId]
if (!result || result.status !== 'success' || !result.data) {
return null
}
const data = result.data as Record<string, any>
const extra = data.extra
if (!extra || extra.checkin_success === undefined) {
return null
}
return {
success: extra.checkin_success,
message: extra.checkin_message || '',
}
}
// 获取 provider 的 Cookie 失效状态(从 extra 字段)
function getProviderCookieExpired(providerId: string): { expired: boolean; message: string } | null {
const result = balanceCache.value[providerId]
if (!result || !result.data) {
return null
}
if (result.status !== 'success' && result.status !== 'auth_expired') {
return null
}
const data = result.data as Record<string, any>
const extra = data.extra
if (!extra || !extra.cookie_expired) {
return null
}
return {
expired: true,
message: extra.cookie_expired_message || 'Cookie 已失效',
}
}
// 格式化余额显示
function formatBalanceDisplay(balance: { available: number | null; currency: string } | null): string {
if (!balance || balance.available == null) {
return '-'
}
const symbol = balance.currency === 'USD' ? '$' : balance.currency
return `${symbol}${balance.available.toFixed(2)}`
}
// 格式化重置倒计时(从 Unix 时间戳)
function formatResetCountdown(resetsAt: number): string {
// 依赖 tickCounter 触发响应式更新
void tickCounter.value
const now = Date.now() / 1000
const diff = resetsAt - now
if (diff <= 0) return '即将重置'
const totalHours = Math.floor(diff / 3600)
const minutes = Math.floor((diff % 3600) / 60)
const seconds = Math.floor(diff % 60)
const pad = (n: number) => n.toString().padStart(2, '0')
if (totalHours > 0) {
return `${totalHours}:${pad(minutes)}:${pad(seconds)}`
}
return `${minutes}:${pad(seconds)}`
}
// 获取 provider 余额的额外信息(如窗口限额)
function getProviderBalanceExtra(providerId: string, architectureId?: string): BalanceExtraItem[] {
if (!architectureId) return []
const result = balanceCache.value[providerId]
// auth_expired 时余额数据仍有效(只是签到 Cookie 失效)
if (!result || (result.status !== 'success' && result.status !== 'auth_expired') || !result.data) {
return []
}
const data = result.data as Record<string, any>
const extra = data.extra
if (!extra) return []
// 从 schema 缓存中获取格式化配置
const schema = architectureSchemas.value[architectureId]
if (!schema) return []
return formatBalanceExtraFromSchema(schema, extra)
}
// 配额已用颜色(根据使用比例)
function getQuotaUsedColorClass(provider: ProviderWithEndpointsSummary): string {
const used = provider.monthly_used_usd ?? 0
const quota = provider.monthly_quota_usd ?? 0
if (quota <= 0) return 'text-foreground'
const ratio = used / quota
if (ratio >= 0.9) return 'text-red-600 dark:text-red-400'
if (ratio >= 0.7) return 'text-amber-600 dark:text-amber-400'
return 'text-foreground'
}
// 组件卸载时清理
function cleanup() {
stopTick()
pendingTimers.forEach(clearTimeout)
pendingTimers.clear()
}
onUnmounted(cleanup)
return {
balanceCache,
loadArchitectureSchemas,
loadBalances,
getProviderBalance,
getProviderBalanceBreakdown,
getProviderBalanceError,
isBalanceLoading,
getProviderCheckin,
getProviderCookieExpired,
formatBalanceDisplay,
formatResetCountdown,
getProviderBalanceExtra,
getQuotaUsedColorClass,
tickCounter,
startTick,
stopTick,
}
}

View File

@@ -0,0 +1,142 @@
import { ref, computed, watch } from 'vue'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
export interface FilterOption {
value: string
label: string
}
export function useProviderFilters(
providers: () => ProviderWithEndpointsSummary[],
globalModels: () => { id: string; name: string }[],
) {
// 搜索与筛选
const searchQuery = ref('')
const filterStatus = ref('all')
const filterApiFormat = ref('all')
const filterModel = ref('all')
const statusFilters: FilterOption[] = [
{ value: 'all', label: '全部状态' },
{ value: 'active', label: '活跃' },
{ value: 'inactive', label: '已停用' },
]
const apiFormatFilters: FilterOption[] = [
{ value: 'all', label: '全部格式' },
{ value: 'claude:chat', label: 'Claude Chat' },
{ value: 'claude:cli', label: 'Claude CLI' },
{ value: 'openai:chat', label: 'OpenAI Chat' },
{ value: 'openai:cli', label: 'OpenAI CLI' },
{ value: 'gemini:chat', label: 'Gemini Chat' },
{ value: 'gemini:cli', label: 'Gemini CLI' },
]
// 动态计算模型筛选选项:只展示当前提供商列表中实际关联的全局模型
const modelFilters = computed<FilterOption[]>(() => {
const usedIds = new Set(providers().flatMap(p => p.global_model_ids || []))
const items = globalModels()
.filter(m => usedIds.has(m.id))
.map(m => ({ value: m.id, label: m.name }))
.sort((a, b) => a.label.localeCompare(b.label))
return [{ value: 'all', label: '全部模型' }, ...items]
})
const hasActiveFilters = computed(() => {
return (
searchQuery.value !== '' ||
filterStatus.value !== 'all' ||
filterApiFormat.value !== 'all' ||
filterModel.value !== 'all'
)
})
// 筛选后的提供商列表
const filteredProviders = computed(() => {
let result = [...providers()]
// 搜索筛选(支持空格分隔的多关键词 AND 搜索)
if (searchQuery.value.trim()) {
const keywords = searchQuery.value
.toLowerCase()
.split(/\s+/)
.filter(k => k.length > 0)
result = result.filter(p => {
const searchableText = `${p.name}`.toLowerCase()
return keywords.every(keyword => searchableText.includes(keyword))
})
}
// 状态筛选
if (filterStatus.value !== 'all') {
const isActive = filterStatus.value === 'active'
result = result.filter(p => p.is_active === isActive)
}
// API 格式筛选
if (filterApiFormat.value !== 'all') {
result = result.filter(
p => p.api_formats && p.api_formats.includes(filterApiFormat.value),
)
}
// 模型筛选
if (filterModel.value !== 'all') {
result = result.filter(
p => p.global_model_ids && p.global_model_ids.includes(filterModel.value),
)
}
// 排序
return result.sort((a, b) => {
// 1. 优先显示活跃的提供商
if (a.is_active !== b.is_active) {
return a.is_active ? -1 : 1
}
// 2. 按优先级排序
if (a.provider_priority !== b.provider_priority) {
return a.provider_priority - b.provider_priority
}
// 3. 按名称排序
return a.name.localeCompare(b.name)
})
})
// 分页
const currentPage = ref(1)
const pageSize = ref(20)
const paginatedProviders = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredProviders.value.slice(start, end)
})
// 搜索/筛选时重置分页
watch([searchQuery, filterStatus, filterApiFormat, filterModel], () => {
currentPage.value = 1
})
function resetFilters() {
searchQuery.value = ''
filterStatus.value = 'all'
filterApiFormat.value = 'all'
filterModel.value = 'all'
}
return {
searchQuery,
filterStatus,
filterApiFormat,
filterModel,
statusFilters,
apiFormatFilters,
modelFilters,
hasActiveFilters,
filteredProviders,
currentPage,
pageSize,
paginatedProviders,
resetFilters,
}
}

View File

@@ -0,0 +1,48 @@
import type { RouteLocationNormalized } from 'vue-router'
import type { useAuthStore } from '@/stores/auth'
import type { useModuleStore } from '@/stores/modules'
import { log } from '@/utils/logger'
/**
* 检查管理员权限和管理端模块可用性。
* @returns 重定向路径,或 null 表示通过
*/
export async function checkAdminAccess(
to: RouteLocationNormalized,
authStore: ReturnType<typeof useAuthStore>,
moduleStore: ReturnType<typeof useModuleStore>
): Promise<string | null> {
const isAdmin = authStore.user?.role === 'admin'
if (!isAdmin) {
log.warn('Non-admin user attempted to access admin page, redirecting to user dashboard')
return '/dashboard'
}
// 检查路由链中是否有模块要求
const moduleName = to.matched.find(record => record.meta.module)?.meta.module as
| string
| undefined
if (!moduleName) {
return null
}
// 确保模块状态已加载
if (!moduleStore.loaded) {
try {
await moduleStore.fetchModules()
} catch (error) {
// fail-close: 获取模块状态失败时拒绝访问
log.warn('Failed to fetch modules status, denying access', { error })
return '/admin/dashboard'
}
}
// 如果模块不可用(未部署),重定向到管理员首页
// 注意:只检查 available不检查 enabled/active允许管理员配置未启用的模块
if (!moduleStore.isAvailable(moduleName)) {
log.warn(`Module ${moduleName} is not available, redirecting to admin dashboard`)
return '/admin/dashboard'
}
return null
}

View File

@@ -0,0 +1,37 @@
import type { useAuthStore } from '@/stores/auth'
import { log } from '@/utils/logger'
/**
* 判断错误是否为网络错误
*/
function isNetworkError(error: any): boolean {
return !error.response || error.message?.includes('Network') || error.message?.includes('timeout')
}
/**
* 确保用户信息已加载。如果有 token 但未加载用户信息,尝试获取。
* @returns true 如果用户已认证false 如果认证失败
*/
export async function ensureUserLoaded(
authStore: ReturnType<typeof useAuthStore>
): Promise<boolean> {
if (authStore.token && !authStore.user) {
try {
await authStore.fetchCurrentUser()
} catch (error: any) {
// 区分网络错误和认证错误
if (isNetworkError(error)) {
log.warn('Network error while fetching user info, keeping session', {
error: error?.message
})
} else if (error.response?.status === 401) {
log.info('Authentication failed, clearing session')
authStore.logout()
} else {
log.warn('Failed to fetch user info, but keeping session', { error: error?.message })
}
}
}
return !!authStore.token
}

View File

@@ -0,0 +1,37 @@
import type { RouteLocationNormalized } from 'vue-router'
import type { useAuthStore } from '@/stores/auth'
/**
* 处理已认证用户访问首页时的重定向。
* @returns 重定向路径null 表示不需要重定向,空字符串表示放行(不跳转)
*/
export function resolveHomeRedirect(
to: RouteLocationNormalized,
from: RouteLocationNormalized,
authStore: ReturnType<typeof useAuthStore>
): string | null {
if (to.path !== '/') {
return null
}
if (!authStore.isAuthenticated) {
return null
}
// 已登录用户如果是从dashboard返回首页、刷新首页、或者有returnTo参数,允许访问首页
const isFromApp =
from.path.startsWith('/dashboard') || from.path.startsWith('/admin') || from.path === '/'
if (to.query.returnTo || isFromApp) {
return ''
}
// 已登录用户首次访问首页(非返回/刷新场景),根据角色跳转到对应仪表盘
const isAdmin = authStore.user?.role === 'admin'
const redirectPath = sessionStorage.getItem('redirectPath')
if (redirectPath && redirectPath !== '/') {
sessionStorage.removeItem('redirectPath')
return redirectPath
}
return isAdmin ? '/admin/dashboard' : '/dashboard'
}

View File

@@ -0,0 +1,4 @@
export { ensureUserLoaded } from './authGuard'
export { resolveHomeRedirect } from './homeGuard'
export { checkAdminAccess } from './adminGuard'
export { checkModuleAccess } from './moduleGuard'

View File

@@ -0,0 +1,39 @@
import type { RouteLocationNormalized } from 'vue-router'
import type { useModuleStore } from '@/stores/modules'
import { log } from '@/utils/logger'
/**
* 检查非管理端路由的模块激活状态。
* @returns 重定向路径,或 null 表示通过
*/
export async function checkModuleAccess(
to: RouteLocationNormalized,
moduleStore: ReturnType<typeof useModuleStore>
): Promise<string | null> {
// 检查路由链中是否有模块要求
const moduleName = to.matched.find(record => record.meta.module)?.meta.module as
| string
| undefined
if (!moduleName) {
return null
}
// 确保模块状态已加载
if (!moduleStore.loaded) {
try {
await moduleStore.fetchModules()
} catch (error) {
// fail-close: 获取模块状态失败时拒绝访问
log.warn('Failed to fetch modules status, denying access', { error })
return '/dashboard'
}
}
// 用户侧需要检查模块是否激活active而不仅仅是可用available
if (!moduleStore.isActive(moduleName)) {
log.warn(`Module ${moduleName} is not active, redirecting to user dashboard`)
return '/dashboard'
}
return null
}

View File

@@ -4,6 +4,12 @@ import { useAuthStore } from '@/stores/auth'
import { useModuleStore } from '@/stores/modules' import { useModuleStore } from '@/stores/modules'
import { importWithRetry } from '@/utils/importRetry' import { importWithRetry } from '@/utils/importRetry'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import {
ensureUserLoaded,
resolveHomeRedirect,
checkAdminAccess,
checkModuleAccess
} from './guards'
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
@@ -255,111 +261,39 @@ const router = createRouter({
routes routes
}) })
/**
* 判断错误是否为网络错误
*/
function isNetworkError(error: any): boolean {
return !error.response || error.message?.includes('Network') || error.message?.includes('timeout')
}
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore() const authStore = useAuthStore()
const moduleStore = useModuleStore() const moduleStore = useModuleStore()
try { try {
// 如果有token但没有用户信息,尝试获取用户信息 const isAuthenticated = await ensureUserLoaded(authStore)
if (authStore.token && !authStore.user) {
try {
await authStore.fetchCurrentUser()
} catch (error: any) {
// 区分网络错误和认证错误
if (isNetworkError(error)) {
log.warn('Network error while fetching user info, keeping session', { error: error?.message })
} else if (error.response?.status === 401) {
log.info('Authentication failed, clearing session')
authStore.logout()
} else {
log.warn('Failed to fetch user info, but keeping session', { error: error?.message })
}
}
}
// 检查整个路由匹配记录链中的 meta // 首页重定向
const homeRedirect = resolveHomeRedirect(to, from, authStore)
if (homeRedirect !== null) return next(homeRedirect === '' ? undefined : homeRedirect)
// 需要认证但未认证
const requiresAuth = to.matched.some(record => record.meta.requiresAuth !== false) const requiresAuth = to.matched.some(record => record.meta.requiresAuth !== false)
const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin) if (requiresAuth && !isAuthenticated) {
const moduleName = to.matched.find(record => record.meta.module)?.meta.module as string | undefined
// 如果需要认证但没有token,跳转到首页
if (requiresAuth && !authStore.token) {
sessionStorage.setItem('redirectPath', to.fullPath) sessionStorage.setItem('redirectPath', to.fullPath)
log.debug('No valid token found, redirecting to home') log.debug('No valid token found, redirecting to home')
next('/') return next('/')
} else if (to.path === '/' && authStore.isAuthenticated && (to.query.returnTo || from.path.startsWith('/dashboard') || from.path.startsWith('/admin') || from.path === '/')) {
// 已登录用户如果是从dashboard返回首页、刷新首页、或者有returnTo参数,允许访问首页
next()
} else if (authStore.isAuthenticated && to.path === '/' && !to.query.returnTo) {
// 已登录用户首次访问首页(非返回/刷新场景),根据角色跳转到对应仪表盘
const isAdmin = authStore.user?.role === 'admin'
const redirectPath = sessionStorage.getItem('redirectPath')
if (redirectPath && redirectPath !== '/') {
sessionStorage.removeItem('redirectPath')
next(redirectPath)
} else {
next(isAdmin ? '/admin/dashboard' : '/dashboard')
}
} else if (requiresAdmin) {
// 需要管理员权限的页面
const isAdmin = authStore.user?.role === 'admin'
if (!isAdmin) {
log.warn('Non-admin user attempted to access admin page, redirecting to user dashboard')
next('/dashboard')
} else {
// 检查模块可用性
if (moduleName) {
// 确保模块状态已加载
if (!moduleStore.loaded) {
try {
await moduleStore.fetchModules()
} catch (error) {
// fail-close: 获取模块状态失败时拒绝访问
log.warn('Failed to fetch modules status, denying access', { error })
next('/admin/dashboard')
return
}
}
// 如果模块不可用(未部署),重定向到管理员首页
// 注意:只检查 available不检查 enabled/active允许管理员配置未启用的模块
if (!moduleStore.isAvailable(moduleName)) {
log.warn(`Module ${moduleName} is not available, redirecting to admin dashboard`)
next('/admin/dashboard')
return
}
}
next()
}
} else if (moduleName) {
// 非管理员页面但需要模块的路由(如用户侧访问令牌)
// 确保模块状态已加载
if (!moduleStore.loaded) {
try {
await moduleStore.fetchModules()
} catch (error) {
// fail-close: 获取模块状态失败时拒绝访问
log.warn('Failed to fetch modules status, denying access', { error })
next('/dashboard')
return
}
}
// 用户侧需要检查模块是否激活active而不仅仅是可用available
if (!moduleStore.isActive(moduleName)) {
log.warn(`Module ${moduleName} is not active, redirecting to user dashboard`)
next('/dashboard')
return
}
next()
} else {
next()
} }
// 管理端检查
const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin)
if (requiresAdmin) {
const adminRedirect = await checkAdminAccess(to, authStore, moduleStore)
if (adminRedirect) return next(adminRedirect)
}
// 非管理端的模块检查
if (!requiresAdmin) {
const moduleRedirect = await checkModuleAccess(to, moduleStore)
if (moduleRedirect) return next(moduleRedirect)
}
next()
} catch (error) { } catch (error) {
log.error('Router guard error', error) log.error('Router guard error', error)
// 发生错误时,直接放行,不要乱跳转 // 发生错误时,直接放行,不要乱跳转

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,148 @@
<template>
<CardSection
title="基础配置"
description="配置系统默认参数"
>
<template #actions>
<Button
size="sm"
:disabled="loading || !hasChanges"
@click="$emit('save')"
>
{{ loading ? '保存中...' : '保存' }}
</Button>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label
for="default-quota"
class="block text-sm font-medium"
>
默认用户配额(美元)
</Label>
<Input
id="default-quota"
:model-value="defaultUserQuotaUsd"
type="number"
step="0.01"
placeholder="10.00"
class="mt-1"
@update:model-value="$emit('update:defaultUserQuotaUsd', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
新用户注册时的默认配额
</p>
</div>
<div>
<Label
for="rate-limit"
class="block text-sm font-medium"
>
每分钟请求限制
</Label>
<Input
id="rate-limit"
:model-value="rateLimitPerMinute"
type="number"
placeholder="0"
class="mt-1"
@update:model-value="$emit('update:rateLimitPerMinute', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
0 表示不限制
</p>
</div>
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="enable-registration"
:checked="enableRegistration"
@update:checked="$emit('update:enableRegistration', $event)"
/>
<div>
<Label
for="enable-registration"
class="cursor-pointer"
>
开放用户注册
</Label>
<p class="text-xs text-muted-foreground">
允许新用户自助注册账户
</p>
</div>
</div>
</div>
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="auto-delete-expired-keys"
:checked="autoDeleteExpiredKeys"
@update:checked="$emit('update:autoDeleteExpiredKeys', $event)"
/>
<div>
<Label
for="auto-delete-expired-keys"
class="cursor-pointer"
>
自动删除过期 Key
</Label>
<p class="text-xs text-muted-foreground">
关闭时仅禁用过期的独立余额 Key
</p>
</div>
</div>
</div>
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="enable-format-conversion"
:checked="enableFormatConversion"
@update:checked="$emit('update:enableFormatConversion', $event)"
/>
<div>
<Label
for="enable-format-conversion"
class="cursor-pointer"
>
全局格式转换
</Label>
<p class="text-xs text-muted-foreground">
开启后强制允许所有提供商接受跨格式请求
</p>
</div>
</div>
</div>
</div>
</CardSection>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import { CardSection } from '@/components/layout'
defineProps<{
defaultUserQuotaUsd: number
rateLimitPerMinute: number
enableRegistration: boolean
autoDeleteExpiredKeys: boolean
enableFormatConversion: boolean
loading: boolean
hasChanges: boolean
}>()
defineEmits<{
save: []
'update:defaultUserQuotaUsd': [value: number]
'update:rateLimitPerMinute': [value: number]
'update:enableRegistration': [value: boolean]
'update:autoDeleteExpiredKeys': [value: boolean]
'update:enableFormatConversion': [value: boolean]
}>()
</script>

View File

@@ -0,0 +1,202 @@
<template>
<CardSection
title="请求记录清理策略"
description="配置请求记录的分级保留和自动清理"
>
<template #actions>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<Switch
id="enable-auto-cleanup"
:model-value="enableAutoCleanup"
@update:model-value="$emit('toggleAutoCleanup', $event)"
/>
<div>
<Label
for="enable-auto-cleanup"
class="text-sm cursor-pointer"
>
启用自动清理
</Label>
<p class="text-xs text-muted-foreground">
每天凌晨执行
</p>
</div>
</div>
<Button
size="sm"
:disabled="loading || !hasChanges"
@click="$emit('save')"
>
{{ loading ? '保存中...' : '保存' }}
</Button>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label
for="detail-log-retention-days"
class="block text-sm font-medium"
>
详细记录保留天数
</Label>
<Input
id="detail-log-retention-days"
:model-value="detailLogRetentionDays"
type="number"
placeholder="7"
class="mt-1"
@update:model-value="$emit('update:detailLogRetentionDays', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
超过后压缩 body 字段
</p>
</div>
<div>
<Label
for="compressed-log-retention-days"
class="block text-sm font-medium"
>
压缩记录保留天数
</Label>
<Input
id="compressed-log-retention-days"
:model-value="compressedLogRetentionDays"
type="number"
placeholder="90"
class="mt-1"
@update:model-value="$emit('update:compressedLogRetentionDays', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
超过后删除 body 字段
</p>
</div>
<div>
<Label
for="header-retention-days"
class="block text-sm font-medium"
>
请求头保留天数
</Label>
<Input
id="header-retention-days"
:model-value="headerRetentionDays"
type="number"
placeholder="90"
class="mt-1"
@update:model-value="$emit('update:headerRetentionDays', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
超过后清空 headers 字段
</p>
</div>
<div>
<Label
for="log-retention-days"
class="block text-sm font-medium"
>
完整记录保留天数
</Label>
<Input
id="log-retention-days"
:model-value="logRetentionDays"
type="number"
placeholder="365"
class="mt-1"
@update:model-value="$emit('update:logRetentionDays', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
超过后删除整条记录
</p>
</div>
<div>
<Label
for="cleanup-batch-size"
class="block text-sm font-medium"
>
每批次清理记录数
</Label>
<Input
id="cleanup-batch-size"
:model-value="cleanupBatchSize"
type="number"
placeholder="1000"
class="mt-1"
@update:model-value="$emit('update:cleanupBatchSize', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
避免单次操作过大影响性能
</p>
</div>
<div>
<Label
for="audit-log-retention-days"
class="block text-sm font-medium"
>
审计日志保留天数
</Label>
<Input
id="audit-log-retention-days"
:model-value="auditLogRetentionDays"
type="number"
placeholder="30"
class="mt-1"
@update:model-value="$emit('update:auditLogRetentionDays', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
超过后删除审计日志记录
</p>
</div>
</div>
<!-- 清理策略说明 -->
<div class="mt-4 p-4 bg-muted/50 rounded-lg">
<h4 class="text-sm font-medium mb-2">
清理策略说明
</h4>
<div class="text-xs text-muted-foreground space-y-1">
<p>1. <strong>详细日志阶段</strong>: 保留完整的 request_body response_body</p>
<p>2. <strong>压缩日志阶段</strong>: body 字段被压缩存储节省空间</p>
<p>3. <strong>统计阶段</strong>: 仅保留 tokens成本等统计信息</p>
<p>4. <strong>归档删除</strong>: 超过保留期限后完全删除记录</p>
<p>5. <strong>审计日志</strong>: 独立清理记录用户登录操作等安全事件</p>
</div>
</div>
</CardSection>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Switch from '@/components/ui/switch.vue'
import { CardSection } from '@/components/layout'
defineProps<{
enableAutoCleanup: boolean
detailLogRetentionDays: number
compressedLogRetentionDays: number
headerRetentionDays: number
logRetentionDays: number
cleanupBatchSize: number
auditLogRetentionDays: number
loading: boolean
hasChanges: boolean
}>()
defineEmits<{
save: []
toggleAutoCleanup: [enabled: boolean]
'update:detailLogRetentionDays': [value: number]
'update:compressedLogRetentionDays': [value: number]
'update:headerRetentionDays': [value: number]
'update:logRetentionDays': [value: number]
'update:cleanupBatchSize': [value: number]
'update:auditLogRetentionDays': [value: number]
}>()
</script>

View File

@@ -0,0 +1,228 @@
<template>
<!-- 导入配置对话框 -->
<Dialog
:open="importDialogOpen"
title="导入配置"
description="选择冲突处理模式并确认导入"
@update:open="$emit('update:importDialogOpen', $event)"
>
<div class="space-y-4">
<div
v-if="importPreview"
class="text-sm"
>
<p class="font-medium mb-2">
配置预览
</p>
<ul class="space-y-1 text-muted-foreground">
<li>全局模型: {{ importPreview.global_models?.length || 0 }} </li>
<li>提供商: {{ importPreview.providers?.length || 0 }} </li>
<li>
端点: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + (p.endpoints?.length || 0), 0) }}
</li>
<li>
API Keys: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + (p.api_keys?.length || 0), 0) }}
</li>
<li v-if="importPreview.ldap_config">
LDAP 配置: 1
</li>
<li v-if="importPreview.oauth_providers?.length">
OAuth Providers: {{ importPreview.oauth_providers.length }}
</li>
</ul>
</div>
<div>
<Label class="block text-sm font-medium mb-2">冲突处理模式</Label>
<Select
:model-value="mergeMode"
:open="mergeModeSelectOpen"
@update:model-value="$emit('update:mergeMode', $event)"
@update:open="$emit('update:mergeModeSelectOpen', $event)"
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">
跳过 - 保留现有配置
</SelectItem>
<SelectItem value="overwrite">
覆盖 - 用导入配置替换
</SelectItem>
<SelectItem value="error">
报错 - 遇到冲突时中止
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-muted-foreground">
<template v-if="mergeMode === 'skip'">
已存在的配置将被保留仅导入新配置
</template>
<template v-else-if="mergeMode === 'overwrite'">
已存在的配置将被导入的配置覆盖
</template>
<template v-else>
如果发现任何冲突导入将中止并回滚
</template>
</p>
</div>
<p class="text-xs text-muted-foreground">
注意相同的 API Keys 会自动跳过不会创建重复记录
</p>
</div>
<template #footer>
<Button
variant="outline"
@click="$emit('update:importDialogOpen', false); $emit('update:mergeModeSelectOpen', false)"
>
取消
</Button>
<Button
:disabled="importLoading"
@click="$emit('confirm')"
>
{{ importLoading ? '导入中...' : '确认导入' }}
</Button>
</template>
</Dialog>
<!-- 导入结果对话框 -->
<Dialog
:open="importResultDialogOpen"
title="导入完成"
@update:open="$emit('update:importResultDialogOpen', $event)"
>
<div
v-if="importResult"
class="space-y-4"
>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="font-medium">
全局模型
</p>
<p class="text-muted-foreground">
创建: {{ importResult.stats.global_models.created }},
更新: {{ importResult.stats.global_models.updated }},
跳过: {{ importResult.stats.global_models.skipped }}
</p>
</div>
<div>
<p class="font-medium">
提供商
</p>
<p class="text-muted-foreground">
创建: {{ importResult.stats.providers.created }},
更新: {{ importResult.stats.providers.updated }},
跳过: {{ importResult.stats.providers.skipped }}
</p>
</div>
<div>
<p class="font-medium">
端点
</p>
<p class="text-muted-foreground">
创建: {{ importResult.stats.endpoints.created }},
更新: {{ importResult.stats.endpoints.updated }},
跳过: {{ importResult.stats.endpoints.skipped }}
</p>
</div>
<div>
<p class="font-medium">
API Keys
</p>
<p class="text-muted-foreground">
创建: {{ importResult.stats.keys.created }},
跳过: {{ importResult.stats.keys.skipped }}
</p>
</div>
<div class="col-span-2">
<p class="font-medium">
模型配置
</p>
<p class="text-muted-foreground">
创建: {{ importResult.stats.models.created }},
更新: {{ importResult.stats.models.updated }},
跳过: {{ importResult.stats.models.skipped }}
</p>
</div>
<div v-if="importResult.stats.ldap">
<p class="font-medium">
LDAP 配置
</p>
<p class="text-muted-foreground">
创建: {{ importResult.stats.ldap.created }},
更新: {{ importResult.stats.ldap.updated }},
跳过: {{ importResult.stats.ldap.skipped }}
</p>
</div>
<div v-if="importResult.stats.oauth">
<p class="font-medium">
OAuth Providers
</p>
<p class="text-muted-foreground">
创建: {{ importResult.stats.oauth.created }},
更新: {{ importResult.stats.oauth.updated }},
跳过: {{ importResult.stats.oauth.skipped }}
</p>
</div>
</div>
<div
v-if="importResult.stats.errors.length > 0"
class="p-3 bg-destructive/10 rounded-lg"
>
<p class="font-medium text-destructive mb-2">
警告信息
</p>
<ul class="text-sm text-destructive space-y-1">
<li
v-for="(err, index) in importResult.stats.errors"
:key="index"
>
{{ err }}
</li>
</ul>
</div>
</div>
<template #footer>
<Button @click="$emit('update:importResultDialogOpen', false)">
确定
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button.vue'
import Label from '@/components/ui/label.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import { Dialog } from '@/components/ui'
import type { ConfigExportData, ConfigImportResponse } from '@/api/admin'
defineProps<{
importDialogOpen: boolean
importResultDialogOpen: boolean
importPreview: ConfigExportData | null
importResult: ConfigImportResponse | null
mergeMode: 'skip' | 'overwrite' | 'error'
mergeModeSelectOpen: boolean
importLoading: boolean
}>()
defineEmits<{
confirm: []
'update:importDialogOpen': [value: boolean]
'update:importResultDialogOpen': [value: boolean]
'update:mergeMode': [value: 'skip' | 'overwrite' | 'error']
'update:mergeModeSelectOpen': [value: boolean]
}>()
</script>

View File

@@ -0,0 +1,67 @@
<template>
<CardSection
title="配置管理"
description="导出或导入提供商和模型配置,便于备份或迁移"
>
<div class="flex flex-wrap gap-4">
<div class="flex-1 min-w-[200px]">
<p class="text-sm text-muted-foreground mb-3">
导出当前所有提供商端点API Key 和模型配置到 JSON 文件
</p>
<Button
variant="outline"
:disabled="exportLoading"
@click="$emit('export')"
>
<Download class="w-4 h-4 mr-2" />
{{ exportLoading ? '导出中...' : '导出配置' }}
</Button>
</div>
<div class="flex-1 min-w-[200px]">
<p class="text-sm text-muted-foreground mb-3">
JSON 文件导入配置支持跳过覆盖或报错三种冲突处理模式
</p>
<div class="flex items-center gap-2">
<input
ref="configFileInput"
type="file"
accept=".json"
class="hidden"
@change="$emit('fileSelect', $event)"
>
<Button
variant="outline"
:disabled="importLoading"
@click="triggerFileSelect"
>
<Upload class="w-4 h-4 mr-2" />
{{ importLoading ? '导入中...' : '导入配置' }}
</Button>
</div>
</div>
</div>
</CardSection>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Download, Upload } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import { CardSection } from '@/components/layout'
defineProps<{
exportLoading: boolean
importLoading: boolean
}>()
defineEmits<{
export: []
fileSelect: [event: Event]
}>()
const configFileInput = ref<HTMLInputElement | null>(null)
function triggerFileSelect() {
configFileInput.value?.click()
}
</script>

View File

@@ -0,0 +1,75 @@
<template>
<CardSection
title="网络代理"
description="配置提供商出站请求的默认代理,仅影响大模型 API、余额查询、OAuth 等提供商请求"
>
<template #actions>
<Button
size="sm"
:disabled="loading || !hasChanges"
@click="$emit('save')"
>
{{ loading ? '保存中...' : '保存' }}
</Button>
</template>
<div class="max-w-md">
<Label class="block text-sm font-medium mb-1">
默认代理节点
</Label>
<Select
:model-value="proxyNodeId || '__direct__'"
@update:model-value="(v: string) => $emit('update:proxyNodeId', v === '__direct__' ? null : v)"
>
<SelectTrigger>
<SelectValue placeholder="直连不使用代理" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__direct__">
直连(不使用代理)
</SelectItem>
<SelectItem
v-for="node in onlineNodes"
:key="node.id"
:value="node.id"
>
{{ node.name }}{{ node.region ? ` · ${node.region}` : '' }} ({{ node.ip }}:{{ node.port }})
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-muted-foreground">
对未单独配置代理的提供商生效,覆盖大模型 API 请求、余额查询、OAuth 刷新等。不影响系统内部接口。
</p>
</div>
</CardSection>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button.vue'
import Label from '@/components/ui/label.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import { CardSection } from '@/components/layout'
interface ProxyNode {
id: string
name: string
region?: string | null
ip: string
port: number
}
defineProps<{
proxyNodeId: string | null
onlineNodes: ProxyNode[]
loading: boolean
hasChanges: boolean
}>()
defineEmits<{
save: []
'update:proxyNodeId': [value: string | null]
}>()
</script>

View File

@@ -0,0 +1,139 @@
<template>
<CardSection
title="请求记录"
description="控制请求/响应详情的入库方式和内容"
>
<template #actions>
<Button
size="sm"
:disabled="loading || !hasChanges"
@click="$emit('save')"
>
{{ loading ? '保存中...' : '保存' }}
</Button>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label
for="request-log-level"
class="block text-sm font-medium mb-2"
>
记录详细程度
</Label>
<Select
:model-value="requestRecordLevel"
@update:model-value="$emit('update:requestRecordLevel', $event)"
>
<SelectTrigger
id="request-log-level"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="basic">
BASIC - 基本信息 (~1KB/)
</SelectItem>
<SelectItem value="headers">
HEADERS - 含请求头 (~2-3KB/)
</SelectItem>
<SelectItem value="full">
FULL - 完整请求响应 (~50KB/)
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-muted-foreground">
敏感信息会自动脱敏
</p>
</div>
<div>
<Label
for="max-request-body-size"
class="block text-sm font-medium"
>
最大请求体大小 (KB)
</Label>
<Input
id="max-request-body-size"
:model-value="maxRequestBodySizeKB"
type="number"
placeholder="512"
class="mt-1"
@update:model-value="$emit('update:maxRequestBodySizeKB', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
超过此大小的请求体将被截断记录
</p>
</div>
<div>
<Label
for="max-response-body-size"
class="block text-sm font-medium"
>
最大响应体大小 (KB)
</Label>
<Input
id="max-response-body-size"
:model-value="maxResponseBodySizeKB"
type="number"
placeholder="512"
class="mt-1"
@update:model-value="$emit('update:maxResponseBodySizeKB', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
超过此大小的响应体将被截断记录
</p>
</div>
<div>
<Label
for="sensitive-headers"
class="block text-sm font-medium"
>
敏感请求头
</Label>
<Input
id="sensitive-headers"
:model-value="sensitiveHeadersStr"
placeholder="authorization, x-api-key, cookie"
class="mt-1"
@update:model-value="$emit('update:sensitiveHeadersStr', $event)"
/>
<p class="mt-1 text-xs text-muted-foreground">
逗号分隔这些请求头会被脱敏处理
</p>
</div>
</div>
</CardSection>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import { CardSection } from '@/components/layout'
defineProps<{
requestRecordLevel: string
maxRequestBodySizeKB: number
maxResponseBodySizeKB: number
sensitiveHeadersStr: string
loading: boolean
hasChanges: boolean
}>()
defineEmits<{
save: []
'update:requestRecordLevel': [value: string]
'update:maxRequestBodySizeKB': [value: number]
'update:maxResponseBodySizeKB': [value: number]
'update:sensitiveHeadersStr': [value: string]
}>()
</script>

View File

@@ -0,0 +1,181 @@
<template>
<CardSection
title="定时任务"
description="配置系统后台定时任务"
>
<div class="space-y-3">
<template
v-for="task in scheduledTasks"
:key="task.id"
>
<div
class="group relative rounded-xl border transition-all duration-300"
:class="task.enabled
? 'border-primary/30 bg-primary/[0.02] shadow-sm shadow-primary/5'
: 'border-border bg-card hover:border-border/80'"
>
<!-- 主行 -->
<div class="flex items-center gap-4 p-4">
<!-- 左侧开关 -->
<div class="shrink-0">
<Switch
:id="`enable-${task.id}`"
:model-value="task.enabled"
@update:model-value="task.onToggle"
/>
</div>
<!-- 中间图标标题描述 -->
<div class="flex items-center gap-3 flex-1 min-w-0">
<div
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 transition-colors duration-300"
:class="task.enabled
? 'bg-primary/10 text-primary'
: 'text-muted-foreground'"
>
<component
:is="task.icon"
class="w-4.5 h-4.5"
/>
</div>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-sm">
{{ task.title }}
</h4>
<p class="text-xs text-muted-foreground mt-0.5 truncate">
{{ task.description }}
</p>
</div>
</div>
<!-- 右侧时间选择器 + 保存按钮 -->
<div
v-if="task.enabled && task.hasTimeConfig"
class="flex items-center gap-2 shrink-0"
>
<Clock class="w-4 h-4 text-muted-foreground" />
<Select
:model-value="task.hour"
@update:model-value="(val: string) => task.updateTime(val, task.minute)"
>
<SelectTrigger class="w-14 h-8 text-xs">
<SelectValue placeholder="" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="h in 24"
:key="h - 1"
:value="String(h - 1).padStart(2, '0')"
>
{{ String(h - 1).padStart(2, '0') }}
</SelectItem>
</SelectContent>
</Select>
<span class="text-sm text-muted-foreground">:</span>
<Select
:model-value="task.minute"
@update:model-value="(val: string) => task.updateTime(task.hour, val)"
>
<SelectTrigger class="w-14 h-8 text-xs">
<SelectValue placeholder="" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="m in 60"
:key="m - 1"
:value="String(m - 1).padStart(2, '0')"
>
{{ String(m - 1).padStart(2, '0') }}
</SelectItem>
</SelectContent>
</Select>
<Button
v-if="task.hasChanges"
variant="default"
size="sm"
class="h-8 px-2.5 text-xs"
:disabled="task.loading"
@click="task.onSave"
>
<Check
v-if="!task.loading"
class="w-3.5 h-3.5"
/>
<Loader2
v-else
class="w-3.5 h-3.5 animate-spin"
/>
</Button>
</div>
</div>
<!-- 额外配置区域(仅用户配额重置任务有) -->
<div
v-if="task.id === 'user-quota-reset' && task.enabled"
class="px-4 pb-4 pt-0"
>
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/30 border border-border/50">
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">重置周期</span>
<div class="flex items-center gap-1.5">
<span class="text-muted-foreground">每</span>
<Input
:model-value="quotaResetIntervalDays"
type="number"
min="1"
step="1"
class="w-14 h-7 text-xs text-center px-2"
@update:model-value="$emit('update:quotaResetIntervalDays', Number($event))"
/>
<span class="text-muted-foreground">天</span>
</div>
</div>
</div>
<p class="text-[11px] text-muted-foreground mt-2 ml-1">
滚动计算:距离上次成功执行满 N 天后再次执行
</p>
</div>
</div>
</template>
</div>
</CardSection>
</template>
<script setup lang="ts">
import { Clock, Check, Loader2 } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Switch from '@/components/ui/switch.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import { CardSection } from '@/components/layout'
import type { Component } from 'vue'
interface ScheduledTask {
id: string
icon: Component
title: string
description: string
enabled: boolean
hasTimeConfig: boolean
hour: string
minute: string
updateTime: (hour: string, minute: string) => void
hasChanges: boolean
loading: boolean
onToggle: (enabled: boolean) => void
onSave: () => void
}
defineProps<{
scheduledTasks: ScheduledTask[]
quotaResetIntervalDays: number
}>()
defineEmits<{
'update:quotaResetIntervalDays': [value: number]
}>()
</script>

View File

@@ -0,0 +1,76 @@
<template>
<CardSection
title="站点信息"
description="自定义站点名称和副标题,影响导航栏、登录页、指南页面和邮件等全站显示"
>
<template #actions>
<Button
size="sm"
:disabled="loading || !hasChanges"
@click="$emit('save')"
>
{{ loading ? '保存中...' : '保存' }}
</Button>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label
for="site-name"
class="block text-sm font-medium"
>
站点名称
</Label>
<Input
id="site-name"
:model-value="siteName"
type="text"
placeholder="Aether"
class="mt-1"
@update:model-value="$emit('update:siteName', $event)"
/>
<p class="mt-1 text-xs text-muted-foreground">
显示在导航栏登录页标题和邮件中
</p>
</div>
<div>
<Label
for="site-subtitle"
class="block text-sm font-medium"
>
站点副标题
</Label>
<Input
id="site-subtitle"
:model-value="siteSubtitle"
type="text"
placeholder="AI Gateway"
class="mt-1"
@update:model-value="$emit('update:siteSubtitle', $event)"
/>
<p class="mt-1 text-xs text-muted-foreground">
显示在导航栏品牌名称下方
</p>
</div>
</div>
</CardSection>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import { CardSection } from '@/components/layout'
defineProps<{
siteName: string
siteSubtitle: string
loading: boolean
hasChanges: boolean
}>()
defineEmits<{
save: []
'update:siteName': [value: string]
'update:siteSubtitle': [value: string]
}>()
</script>

View File

@@ -0,0 +1,33 @@
<template>
<CardSection
title="系统信息"
description="当前系统版本和构建信息"
>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<Label class="text-sm font-medium text-muted-foreground">版本:</Label>
<span
v-if="systemVersion"
class="text-sm font-mono"
>
{{ systemVersion }}
</span>
<span
v-else
class="text-sm text-muted-foreground"
>
加载中...
</span>
</div>
</div>
</CardSection>
</template>
<script setup lang="ts">
import Label from '@/components/ui/label.vue'
import { CardSection } from '@/components/layout'
defineProps<{
systemVersion: string
}>()
</script>

View File

@@ -0,0 +1,67 @@
<template>
<CardSection
title="用户数据管理"
description="导出或导入用户及其 API Keys 数据(不含管理员)"
>
<div class="flex flex-wrap gap-4">
<div class="flex-1 min-w-[200px]">
<p class="text-sm text-muted-foreground mb-3">
导出所有普通用户及其 API Keys JSON 文件
</p>
<Button
variant="outline"
:disabled="exportLoading"
@click="$emit('export')"
>
<Download class="w-4 h-4 mr-2" />
{{ exportLoading ? '导出中...' : '导出用户数据' }}
</Button>
</div>
<div class="flex-1 min-w-[200px]">
<p class="text-sm text-muted-foreground mb-3">
JSON 文件导入用户数据需相同 ENCRYPTION_KEY
</p>
<div class="flex items-center gap-2">
<input
ref="usersFileInput"
type="file"
accept=".json"
class="hidden"
@change="$emit('fileSelect', $event)"
>
<Button
variant="outline"
:disabled="importLoading"
@click="triggerFileSelect"
>
<Upload class="w-4 h-4 mr-2" />
{{ importLoading ? '导入中...' : '导入用户数据' }}
</Button>
</div>
</div>
</div>
</CardSection>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Download, Upload } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import { CardSection } from '@/components/layout'
defineProps<{
exportLoading: boolean
importLoading: boolean
}>()
defineEmits<{
export: []
fileSelect: [event: Event]
}>()
const usersFileInput = ref<HTMLInputElement | null>(null)
function triggerFileSelect() {
usersFileInput.value?.click()
}
</script>

View File

@@ -0,0 +1,183 @@
<template>
<!-- 用户数据导入对话框 -->
<Dialog
:open="importUsersDialogOpen"
title="导入用户数据"
description="选择冲突处理模式并确认导入"
@update:open="$emit('update:importUsersDialogOpen', $event)"
>
<div class="space-y-4">
<div
v-if="importUsersPreview"
class="text-sm"
>
<p class="font-medium mb-2">
数据预览
</p>
<ul class="space-y-1 text-muted-foreground">
<li>用户: {{ importUsersPreview.users?.length || 0 }} </li>
<li>
API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: any) => sum + (u.api_keys?.length || 0), 0) }}
</li>
<li v-if="importUsersPreview.standalone_keys?.length">
独立余额 Keys: {{ importUsersPreview.standalone_keys.length }}
</li>
</ul>
</div>
<div>
<Label class="block text-sm font-medium mb-2">冲突处理模式</Label>
<Select
:model-value="usersMergeMode"
:open="usersMergeModeSelectOpen"
@update:model-value="$emit('update:usersMergeMode', $event)"
@update:open="$emit('update:usersMergeModeSelectOpen', $event)"
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">
跳过 - 保留现有用户
</SelectItem>
<SelectItem value="overwrite">
覆盖 - 用导入数据替换
</SelectItem>
<SelectItem value="error">
报错 - 遇到冲突时中止
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-muted-foreground">
<template v-if="usersMergeMode === 'skip'">
已存在的用户将被保留仅导入新用户
</template>
<template v-else-if="usersMergeMode === 'overwrite'">
已存在的用户将被导入的数据覆盖
</template>
<template v-else>
如果发现任何冲突导入将中止并回滚
</template>
</p>
</div>
<p class="text-xs text-muted-foreground">
注意用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY 环境变量才能正常工作
</p>
</div>
<template #footer>
<Button
variant="outline"
@click="$emit('update:importUsersDialogOpen', false); $emit('update:usersMergeModeSelectOpen', false)"
>
取消
</Button>
<Button
:disabled="importUsersLoading"
@click="$emit('confirm')"
>
{{ importUsersLoading ? '导入中...' : '确认导入' }}
</Button>
</template>
</Dialog>
<!-- 用户数据导入结果对话框 -->
<Dialog
:open="importUsersResultDialogOpen"
title="用户数据导入完成"
@update:open="$emit('update:importUsersResultDialogOpen', $event)"
>
<div
v-if="importUsersResult"
class="space-y-4"
>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="font-medium">
用户
</p>
<p class="text-muted-foreground">
创建: {{ importUsersResult.stats.users.created }},
更新: {{ importUsersResult.stats.users.updated }},
跳过: {{ importUsersResult.stats.users.skipped }}
</p>
</div>
<div>
<p class="font-medium">
API Keys
</p>
<p class="text-muted-foreground">
创建: {{ importUsersResult.stats.api_keys.created }},
跳过: {{ importUsersResult.stats.api_keys.skipped }}
</p>
</div>
<div
v-if="importUsersResult.stats.standalone_keys"
class="col-span-2"
>
<p class="font-medium">
独立余额 Keys
</p>
<p class="text-muted-foreground">
创建: {{ importUsersResult.stats.standalone_keys.created }},
跳过: {{ importUsersResult.stats.standalone_keys.skipped }}
</p>
</div>
</div>
<div
v-if="importUsersResult.stats.errors.length > 0"
class="p-3 bg-destructive/10 rounded-lg"
>
<p class="font-medium text-destructive mb-2">
警告信息
</p>
<ul class="text-sm text-destructive space-y-1">
<li
v-for="(err, index) in importUsersResult.stats.errors"
:key="index"
>
{{ err }}
</li>
</ul>
</div>
</div>
<template #footer>
<Button @click="$emit('update:importUsersResultDialogOpen', false)">
确定
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button.vue'
import Label from '@/components/ui/label.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import { Dialog } from '@/components/ui'
import type { UsersExportData, UsersImportResponse } from '@/api/admin'
defineProps<{
importUsersDialogOpen: boolean
importUsersResultDialogOpen: boolean
importUsersPreview: UsersExportData | null
importUsersResult: UsersImportResponse | null
usersMergeMode: 'skip' | 'overwrite' | 'error'
usersMergeModeSelectOpen: boolean
importUsersLoading: boolean
}>()
defineEmits<{
confirm: []
'update:importUsersDialogOpen': [value: boolean]
'update:importUsersResultDialogOpen': [value: boolean]
'update:usersMergeMode': [value: 'skip' | 'overwrite' | 'error']
'update:usersMergeModeSelectOpen': [value: boolean]
}>()
</script>

View File

@@ -0,0 +1,240 @@
import { ref } from 'vue'
import { useToast } from '@/composables/useToast'
import {
adminApi,
type ConfigExportData,
type ConfigImportResponse,
type UsersExportData,
type UsersImportResponse,
} from '@/api/admin'
import { log } from '@/utils/logger'
import type { SystemConfig } from './useSystemConfig'
// 文件大小限制 (10MB)
const MAX_FILE_SIZE = 10 * 1024 * 1024
export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
const { success, error } = useToast()
// 配置导出/导入相关
const exportLoading = ref(false)
const importLoading = ref(false)
const importDialogOpen = ref(false)
const importResultDialogOpen = ref(false)
const configFileInput = ref<HTMLInputElement | null>(null)
const importPreview = ref<ConfigExportData | null>(null)
const importResult = ref<ConfigImportResponse | null>(null)
const mergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
const mergeModeSelectOpen = ref(false)
// 用户数据导出/导入相关
const exportUsersLoading = ref(false)
const importUsersLoading = ref(false)
const importUsersDialogOpen = ref(false)
const importUsersResultDialogOpen = ref(false)
const usersFileInput = ref<HTMLInputElement | null>(null)
const importUsersPreview = ref<UsersExportData | null>(null)
const importUsersResult = ref<UsersImportResponse | null>(null)
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
const usersMergeModeSelectOpen = ref(false)
// 导出配置
async function handleExportConfig() {
exportLoading.value = true
try {
const data = await adminApi.exportConfig()
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${systemConfig.value.site_name.toLowerCase()}-config-${new Date().toISOString().slice(0, 10)}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
success('配置已导出')
} catch (err) {
error('导出配置失败')
log.error('导出配置失败:', err)
} finally {
exportLoading.value = false
}
}
// 触发文件选择
function triggerConfigFileSelect() {
configFileInput.value?.click()
}
// 处理文件选择
function handleConfigFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
if (file.size > MAX_FILE_SIZE) {
error('文件大小不能超过 10MB')
input.value = ''
return
}
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = e.target?.result as string
const data = JSON.parse(content) as ConfigExportData
if (!data.version) {
error('无效的配置文件:缺少版本信息')
return
}
importPreview.value = data
mergeMode.value = 'skip'
importDialogOpen.value = true
} catch (err) {
error('解析配置文件失败,请确保是有效的 JSON 文件')
log.error('解析配置文件失败:', err)
}
}
reader.readAsText(file)
input.value = ''
}
// 确认导入
async function confirmImport() {
if (!importPreview.value) return
importLoading.value = true
try {
const result = await adminApi.importConfig({
...importPreview.value,
merge_mode: mergeMode.value,
})
importResult.value = result
importDialogOpen.value = false
mergeModeSelectOpen.value = false
importResultDialogOpen.value = true
success('配置导入成功')
} catch (err: any) {
error(err.response?.data?.detail || '导入配置失败')
log.error('导入配置失败:', err)
} finally {
importLoading.value = false
}
}
// 导出用户数据
async function handleExportUsers() {
exportUsersLoading.value = true
try {
const data = await adminApi.exportUsers()
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${systemConfig.value.site_name.toLowerCase()}-users-${new Date().toISOString().slice(0, 10)}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
success('用户数据已导出')
} catch (err) {
error('导出用户数据失败')
log.error('导出用户数据失败:', err)
} finally {
exportUsersLoading.value = false
}
}
// 触发用户数据文件选择
function triggerUsersFileSelect() {
usersFileInput.value?.click()
}
// 处理用户数据文件选择
function handleUsersFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
if (file.size > MAX_FILE_SIZE) {
error('文件大小不能超过 10MB')
input.value = ''
return
}
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = e.target?.result as string
const data = JSON.parse(content) as UsersExportData
importUsersPreview.value = data
usersMergeMode.value = 'skip'
importUsersDialogOpen.value = true
} catch (err) {
error('解析用户数据文件失败,请确保是有效的 JSON 文件')
log.error('解析用户数据文件失败:', err)
}
}
reader.readAsText(file)
input.value = ''
}
// 确认导入用户数据
async function confirmImportUsers() {
if (!importUsersPreview.value) return
importUsersLoading.value = true
try {
const result = await adminApi.importUsers({
...importUsersPreview.value,
merge_mode: usersMergeMode.value,
})
importUsersResult.value = result
importUsersDialogOpen.value = false
usersMergeModeSelectOpen.value = false
importUsersResultDialogOpen.value = true
success('用户数据导入成功')
} catch (err: any) {
error(err.response?.data?.detail || '导入用户数据失败')
log.error('导入用户数据失败:', err)
} finally {
importUsersLoading.value = false
}
}
return {
// 配置导出/导入
exportLoading,
importLoading,
importDialogOpen,
importResultDialogOpen,
configFileInput,
importPreview,
importResult,
mergeMode,
mergeModeSelectOpen,
handleExportConfig,
triggerConfigFileSelect,
handleConfigFileSelect,
confirmImport,
// 用户数据导出/导入
exportUsersLoading,
importUsersLoading,
importUsersDialogOpen,
importUsersResultDialogOpen,
usersFileInput,
importUsersPreview,
importUsersResult,
usersMergeMode,
usersMergeModeSelectOpen,
handleExportUsers,
triggerUsersFileSelect,
handleUsersFileSelect,
confirmImportUsers,
}
}

View File

@@ -0,0 +1,280 @@
import { ref, computed, type Ref } from 'vue'
import { CalendarCheck, RotateCcw, RefreshCw } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { adminApi } from '@/api/admin'
import { log } from '@/utils/logger'
import type { SystemConfig } from './useSystemConfig'
export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
const { success, error } = useToast()
const checkinConfigLoading = ref(false)
const quotaResetConfigLoading = ref(false)
// 签到时间的原始值(用于回滚)
const previousCheckinTime = ref('')
// 用户配额重置时间的原始值
const previousUserQuotaResetTime = ref('')
const previousUserQuotaResetIntervalDays = ref(1)
// 初始化原始值(在配置加载完成后调用)
function initPreviousValues() {
previousCheckinTime.value = systemConfig.value.provider_checkin_time
previousUserQuotaResetTime.value = systemConfig.value.user_quota_reset_time
previousUserQuotaResetIntervalDays.value = systemConfig.value.user_quota_reset_interval_days
}
// 签到时间
const checkinHour = computed(() => {
const time = systemConfig.value.provider_checkin_time
if (!time || !time.includes(':')) return '01'
return time.split(':')[0]
})
const checkinMinute = computed(() => {
const time = systemConfig.value.provider_checkin_time
if (!time || !time.includes(':')) return '05'
return time.split(':')[1]
})
function updateCheckinTime(hour: string, minute: string) {
systemConfig.value.provider_checkin_time = `${hour}:${minute}`
}
const hasCheckinTimeChanged = computed(() => {
return systemConfig.value.provider_checkin_time !== previousCheckinTime.value
})
// 用户配额重置时间
const userQuotaResetHour = computed(() => {
const time = systemConfig.value.user_quota_reset_time
if (!time || !time.includes(':')) return '05'
return time.split(':')[0]
})
const userQuotaResetMinute = computed(() => {
const time = systemConfig.value.user_quota_reset_time
if (!time || !time.includes(':')) return '00'
return time.split(':')[1]
})
function updateUserQuotaResetTime(hour: string, minute: string) {
systemConfig.value.user_quota_reset_time = `${hour}:${minute}`
}
const hasUserQuotaResetTimeChanged = computed(() => {
return systemConfig.value.user_quota_reset_time !== previousUserQuotaResetTime.value
})
const hasUserQuotaResetIntervalChanged = computed(() => {
return (
systemConfig.value.user_quota_reset_interval_days !==
previousUserQuotaResetIntervalDays.value
)
})
const hasQuotaResetConfigChanged = computed(() => {
return hasUserQuotaResetTimeChanged.value || hasUserQuotaResetIntervalChanged.value
})
// Toggle handlers
async function handleProviderCheckinToggle(enabled: boolean) {
const previousValue = systemConfig.value.enable_provider_checkin
systemConfig.value.enable_provider_checkin = enabled
try {
await adminApi.updateSystemConfig(
'enable_provider_checkin',
enabled,
'是否启用 Provider 自动签到任务'
)
success(enabled ? '已启用自动签到' : '已禁用自动签到')
} catch (err) {
error('保存配置失败')
log.error('保存自动签到配置失败:', err)
systemConfig.value.enable_provider_checkin = previousValue
}
}
async function handleUserQuotaResetToggle(enabled: boolean) {
const previousValue = systemConfig.value.enable_user_quota_reset
systemConfig.value.enable_user_quota_reset = enabled
try {
await adminApi.updateSystemConfig(
'enable_user_quota_reset',
enabled,
'是否启用用户配额自动重置任务'
)
success(enabled ? '已启用用户配额自动重置' : '已禁用用户配额自动重置')
} catch (err) {
error('保存配置失败')
log.error('保存用户配额自动重置配置失败:', err)
systemConfig.value.enable_user_quota_reset = previousValue
}
}
async function handleOAuthTokenRefreshToggle(enabled: boolean) {
const previousValue = systemConfig.value.enable_oauth_token_refresh
systemConfig.value.enable_oauth_token_refresh = enabled
try {
await adminApi.updateSystemConfig(
'enable_oauth_token_refresh',
enabled,
'是否启用 OAuth Token 自动刷新任务'
)
success(enabled ? '已启用 OAuth Token 自动刷新' : '已禁用 OAuth Token 自动刷新')
} catch (err) {
error('保存配置失败')
log.error('保存 OAuth Token 自动刷新配置失败:', err)
systemConfig.value.enable_oauth_token_refresh = previousValue
}
}
// Save handlers
async function handleCheckinTimeSave() {
const newTime = systemConfig.value.provider_checkin_time
if (!newTime || !/^\d{2}:\d{2}$/.test(newTime)) {
error('请输入有效的时间格式 (HH:MM)')
return
}
checkinConfigLoading.value = true
try {
await adminApi.updateSystemConfig(
'provider_checkin_time',
newTime,
'Provider 自动签到执行时间HH:MM 格式)'
)
previousCheckinTime.value = newTime
success(`签到时间已设置为 ${newTime}`)
} catch (err) {
error('保存签到时间失败')
log.error('保存签到时间失败:', err)
} finally {
checkinConfigLoading.value = false
}
}
async function handleQuotaResetConfigSave() {
const configItems: Array<{
key: string
value: any
description: string
onSuccess: () => void
}> = []
if (hasUserQuotaResetTimeChanged.value) {
const newTime = systemConfig.value.user_quota_reset_time
if (!newTime || !/^\d{2}:\d{2}$/.test(newTime)) {
error('请输入有效的时间格式 (HH:MM)')
return
}
configItems.push({
key: 'user_quota_reset_time',
value: newTime,
description: '用户配额自动重置执行时间HH:MM 格式)',
onSuccess: () => {
previousUserQuotaResetTime.value = newTime
},
})
}
if (hasUserQuotaResetIntervalChanged.value) {
let intervalDays = Number(systemConfig.value.user_quota_reset_interval_days)
if (!Number.isFinite(intervalDays) || intervalDays < 1) intervalDays = 1
intervalDays = Math.trunc(intervalDays)
systemConfig.value.user_quota_reset_interval_days = intervalDays
configItems.push({
key: 'user_quota_reset_interval_days',
value: intervalDays,
description: '用户配额重置周期(天数),滚动计算',
onSuccess: () => {
previousUserQuotaResetIntervalDays.value = intervalDays
},
})
}
if (configItems.length === 0) return
quotaResetConfigLoading.value = true
const failedKeys: string[] = []
try {
for (const item of configItems) {
try {
await adminApi.updateSystemConfig(item.key, item.value, item.description)
item.onSuccess()
} catch (err) {
failedKeys.push(item.key)
log.error(`保存配额重置配置失败: ${item.key}`, err)
}
}
if (failedKeys.length > 0) {
error(`部分配置保存失败: ${failedKeys.join(', ')}`)
return
}
success('配额重置配置已保存')
} finally {
quotaResetConfigLoading.value = false
}
}
// 定时任务配置列表
const scheduledTasks = computed(() => [
{
id: 'provider-checkin',
icon: CalendarCheck,
title: 'Provider 自动签到',
description: '自动执行已配置 Provider 的签到任务',
enabled: systemConfig.value.enable_provider_checkin,
hasTimeConfig: true,
hour: checkinHour.value,
minute: checkinMinute.value,
updateTime: updateCheckinTime,
hasChanges: hasCheckinTimeChanged.value,
loading: checkinConfigLoading.value,
onToggle: handleProviderCheckinToggle,
onSave: handleCheckinTimeSave,
},
{
id: 'user-quota-reset',
icon: RotateCcw,
title: '用户配额自动重置',
description: '定时将用户已使用配额重置为零',
enabled: systemConfig.value.enable_user_quota_reset,
hasTimeConfig: true,
hour: userQuotaResetHour.value,
minute: userQuotaResetMinute.value,
updateTime: updateUserQuotaResetTime,
hasChanges: hasQuotaResetConfigChanged.value,
loading: quotaResetConfigLoading.value,
onToggle: handleUserQuotaResetToggle,
onSave: handleQuotaResetConfigSave,
},
{
id: 'oauth-token-refresh',
icon: RefreshCw,
title: 'OAuth Token 自动刷新',
description: '主动刷新即将过期的 OAuth Token动态调度',
enabled: systemConfig.value.enable_oauth_token_refresh,
hasTimeConfig: false,
hour: '',
minute: '',
updateTime: () => {},
hasChanges: false,
loading: false,
onToggle: handleOAuthTokenRefreshToggle,
onSave: () => {},
},
])
return {
checkinConfigLoading,
quotaResetConfigLoading,
scheduledTasks,
initPreviousValues,
}
}

View File

@@ -0,0 +1,498 @@
import { ref, computed } from 'vue'
import { useToast } from '@/composables/useToast'
import { adminApi } from '@/api/admin'
import { log } from '@/utils/logger'
import { useSiteInfo } from '@/composables/useSiteInfo'
export interface SystemConfig {
// 站点信息
site_name: string
site_subtitle: string
// 网络代理
system_proxy_node_id: string | null
// 基础配置
default_user_quota_usd: number
rate_limit_per_minute: number
enable_registration: boolean
// 独立余额 Key 过期管理
auto_delete_expired_keys: boolean
// 格式转换
enable_format_conversion: boolean
// 请求记录
request_record_level: string
max_request_body_size: number
max_response_body_size: number
sensitive_headers: string[]
// 请求记录清理
enable_auto_cleanup: boolean
detail_log_retention_days: number
compressed_log_retention_days: number
header_retention_days: number
log_retention_days: number
cleanup_batch_size: number
audit_log_retention_days: number
// 定时任务
enable_provider_checkin: boolean
provider_checkin_time: string
enable_user_quota_reset: boolean
user_quota_reset_time: string
user_quota_reset_interval_days: number
enable_oauth_token_refresh: boolean
}
const CONFIG_KEYS = [
// 站点信息
'site_name',
'site_subtitle',
// 网络代理
'system_proxy_node_id',
// 基础配置
'default_user_quota_usd',
'rate_limit_per_minute',
'enable_registration',
// 独立余额 Key 过期管理
'auto_delete_expired_keys',
// 格式转换
'enable_format_conversion',
// 请求记录
'request_record_level',
'max_request_body_size',
'max_response_body_size',
'sensitive_headers',
// 请求记录清理
'enable_auto_cleanup',
'detail_log_retention_days',
'compressed_log_retention_days',
'header_retention_days',
'log_retention_days',
'cleanup_batch_size',
'audit_log_retention_days',
// 定时任务
'enable_provider_checkin',
'provider_checkin_time',
'enable_user_quota_reset',
'user_quota_reset_time',
'user_quota_reset_interval_days',
'enable_oauth_token_refresh',
]
function createDefaultConfig(): SystemConfig {
return {
// 站点信息
site_name: 'Aether',
site_subtitle: 'AI Gateway',
// 网络代理
system_proxy_node_id: null,
// 基础配置
default_user_quota_usd: 10.0,
rate_limit_per_minute: 0,
enable_registration: false,
// 独立余额 Key 过期管理
auto_delete_expired_keys: false,
// 格式转换
enable_format_conversion: false,
// 请求记录
request_record_level: 'basic',
max_request_body_size: 1048576,
max_response_body_size: 1048576,
sensitive_headers: ['authorization', 'x-api-key', 'api-key', 'cookie', 'set-cookie'],
// 请求记录清理
enable_auto_cleanup: true,
detail_log_retention_days: 7,
compressed_log_retention_days: 90,
header_retention_days: 90,
log_retention_days: 365,
cleanup_batch_size: 1000,
audit_log_retention_days: 30,
// 定时任务
enable_provider_checkin: true,
provider_checkin_time: '01:05',
enable_user_quota_reset: false,
user_quota_reset_time: '05:00',
user_quota_reset_interval_days: 1,
enable_oauth_token_refresh: true,
}
}
export function useSystemConfig() {
const { success, error } = useToast()
const { refreshSiteInfo } = useSiteInfo()
const systemConfig = ref<SystemConfig>(createDefaultConfig())
const originalConfig = ref<SystemConfig | null>(null)
const systemVersion = ref<string>('')
// 各模块 loading 状态
const siteInfoLoading = ref(false)
const proxyConfigLoading = ref(false)
const basicConfigLoading = ref(false)
const logConfigLoading = ref(false)
const cleanupConfigLoading = ref(false)
// 变动检测
const hasSiteInfoChanges = computed(() => {
if (!originalConfig.value) return false
return (
systemConfig.value.site_name !== originalConfig.value.site_name ||
systemConfig.value.site_subtitle !== originalConfig.value.site_subtitle
)
})
const hasProxyConfigChanges = computed(() => {
if (!originalConfig.value) return false
return systemConfig.value.system_proxy_node_id !== originalConfig.value.system_proxy_node_id
})
const hasBasicConfigChanges = computed(() => {
if (!originalConfig.value) return false
return (
systemConfig.value.default_user_quota_usd !== originalConfig.value.default_user_quota_usd ||
systemConfig.value.rate_limit_per_minute !== originalConfig.value.rate_limit_per_minute ||
systemConfig.value.enable_registration !== originalConfig.value.enable_registration ||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion
)
})
const hasLogConfigChanges = computed(() => {
if (!originalConfig.value) return false
return (
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_response_body_size !== originalConfig.value.max_response_body_size ||
JSON.stringify(systemConfig.value.sensitive_headers) !==
JSON.stringify(originalConfig.value.sensitive_headers)
)
})
const hasCleanupConfigChanges = computed(() => {
if (!originalConfig.value) return false
return (
systemConfig.value.detail_log_retention_days !==
originalConfig.value.detail_log_retention_days ||
systemConfig.value.compressed_log_retention_days !==
originalConfig.value.compressed_log_retention_days ||
systemConfig.value.header_retention_days !== originalConfig.value.header_retention_days ||
systemConfig.value.log_retention_days !== originalConfig.value.log_retention_days ||
systemConfig.value.cleanup_batch_size !== originalConfig.value.cleanup_batch_size ||
systemConfig.value.audit_log_retention_days !==
originalConfig.value.audit_log_retention_days
)
})
// KB 和字节之间的转换
const maxRequestBodySizeKB = computed({
get: () => Math.round(systemConfig.value.max_request_body_size / 1024),
set: (val: number) => {
systemConfig.value.max_request_body_size = val * 1024
},
})
const maxResponseBodySizeKB = computed({
get: () => Math.round(systemConfig.value.max_response_body_size / 1024),
set: (val: number) => {
systemConfig.value.max_response_body_size = val * 1024
},
})
// 敏感请求头数组和字符串之间的转换
const sensitiveHeadersStr = computed({
get: () => systemConfig.value.sensitive_headers.join(', '),
set: (val: string) => {
systemConfig.value.sensitive_headers = val
.split(',')
.map((s) => s.trim().toLowerCase())
.filter((s) => s.length > 0)
},
})
// 加载配置
async function loadSystemConfig() {
try {
for (const key of CONFIG_KEYS) {
try {
const response = await adminApi.getSystemConfig(key)
if (response.value !== null && response.value !== undefined) {
;(systemConfig.value as any)[key] = response.value
}
} catch {
// 配置不存在时使用默认值,无需处理
}
}
originalConfig.value = JSON.parse(JSON.stringify(systemConfig.value))
} catch (err) {
error('加载系统配置失败')
log.error('加载系统配置失败:', err)
}
}
async function loadSystemVersion() {
try {
const data = await adminApi.getSystemVersion()
systemVersion.value = data.version
} catch (err) {
log.error('加载系统版本失败:', err)
}
}
// 保存函数
async function saveSiteInfo() {
siteInfoLoading.value = true
try {
const configItems = [
{ key: 'site_name', value: systemConfig.value.site_name, description: '站点名称' },
{
key: 'site_subtitle',
value: systemConfig.value.site_subtitle,
description: '站点副标题',
},
]
await Promise.all(
configItems.map((item) =>
adminApi.updateSystemConfig(item.key, item.value, item.description)
)
)
if (originalConfig.value) {
originalConfig.value.site_name = systemConfig.value.site_name
originalConfig.value.site_subtitle = systemConfig.value.site_subtitle
}
await refreshSiteInfo()
success('站点信息已保存')
} catch (err) {
error('保存站点信息失败')
log.error('保存站点信息失败:', err)
} finally {
siteInfoLoading.value = false
}
}
async function saveProxyConfig() {
proxyConfigLoading.value = true
try {
await adminApi.updateSystemConfig(
'system_proxy_node_id',
systemConfig.value.system_proxy_node_id || null,
'系统默认代理节点 ID'
)
if (originalConfig.value) {
originalConfig.value.system_proxy_node_id = systemConfig.value.system_proxy_node_id
}
success('网络代理配置已保存')
} catch (err) {
error('保存代理配置失败')
log.error('保存代理配置失败:', err)
} finally {
proxyConfigLoading.value = false
}
}
async function saveBasicConfig() {
basicConfigLoading.value = true
try {
const configItems = [
{
key: 'default_user_quota_usd',
value: systemConfig.value.default_user_quota_usd,
description: '默认用户配额(美元)',
},
{
key: 'rate_limit_per_minute',
value: systemConfig.value.rate_limit_per_minute,
description: '每分钟请求限制',
},
{
key: 'enable_registration',
value: systemConfig.value.enable_registration,
description: '是否开放用户注册',
},
{
key: 'auto_delete_expired_keys',
value: systemConfig.value.auto_delete_expired_keys,
description: '是否自动删除过期的API Key',
},
{
key: 'enable_format_conversion',
value: systemConfig.value.enable_format_conversion,
description: '全局格式转换开关:开启时强制允许所有提供商的格式转换',
},
]
await Promise.all(
configItems.map((item) =>
adminApi.updateSystemConfig(item.key, item.value, item.description)
)
)
if (originalConfig.value) {
originalConfig.value.default_user_quota_usd = systemConfig.value.default_user_quota_usd
originalConfig.value.rate_limit_per_minute = systemConfig.value.rate_limit_per_minute
originalConfig.value.enable_registration = systemConfig.value.enable_registration
originalConfig.value.auto_delete_expired_keys =
systemConfig.value.auto_delete_expired_keys
originalConfig.value.enable_format_conversion =
systemConfig.value.enable_format_conversion
}
success('基础配置已保存')
} catch (err) {
error('保存配置失败')
log.error('保存基础配置失败:', err)
} finally {
basicConfigLoading.value = false
}
}
async function saveLogConfig() {
logConfigLoading.value = true
try {
const configItems = [
{
key: 'request_record_level',
value: systemConfig.value.request_record_level,
description: '请求记录级别',
},
{
key: 'max_request_body_size',
value: systemConfig.value.max_request_body_size,
description: '最大请求体记录大小(字节)',
},
{
key: 'max_response_body_size',
value: systemConfig.value.max_response_body_size,
description: '最大响应体记录大小(字节)',
},
{
key: 'sensitive_headers',
value: systemConfig.value.sensitive_headers,
description: '敏感请求头列表',
},
]
await Promise.all(
configItems.map((item) =>
adminApi.updateSystemConfig(item.key, item.value, item.description)
)
)
if (originalConfig.value) {
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_response_body_size = systemConfig.value.max_response_body_size
originalConfig.value.sensitive_headers = [...systemConfig.value.sensitive_headers]
}
success('请求记录配置已保存')
} catch (err) {
error('保存配置失败')
log.error('保存请求记录配置失败:', err)
} finally {
logConfigLoading.value = false
}
}
async function saveCleanupConfig() {
cleanupConfigLoading.value = true
try {
const configItems = [
{
key: 'detail_log_retention_days',
value: systemConfig.value.detail_log_retention_days,
description: '详细记录保留天数',
},
{
key: 'compressed_log_retention_days',
value: systemConfig.value.compressed_log_retention_days,
description: '压缩记录保留天数',
},
{
key: 'header_retention_days',
value: systemConfig.value.header_retention_days,
description: '请求头保留天数',
},
{
key: 'log_retention_days',
value: systemConfig.value.log_retention_days,
description: '完整记录保留天数',
},
{
key: 'cleanup_batch_size',
value: systemConfig.value.cleanup_batch_size,
description: '每批次清理的记录数',
},
{
key: 'audit_log_retention_days',
value: systemConfig.value.audit_log_retention_days,
description: '审计日志保留天数',
},
]
await Promise.all(
configItems.map((item) =>
adminApi.updateSystemConfig(item.key, item.value, item.description)
)
)
if (originalConfig.value) {
originalConfig.value.detail_log_retention_days =
systemConfig.value.detail_log_retention_days
originalConfig.value.compressed_log_retention_days =
systemConfig.value.compressed_log_retention_days
originalConfig.value.header_retention_days = systemConfig.value.header_retention_days
originalConfig.value.log_retention_days = systemConfig.value.log_retention_days
originalConfig.value.cleanup_batch_size = systemConfig.value.cleanup_batch_size
originalConfig.value.audit_log_retention_days =
systemConfig.value.audit_log_retention_days
}
success('请求记录清理配置已保存')
} catch (err) {
error('保存配置失败')
log.error('保存请求记录清理配置失败:', err)
} finally {
cleanupConfigLoading.value = false
}
}
async function handleAutoCleanupToggle(enabled: boolean) {
const previousValue = systemConfig.value.enable_auto_cleanup
systemConfig.value.enable_auto_cleanup = enabled
try {
await adminApi.updateSystemConfig(
'enable_auto_cleanup',
enabled,
'是否启用自动清理任务'
)
success(enabled ? '已启用自动清理' : '已禁用自动清理')
} catch (err) {
error('保存配置失败')
log.error('保存自动清理配置失败:', err)
systemConfig.value.enable_auto_cleanup = previousValue
}
}
return {
systemConfig,
originalConfig,
systemVersion,
// loading 状态
siteInfoLoading,
proxyConfigLoading,
basicConfigLoading,
logConfigLoading,
cleanupConfigLoading,
// 变动检测
hasSiteInfoChanges,
hasProxyConfigChanges,
hasBasicConfigChanges,
hasLogConfigChanges,
hasCleanupConfigChanges,
// 计算属性
maxRequestBodySizeKB,
maxResponseBodySizeKB,
sensitiveHeadersStr,
// 加载函数
loadSystemConfig,
loadSystemVersion,
// 保存函数
saveSiteInfo,
saveProxyConfig,
saveBasicConfig,
saveLogConfig,
saveCleanupConfig,
handleAutoCleanupToggle,
}
}

View File

@@ -0,0 +1,150 @@
"""
Chat Error Utils - Chat Handler 错误处理工具函数
从 chat_handler_base.py 提取的模块级工具函数,用于错误响应的构建和转换。
"""
from __future__ import annotations
import json
from typing import Any
from src.api.handlers.base.utils import get_format_converter_registry
from src.core.exceptions import ThinkingSignatureException, UpstreamClientException
from src.core.logger import logger
from src.models.database import ProviderAPIKey
from src.services.cache.aware_scheduler import ProviderCandidate
from src.services.provider.transport import get_vertex_ai_effective_format
def _get_error_status_code(e: Exception, default: int = 400) -> int:
"""从异常中提取 HTTP 状态码"""
code = getattr(e, "status_code", None)
return code if isinstance(code, int) and code > 0 else default
def _resolve_vertex_ai_format(
key: ProviderAPIKey,
auth_info: Any,
model: str,
provider_api_format: str,
client_api_format: str,
candidate: ProviderCandidate | None,
) -> tuple[str, bool]:
"""
解析 Vertex AI 动态格式并计算 needs_conversion
当 auth_type=vertex_ai 时,同一个 GCP 项目可以访问 Gemini 和 Claude
但它们的请求/响应格式不同,需要根据模型名动态选择。
用户可通过 auth_config.model_format_mapping 配置自定义映射。
Args:
key: Provider API Key
auth_info: 认证信息(包含 decrypted_auth_config
model: 模型名
provider_api_format: 当前 provider API 格式
client_api_format: 客户端 API 格式
candidate: Provider 候选(用于获取原始 needs_conversion
Returns:
(effective_provider_format, needs_conversion) 元组
"""
key_auth_type = getattr(key, "auth_type", "api_key")
if key_auth_type == "vertex_ai":
vertex_auth_config = auth_info.decrypted_auth_config if auth_info else None
effective_format = get_vertex_ai_effective_format(model, vertex_auth_config)
if effective_format.upper() != provider_api_format.upper():
logger.debug(
f"Vertex AI 动态格式切换: {provider_api_format} -> {effective_format} "
f"(model={model})"
)
provider_api_format = effective_format
# Vertex AI 模式下,根据动态格式与客户端格式比较确定是否需要转换
needs_conversion = provider_api_format.upper() != client_api_format.upper()
else:
# 非 Vertex AI使用 candidate 的 needs_conversion
needs_conversion = (
bool(getattr(candidate, "needs_conversion", False)) if candidate else False
)
return provider_api_format, needs_conversion
def _convert_error_response_best_effort(
error_response: dict[str, Any],
source_format: str,
target_format: str,
) -> dict[str, Any]:
"""
将上游错误响应 best-effort 转换为客户端格式。
说明:错误转换走 Canonical registry。转换失败时构造安全的通用错误响应
避免泄露上游原始错误详情。
"""
try:
registry = get_format_converter_registry()
return registry.convert_error_response(error_response, source_format, target_format)
except Exception as e:
logger.debug(f"错误响应转换失败 ({source_format} -> {target_format}): {e}")
# 转换失败时构造安全的通用错误,避免泄露上游详情
return _build_client_error_response_best_effort("upstream error", target_format)
def _build_client_error_response_best_effort(
message: str,
target_format: str,
) -> dict[str, Any]:
"""
当无法解析上游错误 body 时构造一个目标格式的错误响应best-effort
"""
try:
from src.core.api_format.conversion.internal import ErrorType, InternalError
registry = get_format_converter_registry()
normalizer = registry.get_normalizer(target_format)
if normalizer and normalizer.capabilities.supports_error_conversion:
return normalizer.error_from_internal(
InternalError(type=ErrorType.INVALID_REQUEST, message=message, retryable=False)
)
except Exception as e:
logger.debug(f"构建客户端错误响应失败 (target={target_format}): {e}")
return {"error": {"type": "upstream_client_error", "message": message}}
def _build_error_json_payload(
e: ThinkingSignatureException | UpstreamClientException,
client_format: str,
provider_format: str,
needs_conversion: bool = True,
) -> dict[str, Any]:
"""
构建错误 JSON 响应 payload公共逻辑
从异常中提取上游错误信息,尝试转换为客户端格式。
Args:
e: ThinkingSignatureException 或 UpstreamClientException
client_format: 客户端 API 格式
provider_format: Provider API 格式
needs_conversion: 是否需要格式转换
Returns:
格式化的错误响应字典
"""
raw = getattr(e, "upstream_error", None)
message = getattr(e, "message", str(e))
if isinstance(raw, str) and raw:
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, dict):
if needs_conversion:
return _convert_error_response_best_effort(parsed, provider_format, client_format)
return parsed
return _build_client_error_response_best_effort(message, client_format)

View File

@@ -38,19 +38,19 @@ from src.api.handlers.base.base_handler import (
ClientDisconnectedException, ClientDisconnectedException,
wait_for_with_disconnect_detection, wait_for_with_disconnect_detection,
) )
from src.api.handlers.base.chat_error_utils import (
_build_error_json_payload,
_get_error_status_code,
_resolve_vertex_ai_format,
)
from src.api.handlers.base.parsers import get_parser_for_format from src.api.handlers.base.parsers import get_parser_for_format
from src.api.handlers.base.request_builder import PassthroughRequestBuilder, get_provider_auth from src.api.handlers.base.request_builder import PassthroughRequestBuilder, get_provider_auth
from src.api.handlers.base.response_parser import ResponseParser from src.api.handlers.base.response_parser import ResponseParser
from src.api.handlers.base.stream_context import ( from src.api.handlers.base.stream_context import (
StreamContext, StreamContext,
extract_proxy_timing,
is_format_converted,
) )
from src.api.handlers.base.stream_processor import StreamProcessor from src.api.handlers.base.stream_processor import StreamProcessor
from src.api.handlers.base.stream_telemetry import StreamTelemetryRecorder from src.api.handlers.base.stream_telemetry import StreamTelemetryRecorder
from src.api.handlers.base.upstream_stream_bridge import (
aggregate_upstream_stream_to_internal_response,
)
from src.api.handlers.base.utils import ( from src.api.handlers.base.utils import (
build_sse_headers, build_sse_headers,
filter_proxy_response_headers, filter_proxy_response_headers,
@@ -60,12 +60,9 @@ from src.config.settings import config
from src.core.api_format.conversion.stream_bridge import ( from src.core.api_format.conversion.stream_bridge import (
iter_internal_response_as_stream_events, iter_internal_response_as_stream_events,
) )
from src.core.error_utils import extract_client_error_message
from src.core.exceptions import ( from src.core.exceptions import (
EmbeddedErrorException, EmbeddedErrorException,
ProviderAuthException,
ProviderNotAvailableException, ProviderNotAvailableException,
ProviderRateLimitException,
ProviderTimeoutException, ProviderTimeoutException,
ThinkingSignatureException, ThinkingSignatureException,
UpstreamClientException, UpstreamClientException,
@@ -87,145 +84,10 @@ from src.services.provider.stream_policy import (
) )
from src.services.provider.transport import ( from src.services.provider.transport import (
build_provider_url, build_provider_url,
get_vertex_ai_effective_format,
redact_url_for_log,
) )
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
def _get_error_status_code(e: Exception, default: int = 400) -> int:
"""从异常中提取 HTTP 状态码"""
code = getattr(e, "status_code", None)
return code if isinstance(code, int) and code > 0 else default
def _resolve_vertex_ai_format(
key: ProviderAPIKey,
auth_info: Any,
model: str,
provider_api_format: str,
client_api_format: str,
candidate: ProviderCandidate | None,
) -> tuple[str, bool]:
"""
解析 Vertex AI 动态格式并计算 needs_conversion
当 auth_type=vertex_ai 时,同一个 GCP 项目可以访问 Gemini 和 Claude
但它们的请求/响应格式不同,需要根据模型名动态选择。
用户可通过 auth_config.model_format_mapping 配置自定义映射。
Args:
key: Provider API Key
auth_info: 认证信息(包含 decrypted_auth_config
model: 模型名
provider_api_format: 当前 provider API 格式
client_api_format: 客户端 API 格式
candidate: Provider 候选(用于获取原始 needs_conversion
Returns:
(effective_provider_format, needs_conversion) 元组
"""
key_auth_type = getattr(key, "auth_type", "api_key")
if key_auth_type == "vertex_ai":
vertex_auth_config = auth_info.decrypted_auth_config if auth_info else None
effective_format = get_vertex_ai_effective_format(model, vertex_auth_config)
if effective_format.upper() != provider_api_format.upper():
logger.debug(
f"Vertex AI 动态格式切换: {provider_api_format} -> {effective_format} "
f"(model={model})"
)
provider_api_format = effective_format
# Vertex AI 模式下,根据动态格式与客户端格式比较确定是否需要转换
needs_conversion = provider_api_format.upper() != client_api_format.upper()
else:
# 非 Vertex AI使用 candidate 的 needs_conversion
needs_conversion = (
bool(getattr(candidate, "needs_conversion", False)) if candidate else False
)
return provider_api_format, needs_conversion
def _convert_error_response_best_effort(
error_response: dict[str, Any],
source_format: str,
target_format: str,
) -> dict[str, Any]:
"""
将上游错误响应 best-effort 转换为客户端格式。
说明:错误转换走 Canonical registry。转换失败时构造安全的通用错误响应
避免泄露上游原始错误详情。
"""
try:
registry = get_format_converter_registry()
return registry.convert_error_response(error_response, source_format, target_format)
except Exception as e:
logger.debug(f"错误响应转换失败 ({source_format} -> {target_format}): {e}")
# 转换失败时构造安全的通用错误,避免泄露上游详情
return _build_client_error_response_best_effort("upstream error", target_format)
def _build_client_error_response_best_effort(
message: str,
target_format: str,
) -> dict[str, Any]:
"""
当无法解析上游错误 body 时构造一个目标格式的错误响应best-effort
"""
try:
from src.core.api_format.conversion.internal import ErrorType, InternalError
registry = get_format_converter_registry()
normalizer = registry.get_normalizer(target_format)
if normalizer and normalizer.capabilities.supports_error_conversion:
return normalizer.error_from_internal(
InternalError(type=ErrorType.INVALID_REQUEST, message=message, retryable=False)
)
except Exception as e:
logger.debug(f"构建客户端错误响应失败 (target={target_format}): {e}")
return {"error": {"type": "upstream_client_error", "message": message}}
def _build_error_json_payload(
e: ThinkingSignatureException | UpstreamClientException,
client_format: str,
provider_format: str,
needs_conversion: bool = True,
) -> dict[str, Any]:
"""
构建错误 JSON 响应 payload公共逻辑
从异常中提取上游错误信息,尝试转换为客户端格式。
Args:
e: ThinkingSignatureException 或 UpstreamClientException
client_format: 客户端 API 格式
provider_format: Provider API 格式
needs_conversion: 是否需要格式转换
Returns:
格式化的错误响应字典
"""
raw = getattr(e, "upstream_error", None)
message = getattr(e, "message", str(e))
if isinstance(raw, str) and raw:
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, dict):
if needs_conversion:
return _convert_error_response_best_effort(parsed, provider_format, client_format)
return parsed
return _build_client_error_response_best_effort(message, client_format)
@dataclass @dataclass
class ProviderRequestResult: class ProviderRequestResult:
"""_prepare_provider_request() 的返回结果,封装请求构建阶段的所有产出。""" """_prepare_provider_request() 的返回结果,封装请求构建阶段的所有产出。"""
@@ -756,7 +618,11 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
"签名错误" if isinstance(e, ThinkingSignatureException) else "上游客户端错误" "签名错误" if isinstance(e, ThinkingSignatureException) else "上游客户端错误"
) )
self._log_request_error(f"流式请求失败({error_type}", e) self._log_request_error(f"流式请求失败({error_type}", e)
await self._record_stream_failure(ctx, e, original_headers, original_request_body) from src.api.handlers.base.chat_sync_executor import ChatSyncExecutor
await ChatSyncExecutor(self)._record_stream_failure(
ctx, e, original_headers, original_request_body
)
client_format = (ctx.client_api_format or "").upper() client_format = (ctx.client_api_format or "").upper()
provider_format = (ctx.provider_api_format or client_format).upper() provider_format = (ctx.provider_api_format or client_format).upper()
payload = _build_error_json_payload( payload = _build_error_json_payload(
@@ -769,7 +635,11 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
except Exception as e: except Exception as e:
self._log_request_error("流式请求失败", e) self._log_request_error("流式请求失败", e)
await self._record_stream_failure(ctx, e, original_headers, original_request_body) from src.api.handlers.base.chat_sync_executor import ChatSyncExecutor
await ChatSyncExecutor(self)._record_stream_failure(
ctx, e, original_headers, original_request_body
)
raise raise
async def _prepare_provider_request( async def _prepare_provider_request(
@@ -1351,7 +1221,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
response_ctx = None response_ctx = None
continue continue
error_text = await self._extract_error_text(e) from src.api.handlers.base.chat_sync_executor import ChatSyncExecutor
error_text = await ChatSyncExecutor(self)._extract_error_text(e)
logger.error( logger.error(
f"Provider 返回错误: {e.response.status_code}\n Response: {error_text}" f"Provider 返回错误: {e.response.status_code}\n Response: {error_text}"
) )
@@ -1384,57 +1256,6 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
start_time=self.start_time, start_time=self.start_time,
) )
async def _record_stream_failure(
self,
ctx: StreamContext,
error: Exception,
original_headers: dict[str, str],
original_request_body: dict[str, Any],
) -> None:
"""记录流式请求失败"""
response_time_ms = self.elapsed_ms()
status_code = 503
if isinstance(error, ThinkingSignatureException):
status_code = 400
elif isinstance(error, UpstreamClientException):
status_code = _get_error_status_code(error)
elif isinstance(error, ProviderAuthException):
status_code = 503
elif isinstance(error, ProviderRateLimitException):
status_code = 429
elif isinstance(error, ProviderTimeoutException):
status_code = 504
actual_request_body = ctx.provider_request_body or original_request_body
# 失败时返回给客户端的是 JSON 错误响应
client_response_headers = {"content-type": "application/json"}
stream_fail_metadata: dict[str, Any] | None = None
if ctx.proxy_info:
stream_fail_metadata = {"proxy": ctx.proxy_info}
await self.telemetry.record_failure(
provider=ctx.provider_name or "unknown",
model=ctx.model,
response_time_ms=response_time_ms,
status_code=status_code,
error_message=extract_client_error_message(error),
request_headers=original_headers,
request_body=actual_request_body,
is_stream=True,
api_format=ctx.api_format,
provider_request_headers=ctx.provider_request_headers,
response_headers=ctx.response_headers,
client_response_headers=client_response_headers,
# 格式转换追踪
endpoint_api_format=ctx.provider_api_format or None,
has_format_conversion=ctx.has_format_conversion,
target_model=ctx.mapped_model,
request_metadata=stream_fail_metadata,
)
# ==================== 非流式处理 ==================== # ==================== 非流式处理 ====================
async def process_sync( async def process_sync(
@@ -1446,561 +1267,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
query_params: dict[str, str] | None = None, query_params: dict[str, str] | None = None,
) -> JSONResponse: ) -> JSONResponse:
"""处理非流式响应""" """处理非流式响应"""
logger.debug(f"开始非流式响应处理 ({self.FORMAT_ID})") from src.api.handlers.base.chat_sync_executor import ChatSyncExecutor
# 转换请求格式 executor = ChatSyncExecutor(self)
converted_request = await self._convert_request(request) return await executor.execute(
model = getattr(converted_request, "model", original_request_body.get("model", "unknown")) request, http_request, original_headers, original_request_body, query_params
api_format = self.allowed_api_formats[0]
# 提前创建 pending 记录,让前端可以立即看到"处理中"
self._create_pending_usage(
model=model,
is_stream=False,
request_type="chat",
api_format=self.FORMAT_ID,
request_headers=original_headers,
request_body=original_request_body,
) )
# 可变请求体容器:允许 TaskService 在遇到 Thinking 签名错误时整流请求体后重试
# 结构: {"body": 实际请求体, "_rectified": 是否已整流, "_rectified_this_turn": 本轮是否整流}
request_body_ref: dict[str, Any] = {"body": original_request_body}
# 用于跟踪的变量
provider_name: str | None = None
response_json: dict[str, Any] | None = None
status_code = 200
response_headers: dict[str, str] = {}
provider_request_headers: dict[str, str] = {}
provider_request_body: dict[str, Any] | None = None
provider_api_format_for_error: str | None = None
client_api_format_for_error: str | None = None
needs_conversion_for_error: bool = False # 用于构建错误 payload含 envelope rewrite
provider_id: str | None = None # Provider ID用于失败记录
endpoint_id: str | None = None # Endpoint ID用于失败记录
key_id: str | None = None # Key ID用于失败记录
mapped_model_result: str | None = None # 映射后的目标模型名(用于 Usage 记录)
sync_proxy_info: dict[str, Any] | None = None # 代理信息(用于 Usage 记录)
async def sync_request_func(
provider: Provider,
endpoint: ProviderEndpoint,
key: ProviderAPIKey,
candidate: ProviderCandidate,
) -> dict[str, Any]:
nonlocal provider_name, response_json, status_code, response_headers
nonlocal provider_request_headers, provider_request_body, mapped_model_result
nonlocal provider_api_format_for_error, client_api_format_for_error, needs_conversion_for_error
nonlocal sync_proxy_info
provider_name = str(provider.name)
provider_api_format = str(endpoint.api_format or api_format)
client_api_format = (
api_format.value if hasattr(api_format, "value") else str(api_format)
)
# 构建 Provider 请求模型映射、格式转换、envelope 包装)
prep = await self._prepare_provider_request(
model=model,
provider=provider,
endpoint=endpoint,
key=key,
original_request_body=request_body_ref["body"],
client_api_format=client_api_format,
provider_api_format=provider_api_format,
candidate=candidate,
client_is_stream=False,
)
provider_api_format = prep.provider_api_format
needs_conversion = prep.needs_conversion
provider_api_format_for_error = provider_api_format
client_api_format_for_error = client_api_format
needs_conversion_for_error = needs_conversion
mapped_model = prep.mapped_model
if mapped_model:
mapped_model_result = mapped_model
request_body = prep.request_body
url_model = prep.url_model
envelope = prep.envelope
upstream_is_stream = prep.upstream_is_stream
auth_info = prep.auth_info
# 构建请求(上游始终使用 header 认证,不跟随客户端的 query 方式)
provider_payload, provider_hdrs = self._request_builder.build(
request_body,
original_headers,
endpoint,
key,
is_stream=upstream_is_stream,
extra_headers=prep.extra_headers if prep.extra_headers else None,
pre_computed_auth=auth_info.as_tuple() if auth_info else None,
)
if upstream_is_stream:
from src.core.api_format.headers import set_accept_if_absent
set_accept_if_absent(provider_hdrs)
provider_request_headers = provider_hdrs
provider_request_body = provider_payload
url = build_provider_url(
endpoint,
query_params=query_params,
path_params={"model": url_model},
is_stream=upstream_is_stream, # sync handler may still force upstream streaming
key=key,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
)
# 非流式:必须在 build_provider_url 调用后立即缓存(避免 contextvar 被后续调用覆盖)
selected_base_url_cached = envelope.capture_selected_base_url() if envelope else None
# 解析有效代理Key 级别优先于 Provider 级别)
from src.services.proxy_node.resolver import (
get_proxy_label,
resolve_effective_proxy,
resolve_proxy_info,
)
_effective_proxy = resolve_effective_proxy(provider.proxy, getattr(key, "proxy", None))
sync_proxy_info = resolve_proxy_info(_effective_proxy)
_proxy_label = get_proxy_label(sync_proxy_info)
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
logger.info(
f" [{self.request_id}] 发送{'上游流式(聚合)' if upstream_is_stream else '非流式'}请求: "
f"Provider={provider.name}, 模型={model} -> {mapped_model or '无映射'}, "
f"代理={_proxy_label}"
)
logger.debug(f" [{self.request_id}] 请求URL: {redact_url_for_log(url)}")
# 获取复用的 HTTP 客户端支持代理配置Key 级别优先于 Provider 级别)
# 注意:使用 get_proxy_client 复用连接池,不再每次创建新客户端
from src.clients.http_client import HTTPClientPool
from src.services.proxy_node.resolver import (
build_post_kwargs,
build_stream_kwargs,
resolve_delegate_config,
)
# 非流式请求使用 http_request_timeout 作为整体超时
# 优先使用 Provider 配置,否则使用全局配置
request_timeout = provider.request_timeout or config.http_request_timeout
delegate_cfg = resolve_delegate_config(_effective_proxy)
http_client = await HTTPClientPool.get_upstream_client(
delegate_cfg, proxy_config=_effective_proxy
)
# 注意:不使用 async with因为复用的客户端不应该被关闭
# 超时通过 timeout 参数控制
resp: httpx.Response | None = None
if not upstream_is_stream:
try:
_pkw = build_post_kwargs(
delegate_cfg,
url=url,
headers=provider_hdrs,
payload=provider_payload,
timeout=request_timeout,
)
resp = await http_client.post(**_pkw)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope:
envelope.on_connection_error(base_url=selected_base_url_cached, exc=e)
if selected_base_url_cached:
logger.warning(
f"[{envelope.name}] Connection error: {selected_base_url_cached} ({e})"
)
raise
else:
# Forced upstream streaming: aggregate SSE to a sync JSON response.
provider_parser = (
get_parser_for_format(provider_api_format) if provider_api_format else None
)
try:
_stream_args = build_stream_kwargs(
delegate_cfg,
url=url,
headers=provider_hdrs,
payload=provider_payload,
timeout=request_timeout,
)
async with http_client.stream(**_stream_args) as stream_resp:
resp = stream_resp
status_code = stream_resp.status_code
response_headers = dict(stream_resp.headers)
extract_proxy_timing(sync_proxy_info, response_headers)
if envelope:
envelope.on_http_status(
base_url=selected_base_url_cached,
status_code=status_code,
)
stream_resp.raise_for_status()
byte_iter = stream_resp.aiter_bytes()
if provider_type == "kiro" and envelope and envelope.force_stream_rewrite():
from src.services.provider.adapters.kiro.eventstream_rewriter import (
apply_kiro_stream_rewrite,
)
byte_iter = apply_kiro_stream_rewrite(byte_iter, model=str(model or ""))
internal_resp = await aggregate_upstream_stream_to_internal_response(
byte_iter,
provider_api_format=provider_api_format,
provider_name=str(provider.name),
model=str(model or ""),
request_id=str(self.request_id or ""),
envelope=envelope,
provider_parser=provider_parser,
)
registry = get_format_converter_registry()
tgt_norm = (
registry.get_normalizer(client_api_format)
if client_api_format
else None
)
if tgt_norm is None:
raise RuntimeError(f"未注册 Normalizer: {client_api_format}")
response_json = tgt_norm.response_from_internal(
internal_resp,
requested_model=model,
)
response_json = response_json if isinstance(response_json, dict) else {}
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope:
envelope.on_connection_error(base_url=selected_base_url_cached, exc=e)
if selected_base_url_cached:
logger.warning(
f"[{envelope.name}] Connection error: {selected_base_url_cached} ({e})"
)
raise
status_code = resp.status_code
response_headers = dict(resp.headers)
extract_proxy_timing(sync_proxy_info, response_headers)
if envelope:
envelope.on_http_status(base_url=selected_base_url_cached, status_code=status_code)
# Forced upstream streaming already built response_json via aggregator.
if upstream_is_stream:
return response_json if isinstance(response_json, dict) else {}
# 统一使用 HTTPStatusError让 TaskService/error_classifier 负责分类(客户端错误/兼容性错误/限流等)
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
error_body = ""
try:
error_body = resp.text[:4000] if resp.text else ""
except Exception:
error_body = ""
# 供 ErrorClassifier 优先读取
e.upstream_response = error_body # type: ignore[attr-defined]
raise
# 安全解析 JSON 响应,处理可能的编码错误
try:
response_json = resp.json()
except (UnicodeDecodeError, json.JSONDecodeError) as e:
# 获取原始响应内容用于调试(存入 upstream_response
raw_content = ""
try:
raw_content = resp.text[:500] if resp.text else "(empty)"
except Exception:
try:
raw_content = repr(resp.content[:500]) if resp.content else "(empty)"
except Exception:
raw_content = "(unable to read)"
logger.error(f"[{self.request_id}] 无法解析响应 JSON: {e}, 原始内容: {raw_content}")
# 判断错误类型,生成友好的客户端错误消息(不暴露提供商信息)
if raw_content == "(empty)" or not raw_content.strip():
client_message = "上游服务返回了空响应"
elif raw_content.strip().startswith(("<", "<!doctype", "<!DOCTYPE")):
client_message = "上游服务返回了非预期的响应格式"
else:
client_message = "上游服务返回了无效的响应"
raise ProviderNotAvailableException(
client_message,
provider_name=str(provider.name),
upstream_status=resp.status_code,
upstream_response=raw_content,
)
if envelope:
response_json = envelope.unwrap_response(response_json)
envelope.postprocess_unwrapped_response(model=model, data=response_json)
# 检查响应体中的嵌套错误HTTP 200 但响应体包含错误)
if isinstance(response_json, dict):
parser = get_parser_for_format(provider_api_format)
if parser.is_error_response(response_json):
parsed = parser.parse_response(response_json, 200)
logger.warning(
f" [{self.request_id}] 非流式检测到嵌套错误: "
f"Provider={provider.name}, "
f"error_type={parsed.error_type}, "
f"embedded_status={parsed.embedded_status_code}, "
f"message={parsed.error_message}"
)
raise EmbeddedErrorException(
provider_name=str(provider.name),
error_code=parsed.embedded_status_code,
error_message=parsed.error_message,
error_status=parsed.error_type,
)
# 跨格式:响应转换回 client_format失败触发 failover
if needs_conversion and isinstance(response_json, dict):
registry = get_format_converter_registry()
response_json = registry.convert_response(
response_json,
provider_api_format,
client_api_format,
requested_model=model, # 使用用户请求的原始模型名
)
return response_json if isinstance(response_json, dict) else {}
try:
# 解析能力需求
capability_requirements = self._resolve_capability_requirements(
model_name=model,
request_headers=original_headers,
request_body=original_request_body,
)
preferred_key_ids = await self._resolve_preferred_key_ids(
model_name=model,
request_body=original_request_body,
)
# 统一入口:总是通过 TaskService
from src.services.task import TaskService
from src.services.task.context import TaskMode
exec_result = await TaskService(self.db, self.redis).execute(
task_type="chat",
task_mode=TaskMode.SYNC,
api_format=api_format,
model_name=model,
user_api_key=self.api_key,
request_func=sync_request_func,
request_id=self.request_id,
is_stream=False,
capability_requirements=capability_requirements or None,
preferred_key_ids=preferred_key_ids or None,
request_body_ref=request_body_ref,
)
result = exec_result.response
actual_provider_name = exec_result.provider_name or "unknown"
attempt_id = exec_result.request_candidate_id
provider_id = exec_result.provider_id
endpoint_id = exec_result.endpoint_id
key_id = exec_result.key_id
provider_name = actual_provider_name
response_time_ms = self.elapsed_ms()
# 确保 response_json 不为 None
if response_json is None:
response_json = {}
# 规范化响应
response_json = self._normalize_response(response_json)
# 提取 usage
usage_info = self._extract_usage(response_json)
input_tokens = usage_info.get("input_tokens", 0)
output_tokens = usage_info.get("output_tokens", 0)
cache_creation_tokens = usage_info.get("cache_creation_input_tokens", 0)
cached_tokens = usage_info.get("cache_read_input_tokens", 0)
actual_request_body = provider_request_body or original_request_body
# 非流式成功时,返回给客户端的是提供商响应头(透传)
# JSONResponse 会自动设置 content-type但我们记录实际返回的完整头
client_response_headers = filter_proxy_response_headers(response_headers)
client_response_headers["content-type"] = "application/json"
request_metadata = self._build_request_metadata() or {}
if sync_proxy_info:
request_metadata["proxy"] = sync_proxy_info
total_cost = await self.telemetry.record_success(
provider=provider_name,
model=model,
input_tokens=input_tokens,
output_tokens=output_tokens,
response_time_ms=response_time_ms,
status_code=status_code,
request_headers=original_headers,
request_body=actual_request_body,
response_headers=response_headers,
client_response_headers=client_response_headers,
response_body=response_json,
cache_creation_tokens=cache_creation_tokens,
cache_read_tokens=cached_tokens,
is_stream=False,
provider_request_headers=provider_request_headers,
api_format=api_format,
# 格式转换追踪
endpoint_api_format=provider_api_format_for_error or None,
has_format_conversion=is_format_converted(
provider_api_format_for_error, client_api_format_for_error
),
provider_id=provider_id,
provider_endpoint_id=endpoint_id,
provider_api_key_id=key_id,
# 模型映射信息
target_model=mapped_model_result,
request_metadata=request_metadata or None,
)
logger.debug(f"{self.FORMAT_ID} 非流式响应完成")
# 简洁的请求完成摘要
logger.info(
f"[OK] {self.request_id[:8]} | {model} | {provider_name or 'unknown'} | {response_time_ms}ms | "
f"in:{input_tokens or 0} out:{output_tokens or 0}"
)
# 透传提供商的响应头
return JSONResponse(
status_code=status_code,
content=response_json,
headers=client_response_headers,
)
except ThinkingSignatureException as e:
# Thinking 签名错误TaskService 层已处理整流重试但仍失败
# 记录实际发送给 Provider 的请求体,便于排查问题根因
response_time_ms = self.elapsed_ms()
actual_request_body = provider_request_body or original_request_body
request_metadata = self._build_request_metadata() or {}
if sync_proxy_info:
request_metadata["proxy"] = sync_proxy_info
await self.telemetry.record_failure(
provider=provider_name or "unknown",
model=model,
response_time_ms=response_time_ms,
status_code=e.status_code or 400,
request_headers=original_headers,
request_body=actual_request_body,
error_message=str(e),
is_stream=False,
request_metadata=request_metadata or None,
)
client_format = (client_api_format_for_error or "").upper()
provider_format = (provider_api_format_for_error or client_format).upper()
payload = _build_error_json_payload(
e, client_format, provider_format, needs_conversion=needs_conversion_for_error
)
return JSONResponse(
status_code=_get_error_status_code(e),
content=payload,
)
except UpstreamClientException as e:
response_time_ms = self.elapsed_ms()
actual_request_body = provider_request_body or original_request_body
request_metadata = self._build_request_metadata() or {}
if sync_proxy_info:
request_metadata["proxy"] = sync_proxy_info
await self.telemetry.record_failure(
provider=provider_name or "unknown",
model=model,
response_time_ms=response_time_ms,
status_code=_get_error_status_code(e),
request_headers=original_headers,
request_body=actual_request_body,
error_message=str(e),
is_stream=False,
api_format=api_format,
provider_request_headers=provider_request_headers,
response_headers=response_headers,
client_response_headers={"content-type": "application/json"},
# 格式转换追踪
endpoint_api_format=provider_api_format_for_error or None,
has_format_conversion=is_format_converted(
provider_api_format_for_error, client_api_format_for_error
),
target_model=mapped_model_result,
request_metadata=request_metadata,
)
client_format = (client_api_format_for_error or "").upper()
provider_format = (provider_api_format_for_error or client_format).upper()
payload = _build_error_json_payload(
e, client_format, provider_format, needs_conversion=needs_conversion_for_error
)
return JSONResponse(
status_code=_get_error_status_code(e),
content=payload,
)
except Exception as e:
response_time_ms = self.elapsed_ms()
status_code = 503
if isinstance(e, ProviderAuthException):
status_code = 503
elif isinstance(e, ProviderRateLimitException):
status_code = 429
elif isinstance(e, ProviderTimeoutException):
status_code = 504
actual_request_body = provider_request_body or original_request_body
# 尝试从异常中提取响应头
error_response_headers: dict[str, str] = {}
if isinstance(e, ProviderRateLimitException) and e.response_headers:
error_response_headers = e.response_headers
elif isinstance(e, httpx.HTTPStatusError) and hasattr(e, "response"):
error_response_headers = dict(e.response.headers)
request_metadata = self._build_request_metadata() or {}
if sync_proxy_info:
request_metadata["proxy"] = sync_proxy_info
await self.telemetry.record_failure(
provider=provider_name or "unknown",
model=model,
response_time_ms=response_time_ms,
status_code=status_code,
error_message=extract_client_error_message(e),
request_headers=original_headers,
request_body=actual_request_body,
is_stream=False,
api_format=api_format,
provider_request_headers=provider_request_headers,
response_headers=error_response_headers,
# 非流式失败返回给客户端的是 JSON 错误响应
client_response_headers={"content-type": "application/json"},
# 格式转换追踪
endpoint_api_format=provider_api_format_for_error or None,
has_format_conversion=is_format_converted(
provider_api_format_for_error, client_api_format_for_error
),
# 模型映射信息
target_model=mapped_model_result,
request_metadata=request_metadata,
)
raise
async def _extract_error_text(self, e: httpx.HTTPStatusError) -> str:
"""从 HTTP 错误中提取错误文本"""
try:
if hasattr(e.response, "is_stream_consumed") and not e.response.is_stream_consumed:
error_bytes = await e.response.aread()
return error_bytes.decode("utf-8", errors="replace")
else:
return e.response.text if hasattr(e.response, "_content") else "Unable to read"
except Exception as decode_error:
return f"Unable to read error: {decode_error}"

View File

@@ -0,0 +1,729 @@
"""
ChatSyncExecutor - 非流式请求执行器
从 ChatHandlerBase.process_sync() 提取的独立类,负责:
- 非流式请求的完整执行流程(请求构建、发送、响应解析)
- 通过 SyncRequestContext 管理可变状态(替代原来的 nonlocal 变量)
- 异常处理与 telemetry 记录
- 流式失败记录_record_stream_failure
- HTTP 错误文本提取_extract_error_text
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
import httpx
from fastapi.responses import JSONResponse
from src.api.handlers.base.chat_error_utils import (
_build_error_json_payload,
_get_error_status_code,
)
from src.api.handlers.base.parsers import get_parser_for_format
from src.api.handlers.base.stream_context import (
StreamContext,
extract_proxy_timing,
is_format_converted,
)
from src.api.handlers.base.utils import (
filter_proxy_response_headers,
get_format_converter_registry,
)
from src.core.error_utils import extract_client_error_message
from src.core.exceptions import (
EmbeddedErrorException,
ProviderAuthException,
ProviderNotAvailableException,
ProviderRateLimitException,
ProviderTimeoutException,
ThinkingSignatureException,
UpstreamClientException,
)
from src.core.logger import logger
if TYPE_CHECKING:
from fastapi import Request
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
from src.services.cache.aware_scheduler import ProviderCandidate
@dataclass
class SyncRequestContext:
"""同步请求的可变状态容器,替代原来的 nonlocal 变量"""
provider_name: str | None = None
response_json: dict[str, Any] | None = None
status_code: int = 200
response_headers: dict[str, str] = field(default_factory=dict)
provider_request_headers: dict[str, str] = field(default_factory=dict)
provider_request_body: dict[str, Any] | None = None
provider_api_format_for_error: str | None = None
client_api_format_for_error: str | None = None
needs_conversion_for_error: bool = False
provider_id: str | None = None
endpoint_id: str | None = None
key_id: str | None = None
mapped_model_result: str | None = None
sync_proxy_info: dict[str, Any] | None = None
class ChatSyncExecutor:
"""非流式请求执行器,从 ChatHandlerBase 提取"""
def __init__(self, handler: ChatHandlerBase) -> None:
self._handler = handler
self._ctx = SyncRequestContext()
async def execute(
self,
request: Any,
http_request: Request,
original_headers: dict[str, Any],
original_request_body: dict[str, Any],
query_params: dict[str, str] | None = None,
) -> JSONResponse:
"""处理非流式响应(原 process_sync 的完整逻辑)"""
handler = self._handler
logger.debug(f"开始非流式响应处理 ({handler.FORMAT_ID})")
# 转换请求格式
converted_request = await handler._convert_request(request)
model = getattr(converted_request, "model", original_request_body.get("model", "unknown"))
api_format = handler.allowed_api_formats[0]
# 提前创建 pending 记录,让前端可以立即看到"处理中"
handler._create_pending_usage(
model=model,
is_stream=False,
request_type="chat",
api_format=handler.FORMAT_ID,
request_headers=original_headers,
request_body=original_request_body,
)
# 可变请求体容器:允许 TaskService 在遇到 Thinking 签名错误时整流请求体后重试
# 结构: {"body": 实际请求体, "_rectified": 是否已整流, "_rectified_this_turn": 本轮是否整流}
request_body_ref: dict[str, Any] = {"body": original_request_body}
# 捕获的上下文变量
ctx = self._ctx
async def sync_request_func(
provider: Provider,
endpoint: ProviderEndpoint,
key: ProviderAPIKey,
candidate: ProviderCandidate,
) -> dict[str, Any]:
return await self._sync_request_func(
provider,
endpoint,
key,
candidate,
model=model,
api_format=api_format,
original_headers=original_headers,
request_body_ref=request_body_ref,
query_params=query_params,
)
try:
# 解析能力需求
capability_requirements = handler._resolve_capability_requirements(
model_name=model,
request_headers=original_headers,
request_body=original_request_body,
)
preferred_key_ids = await handler._resolve_preferred_key_ids(
model_name=model,
request_body=original_request_body,
)
# 统一入口:总是通过 TaskService
from src.services.task import TaskService
from src.services.task.context import TaskMode
exec_result = await TaskService(handler.db, handler.redis).execute(
task_type="chat",
task_mode=TaskMode.SYNC,
api_format=api_format,
model_name=model,
user_api_key=handler.api_key,
request_func=sync_request_func,
request_id=handler.request_id,
is_stream=False,
capability_requirements=capability_requirements or None,
preferred_key_ids=preferred_key_ids or None,
request_body_ref=request_body_ref,
)
actual_provider_name = exec_result.provider_name or "unknown"
ctx.provider_id = exec_result.provider_id
ctx.endpoint_id = exec_result.endpoint_id
ctx.key_id = exec_result.key_id
ctx.provider_name = actual_provider_name
response_time_ms = handler.elapsed_ms()
# 确保 response_json 不为 None
if ctx.response_json is None:
ctx.response_json = {}
# 规范化响应
ctx.response_json = handler._normalize_response(ctx.response_json)
# 提取 usage
usage_info = handler._extract_usage(ctx.response_json)
input_tokens = usage_info.get("input_tokens", 0)
output_tokens = usage_info.get("output_tokens", 0)
cache_creation_tokens = usage_info.get("cache_creation_input_tokens", 0)
cached_tokens = usage_info.get("cache_read_input_tokens", 0)
actual_request_body = ctx.provider_request_body or original_request_body
# 非流式成功时,返回给客户端的是提供商响应头(透传)
# JSONResponse 会自动设置 content-type但我们记录实际返回的完整头
client_response_headers = filter_proxy_response_headers(ctx.response_headers)
client_response_headers["content-type"] = "application/json"
request_metadata = handler._build_request_metadata() or {}
if ctx.sync_proxy_info:
request_metadata["proxy"] = ctx.sync_proxy_info
total_cost = await handler.telemetry.record_success( # noqa: F841
provider=ctx.provider_name,
model=model,
input_tokens=input_tokens,
output_tokens=output_tokens,
response_time_ms=response_time_ms,
status_code=ctx.status_code,
request_headers=original_headers,
request_body=actual_request_body,
response_headers=ctx.response_headers,
client_response_headers=client_response_headers,
response_body=ctx.response_json,
cache_creation_tokens=cache_creation_tokens,
cache_read_tokens=cached_tokens,
is_stream=False,
provider_request_headers=ctx.provider_request_headers,
api_format=api_format,
# 格式转换追踪
endpoint_api_format=ctx.provider_api_format_for_error or None,
has_format_conversion=is_format_converted(
ctx.provider_api_format_for_error, ctx.client_api_format_for_error
),
provider_id=ctx.provider_id,
provider_endpoint_id=ctx.endpoint_id,
provider_api_key_id=ctx.key_id,
# 模型映射信息
target_model=ctx.mapped_model_result,
request_metadata=request_metadata or None,
)
logger.debug(f"{handler.FORMAT_ID} 非流式响应完成")
# 简洁的请求完成摘要
logger.info(
f"[OK] {handler.request_id[:8]} | {model} | "
f"{ctx.provider_name or 'unknown'} | {response_time_ms}ms | "
f"in:{input_tokens or 0} out:{output_tokens or 0}"
)
# 透传提供商的响应头
return JSONResponse(
status_code=ctx.status_code,
content=ctx.response_json,
headers=client_response_headers,
)
except ThinkingSignatureException as e:
# Thinking 签名错误TaskService 层已处理整流重试但仍失败
# 记录实际发送给 Provider 的请求体,便于排查问题根因
response_time_ms = handler.elapsed_ms()
actual_request_body = ctx.provider_request_body or original_request_body
request_metadata = handler._build_request_metadata() or {}
if ctx.sync_proxy_info:
request_metadata["proxy"] = ctx.sync_proxy_info
await handler.telemetry.record_failure(
provider=ctx.provider_name or "unknown",
model=model,
response_time_ms=response_time_ms,
status_code=e.status_code or 400,
request_headers=original_headers,
request_body=actual_request_body,
error_message=str(e),
is_stream=False,
request_metadata=request_metadata or None,
)
client_format = (ctx.client_api_format_for_error or "").upper()
provider_format = (ctx.provider_api_format_for_error or client_format).upper()
payload = _build_error_json_payload(
e,
client_format,
provider_format,
needs_conversion=ctx.needs_conversion_for_error,
)
return JSONResponse(
status_code=_get_error_status_code(e),
content=payload,
)
except UpstreamClientException as e:
response_time_ms = handler.elapsed_ms()
actual_request_body = ctx.provider_request_body or original_request_body
request_metadata = handler._build_request_metadata() or {}
if ctx.sync_proxy_info:
request_metadata["proxy"] = ctx.sync_proxy_info
await handler.telemetry.record_failure(
provider=ctx.provider_name or "unknown",
model=model,
response_time_ms=response_time_ms,
status_code=_get_error_status_code(e),
request_headers=original_headers,
request_body=actual_request_body,
error_message=str(e),
is_stream=False,
api_format=api_format,
provider_request_headers=ctx.provider_request_headers,
response_headers=ctx.response_headers,
client_response_headers={"content-type": "application/json"},
# 格式转换追踪
endpoint_api_format=ctx.provider_api_format_for_error or None,
has_format_conversion=is_format_converted(
ctx.provider_api_format_for_error, ctx.client_api_format_for_error
),
target_model=ctx.mapped_model_result,
request_metadata=request_metadata,
)
client_format = (ctx.client_api_format_for_error or "").upper()
provider_format = (ctx.provider_api_format_for_error or client_format).upper()
payload = _build_error_json_payload(
e,
client_format,
provider_format,
needs_conversion=ctx.needs_conversion_for_error,
)
return JSONResponse(
status_code=_get_error_status_code(e),
content=payload,
)
except Exception as e:
response_time_ms = handler.elapsed_ms()
status_code = 503
if isinstance(e, ProviderAuthException):
status_code = 503
elif isinstance(e, ProviderRateLimitException):
status_code = 429
elif isinstance(e, ProviderTimeoutException):
status_code = 504
actual_request_body = ctx.provider_request_body or original_request_body
# 尝试从异常中提取响应头
error_response_headers: dict[str, str] = {}
if isinstance(e, ProviderRateLimitException) and e.response_headers:
error_response_headers = e.response_headers
elif isinstance(e, httpx.HTTPStatusError) and hasattr(e, "response"):
error_response_headers = dict(e.response.headers)
request_metadata = handler._build_request_metadata() or {}
if ctx.sync_proxy_info:
request_metadata["proxy"] = ctx.sync_proxy_info
await handler.telemetry.record_failure(
provider=ctx.provider_name or "unknown",
model=model,
response_time_ms=response_time_ms,
status_code=status_code,
error_message=extract_client_error_message(e),
request_headers=original_headers,
request_body=actual_request_body,
is_stream=False,
api_format=api_format,
provider_request_headers=ctx.provider_request_headers,
response_headers=error_response_headers,
# 非流式失败返回给客户端的是 JSON 错误响应
client_response_headers={"content-type": "application/json"},
# 格式转换追踪
endpoint_api_format=ctx.provider_api_format_for_error or None,
has_format_conversion=is_format_converted(
ctx.provider_api_format_for_error, ctx.client_api_format_for_error
),
# 模型映射信息
target_model=ctx.mapped_model_result,
request_metadata=request_metadata,
)
raise
async def _sync_request_func(
self,
provider: Provider,
endpoint: ProviderEndpoint,
key: ProviderAPIKey,
candidate: ProviderCandidate,
*,
model: str,
api_format: Any,
original_headers: dict[str, Any],
request_body_ref: dict[str, Any],
query_params: dict[str, str] | None = None,
) -> dict[str, Any]:
"""单次同步请求(原 sync_request_func 内嵌函数)"""
handler = self._handler
ctx = self._ctx
ctx.provider_name = str(provider.name)
provider_api_format = str(endpoint.api_format or api_format)
client_api_format = api_format.value if hasattr(api_format, "value") else str(api_format)
# 构建 Provider 请求模型映射、格式转换、envelope 包装)
prep = await handler._prepare_provider_request(
model=model,
provider=provider,
endpoint=endpoint,
key=key,
original_request_body=request_body_ref["body"],
client_api_format=client_api_format,
provider_api_format=provider_api_format,
candidate=candidate,
client_is_stream=False,
)
provider_api_format = prep.provider_api_format
needs_conversion = prep.needs_conversion
ctx.provider_api_format_for_error = provider_api_format
ctx.client_api_format_for_error = client_api_format
ctx.needs_conversion_for_error = needs_conversion
mapped_model = prep.mapped_model
if mapped_model:
ctx.mapped_model_result = mapped_model
request_body = prep.request_body
url_model = prep.url_model
envelope = prep.envelope
upstream_is_stream = prep.upstream_is_stream
auth_info = prep.auth_info
# 构建请求(上游始终使用 header 认证,不跟随客户端的 query 方式)
provider_payload, provider_hdrs = handler._request_builder.build(
request_body,
original_headers,
endpoint,
key,
is_stream=upstream_is_stream,
extra_headers=prep.extra_headers if prep.extra_headers else None,
pre_computed_auth=auth_info.as_tuple() if auth_info else None,
)
if upstream_is_stream:
from src.core.api_format.headers import set_accept_if_absent
set_accept_if_absent(provider_hdrs)
ctx.provider_request_headers = provider_hdrs
ctx.provider_request_body = provider_payload
from src.services.provider.transport import (
build_provider_url,
redact_url_for_log,
)
url = build_provider_url(
endpoint,
query_params=query_params,
path_params={"model": url_model},
is_stream=upstream_is_stream, # sync handler may still force upstream streaming
key=key,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
)
# 非流式:必须在 build_provider_url 调用后立即缓存(避免 contextvar 被后续调用覆盖)
selected_base_url_cached = envelope.capture_selected_base_url() if envelope else None
# 解析有效代理Key 级别优先于 Provider 级别)
from src.services.proxy_node.resolver import (
get_proxy_label,
resolve_effective_proxy,
resolve_proxy_info,
)
_effective_proxy = resolve_effective_proxy(provider.proxy, getattr(key, "proxy", None))
ctx.sync_proxy_info = resolve_proxy_info(_effective_proxy)
_proxy_label = get_proxy_label(ctx.sync_proxy_info)
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
logger.info(
f" [{handler.request_id}] "
f"发送{'上游流式(聚合)' if upstream_is_stream else '非流式'}请求: "
f"Provider={provider.name}, 模型={model} -> {mapped_model or '无映射'}, "
f"代理={_proxy_label}"
)
logger.debug(f" [{handler.request_id}] 请求URL: {redact_url_for_log(url)}")
# 获取复用的 HTTP 客户端支持代理配置Key 级别优先于 Provider 级别)
# 注意:使用 get_proxy_client 复用连接池,不再每次创建新客户端
from src.clients.http_client import HTTPClientPool
from src.config.settings import config
from src.services.proxy_node.resolver import (
build_post_kwargs,
build_stream_kwargs,
resolve_delegate_config,
)
# 非流式请求使用 http_request_timeout 作为整体超时
# 优先使用 Provider 配置,否则使用全局配置
request_timeout = provider.request_timeout or config.http_request_timeout
delegate_cfg = resolve_delegate_config(_effective_proxy)
http_client = await HTTPClientPool.get_upstream_client(
delegate_cfg, proxy_config=_effective_proxy
)
# 注意:不使用 async with因为复用的客户端不应该被关闭
# 超时通过 timeout 参数控制
resp: httpx.Response | None = None
if not upstream_is_stream:
try:
_pkw = build_post_kwargs(
delegate_cfg,
url=url,
headers=provider_hdrs,
payload=provider_payload,
timeout=request_timeout,
)
resp = await http_client.post(**_pkw)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope:
envelope.on_connection_error(base_url=selected_base_url_cached, exc=e)
if selected_base_url_cached:
logger.warning(
f"[{envelope.name}] Connection error: "
f"{selected_base_url_cached} ({e})"
)
raise
else:
# Forced upstream streaming: aggregate SSE to a sync JSON response.
provider_parser = (
get_parser_for_format(provider_api_format) if provider_api_format else None
)
try:
_stream_args = build_stream_kwargs(
delegate_cfg,
url=url,
headers=provider_hdrs,
payload=provider_payload,
timeout=request_timeout,
)
async with http_client.stream(**_stream_args) as stream_resp:
resp = stream_resp
ctx.status_code = stream_resp.status_code
ctx.response_headers = dict(stream_resp.headers)
extract_proxy_timing(ctx.sync_proxy_info, ctx.response_headers)
if envelope:
envelope.on_http_status(
base_url=selected_base_url_cached,
status_code=ctx.status_code,
)
stream_resp.raise_for_status()
byte_iter = stream_resp.aiter_bytes()
if provider_type == "kiro" and envelope and envelope.force_stream_rewrite():
from src.services.provider.adapters.kiro.eventstream_rewriter import (
apply_kiro_stream_rewrite,
)
byte_iter = apply_kiro_stream_rewrite(byte_iter, model=str(model or ""))
from src.api.handlers.base.upstream_stream_bridge import (
aggregate_upstream_stream_to_internal_response,
)
internal_resp = await aggregate_upstream_stream_to_internal_response(
byte_iter,
provider_api_format=provider_api_format,
provider_name=str(provider.name),
model=str(model or ""),
request_id=str(handler.request_id or ""),
envelope=envelope,
provider_parser=provider_parser,
)
registry = get_format_converter_registry()
tgt_norm = (
registry.get_normalizer(client_api_format) if client_api_format else None
)
if tgt_norm is None:
raise RuntimeError(f"未注册 Normalizer: {client_api_format}")
ctx.response_json = tgt_norm.response_from_internal(
internal_resp,
requested_model=model,
)
ctx.response_json = (
ctx.response_json if isinstance(ctx.response_json, dict) else {}
)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope:
envelope.on_connection_error(base_url=selected_base_url_cached, exc=e)
if selected_base_url_cached:
logger.warning(
f"[{envelope.name}] Connection error: "
f"{selected_base_url_cached} ({e})"
)
raise
ctx.status_code = resp.status_code
ctx.response_headers = dict(resp.headers)
extract_proxy_timing(ctx.sync_proxy_info, ctx.response_headers)
if envelope:
envelope.on_http_status(base_url=selected_base_url_cached, status_code=ctx.status_code)
# Forced upstream streaming already built response_json via aggregator.
if upstream_is_stream:
return ctx.response_json if isinstance(ctx.response_json, dict) else {}
# 统一使用 HTTPStatusError让 TaskService/error_classifier 负责分类
# (客户端错误/兼容性错误/限流等)
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
error_body = ""
try:
error_body = resp.text[:4000] if resp.text else ""
except Exception:
error_body = ""
# 供 ErrorClassifier 优先读取
e.upstream_response = error_body # type: ignore[attr-defined]
raise
# 安全解析 JSON 响应,处理可能的编码错误
try:
ctx.response_json = resp.json()
except (UnicodeDecodeError, json.JSONDecodeError) as e:
# 获取原始响应内容用于调试(存入 upstream_response
raw_content = ""
try:
raw_content = resp.text[:500] if resp.text else "(empty)"
except Exception:
try:
raw_content = repr(resp.content[:500]) if resp.content else "(empty)"
except Exception:
raw_content = "(unable to read)"
logger.error(f"[{handler.request_id}] 无法解析响应 JSON: {e}, 原始内容: {raw_content}")
# 判断错误类型,生成友好的客户端错误消息(不暴露提供商信息)
if raw_content == "(empty)" or not raw_content.strip():
client_message = "上游服务返回了空响应"
elif raw_content.strip().startswith(("<", "<!doctype", "<!DOCTYPE")):
client_message = "上游服务返回了非预期的响应格式"
else:
client_message = "上游服务返回了无效的响应"
raise ProviderNotAvailableException(
client_message,
provider_name=str(provider.name),
upstream_status=resp.status_code,
upstream_response=raw_content,
)
if envelope:
ctx.response_json = envelope.unwrap_response(ctx.response_json)
envelope.postprocess_unwrapped_response(model=model, data=ctx.response_json)
# 检查响应体中的嵌套错误HTTP 200 但响应体包含错误)
if isinstance(ctx.response_json, dict):
parser = get_parser_for_format(provider_api_format)
if parser.is_error_response(ctx.response_json):
parsed = parser.parse_response(ctx.response_json, 200)
logger.warning(
f" [{handler.request_id}] 非流式检测到嵌套错误: "
f"Provider={provider.name}, "
f"error_type={parsed.error_type}, "
f"embedded_status={parsed.embedded_status_code}, "
f"message={parsed.error_message}"
)
raise EmbeddedErrorException(
provider_name=str(provider.name),
error_code=parsed.embedded_status_code,
error_message=parsed.error_message,
error_status=parsed.error_type,
)
# 跨格式:响应转换回 client_format失败触发 failover
if needs_conversion and isinstance(ctx.response_json, dict):
registry = get_format_converter_registry()
ctx.response_json = registry.convert_response(
ctx.response_json,
provider_api_format,
client_api_format,
requested_model=model, # 使用用户请求的原始模型名
)
return ctx.response_json if isinstance(ctx.response_json, dict) else {}
async def _record_stream_failure(
self,
ctx: StreamContext,
error: Exception,
original_headers: dict[str, str],
original_request_body: dict[str, Any],
) -> None:
"""记录流式请求失败"""
handler = self._handler
response_time_ms = handler.elapsed_ms()
status_code = 503
if isinstance(error, ThinkingSignatureException):
status_code = 400
elif isinstance(error, UpstreamClientException):
status_code = _get_error_status_code(error)
elif isinstance(error, ProviderAuthException):
status_code = 503
elif isinstance(error, ProviderRateLimitException):
status_code = 429
elif isinstance(error, ProviderTimeoutException):
status_code = 504
actual_request_body = ctx.provider_request_body or original_request_body
# 失败时返回给客户端的是 JSON 错误响应
client_response_headers = {"content-type": "application/json"}
stream_fail_metadata: dict[str, Any] | None = None
if ctx.proxy_info:
stream_fail_metadata = {"proxy": ctx.proxy_info}
await handler.telemetry.record_failure(
provider=ctx.provider_name or "unknown",
model=ctx.model,
response_time_ms=response_time_ms,
status_code=status_code,
error_message=extract_client_error_message(error),
request_headers=original_headers,
request_body=actual_request_body,
is_stream=True,
api_format=ctx.api_format,
provider_request_headers=ctx.provider_request_headers,
response_headers=ctx.response_headers,
client_response_headers=client_response_headers,
# 格式转换追踪
endpoint_api_format=ctx.provider_api_format or None,
has_format_conversion=ctx.has_format_conversion,
target_model=ctx.mapped_model,
request_metadata=stream_fail_metadata,
)
async def _extract_error_text(self, e: httpx.HTTPStatusError) -> str:
"""从 HTTP 错误中提取错误文本"""
try:
if hasattr(e.response, "is_stream_consumed") and not e.response.is_stream_consumed:
error_bytes = await e.response.aread()
return error_bytes.decode("utf-8", errors="replace")
else:
return e.response.text if hasattr(e.response, "_content") else "Unable to read"
except Exception as decode_error:
return f"Unable to read error: {decode_error}"

View File

@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Any from typing import TYPE_CHECKING, Any
from src.api.handlers.base.parsers import get_parser_for_format from src.api.handlers.base.parsers import get_parser_for_format
from src.api.handlers.base.stream_context import StreamContext from src.api.handlers.base.stream_context import StreamContext
@@ -18,12 +18,15 @@ from .cli_sse_helpers import (
_parse_sse_event_data_line, _parse_sse_event_data_line,
) )
if TYPE_CHECKING:
from src.api.handlers.base.cli_protocol import CliHandlerProtocol
class CliEventMixin: class CliEventMixin:
"""SSE 事件处理和格式转换相关方法的 Mixin""" """SSE 事件处理和格式转换相关方法的 Mixin"""
def _handle_sse_event( def _handle_sse_event(
self, self: CliHandlerProtocol,
ctx: StreamContext, ctx: StreamContext,
event_name: str | None, event_name: str | None,
data_str: str, data_str: str,

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import time import time
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import Any from typing import TYPE_CHECKING, Any
import httpx import httpx
from fastapi import Request from fastapi import Request
@@ -25,6 +25,9 @@ from src.database import get_db
from src.models.database import User from src.models.database import User
from src.services.provider.behavior import get_provider_behavior from src.services.provider.behavior import get_provider_behavior
if TYPE_CHECKING:
from src.api.handlers.base.cli_protocol import CliHandlerProtocol
class CliMonitorMixin: class CliMonitorMixin:
"""监控和统计相关方法的 Mixin""" """监控和统计相关方法的 Mixin"""
@@ -157,7 +160,7 @@ class CliMonitorMixin:
raise raise
async def _record_stream_stats( async def _record_stream_stats(
self, self: CliHandlerProtocol,
ctx: StreamContext, ctx: StreamContext,
original_headers: dict[str, str], original_headers: dict[str, str],
original_request_body: dict[str, Any], original_request_body: dict[str, Any],

View File

@@ -29,6 +29,7 @@ from src.utils.sse_parser import SSEEventParser
from src.utils.timeout import read_first_chunk_with_ttfb_timeout from src.utils.timeout import read_first_chunk_with_ttfb_timeout
if TYPE_CHECKING: if TYPE_CHECKING:
from src.api.handlers.base.cli_protocol import CliHandlerProtocol
from src.models.database import Provider, ProviderEndpoint from src.models.database import Provider, ProviderEndpoint
@@ -136,7 +137,7 @@ class CliPrefetchMixin:
) )
async def _prefetch_and_check_embedded_error( async def _prefetch_and_check_embedded_error(
self, self: CliHandlerProtocol,
byte_iterator: Any, byte_iterator: Any,
provider: "Provider", provider: "Provider",
endpoint: "ProviderEndpoint", endpoint: "ProviderEndpoint",

View File

@@ -0,0 +1,252 @@
"""
CLI Handler Mixin Protocol -- Mixin 隐式依赖的编译时契约
各 Mixin (CliStreamMixin, CliSyncMixin, CliRequestMixin, CliMonitorMixin,
CliPrefetchMixin, CliEventMixin) 通过 duck typing 访问宿主类的属性和方法。
本模块将这些隐式依赖显式声明为 Protocol使 mypy/pyright 能在编辑期捕获
缺失属性或类型不匹配的错误。
渐进式采用:仅在各 Mixin 的公开方法签名中标注 `self: CliHandlerProtocol`
不修改方法体或私有 helper。
"""
from __future__ import annotations
from typing import (
TYPE_CHECKING,
Any,
Protocol,
runtime_checkable,
)
if TYPE_CHECKING:
from redis import Redis
from sqlalchemy.orm import Session
from src.api.handlers.base.base_handler import MessageTelemetry
from src.api.handlers.base.request_builder import RequestBuilder
from src.api.handlers.base.response_parser import ResponseParser
from src.api.handlers.base.stream_context import StreamContext
from src.models.database import ApiKey, User
@runtime_checkable
class CliHandlerProtocol(Protocol):
"""CLI Handler Mixin 宿主需要满足的属性/方法契约。
声明范围仅覆盖 Mixin 实际引用的 self.xxx不要求宿主实现全部
BaseMessageHandler 接口。
"""
# ------------------------------------------------------------------
# 实例属性 -- 来自 BaseMessageHandler.__init__
# ------------------------------------------------------------------
db: Session
user: User
api_key: ApiKey
request_id: str
client_ip: str
user_agent: str
start_time: float
allowed_api_formats: list[str]
redis: Redis # type: ignore[type-arg]
telemetry: MessageTelemetry
perf_metrics: dict[str, Any] | None
# ------------------------------------------------------------------
# 类属性 -- 来自 CliMessageHandlerBase
# ------------------------------------------------------------------
FORMAT_ID: str
DATA_TIMEOUT: int
EMPTY_CHUNK_THRESHOLD: int
# ------------------------------------------------------------------
# 属性/方法 -- 来自 CliMessageHandlerBase / BaseMessageHandler
# ------------------------------------------------------------------
@property
def parser(self) -> ResponseParser: ...
_request_builder: RequestBuilder
# ------------------------------------------------------------------
# 方法 -- 来自 BaseMessageHandler (被多个 Mixin 引用)
# ------------------------------------------------------------------
def _create_pending_usage(
self,
model: str,
is_stream: bool,
request_type: str = ...,
api_format: str | None = ...,
request_headers: dict[str, Any] | None = ...,
request_body: dict[str, Any] | None = ...,
) -> None: ...
def _build_request_metadata(
self,
http_request: Any | None = ...,
) -> dict[str, Any] | None: ...
def _resolve_capability_requirements(
self,
model_name: str,
request_headers: dict[str, str] | None = ...,
request_body: dict[str, Any] | None = ...,
) -> dict[str, bool]: ...
async def _resolve_preferred_key_ids(
self,
model_name: str,
request_body: dict[str, Any] | None = ...,
) -> list[str] | None: ...
def _update_usage_to_streaming(
self,
request_id: str | None = ...,
) -> None: ...
def _update_usage_to_streaming_with_ctx(
self,
ctx: StreamContext,
) -> None: ...
def _log_request_error(
self,
message: str,
error: Exception,
) -> None: ...
# ------------------------------------------------------------------
# 方法 -- 来自 CliRequestMixin (被 CliStreamMixin / CliSyncMixin 引用)
# ------------------------------------------------------------------
def extract_model_from_request(
self,
request_body: dict[str, Any],
path_params: dict[str, Any] | None = ...,
) -> str: ...
async def _get_mapped_model(
self,
source_model: str,
provider_id: str,
) -> str | None: ...
def apply_mapped_model(
self,
request_body: dict[str, Any],
mapped_model: str,
) -> dict[str, Any]: ...
def prepare_provider_request_body(
self,
request_body: dict[str, Any],
) -> dict[str, Any]: ...
def finalize_provider_request(
self,
request_body: dict[str, Any],
*,
mapped_model: str | None,
provider_api_format: str | None,
) -> dict[str, Any]: ...
def get_model_for_url(
self,
request_body: dict[str, Any],
mapped_model: str | None,
) -> str | None: ...
def _convert_request_for_cross_format(
self,
request_body: dict[str, Any],
client_api_format: str,
provider_api_format: str,
mapped_model: str | None,
fallback_model: str,
is_stream: bool,
*,
target_variant: str | None = ...,
) -> tuple[dict[str, Any], str]: ...
def _extract_response_metadata(
self,
response: dict[str, Any],
) -> dict[str, Any]: ...
# ------------------------------------------------------------------
# 方法 -- 来自 CliEventMixin (被 CliStreamMixin / CliPrefetchMixin 引用)
# ------------------------------------------------------------------
def _handle_sse_event(
self,
ctx: StreamContext,
event_name: str | None,
data_str: str,
record_chunk: bool = ...,
) -> None: ...
def _mark_first_output(
self,
ctx: StreamContext,
state: dict[str, bool],
) -> None: ...
def _convert_sse_line(
self,
ctx: StreamContext,
line: str,
events: list[Any],
) -> tuple[list[str], list[dict[str, Any]]]: ...
def _record_converted_chunks(
self,
ctx: StreamContext,
converted_events: list[dict[str, Any]],
) -> None: ...
def _finalize_stream_metadata(
self,
ctx: StreamContext,
) -> None: ...
# ------------------------------------------------------------------
# 方法 -- 来自 CliPrefetchMixin (被 CliStreamMixin 引用)
# ------------------------------------------------------------------
def _flush_remaining_sse_data(
self,
ctx: StreamContext,
buffer: bytes,
decoder: Any,
sse_parser: Any,
*,
record_chunk: bool = ...,
) -> None: ...
def _estimate_tokens_for_incomplete_stream(
self,
ctx: StreamContext,
request_body: dict[str, Any],
) -> None: ...
# ------------------------------------------------------------------
# 方法 -- 来自 CliMonitorMixin (被 CliStreamMixin 引用)
# ------------------------------------------------------------------
async def _create_monitored_stream(
self,
ctx: StreamContext,
stream_generator: Any,
http_request: Any | None = ...,
) -> Any: ...
async def _record_stream_stats(
self,
ctx: StreamContext,
original_headers: dict[str, str],
original_request_body: dict[str, Any],
) -> None: ...
async def _record_stream_failure(
self,
ctx: StreamContext,
error: Exception,
original_headers: dict[str, str],
original_request_body: dict[str, Any],
) -> None: ...

View File

@@ -11,6 +11,7 @@ from src.api.handlers.base.utils import get_format_converter_registry
from src.core.logger import logger from src.core.logger import logger
if TYPE_CHECKING: if TYPE_CHECKING:
from src.api.handlers.base.cli_protocol import CliHandlerProtocol
from src.core.api_format import EndpointDefinition from src.core.api_format import EndpointDefinition
@@ -63,7 +64,7 @@ class CliRequestMixin:
return None return None
def extract_model_from_request( def extract_model_from_request(
self, self: CliHandlerProtocol,
request_body: dict[str, Any], request_body: dict[str, Any],
path_params: dict[str, Any] | None = None, # noqa: ARG002 - 子类使用 path_params: dict[str, Any] | None = None, # noqa: ARG002 - 子类使用
) -> str: ) -> str:

View File

@@ -53,6 +53,7 @@ from src.utils.timeout import read_first_chunk_with_ttfb_timeout
from .cli_sse_helpers import _format_converted_events_to_sse from .cli_sse_helpers import _format_converted_events_to_sse
if TYPE_CHECKING: if TYPE_CHECKING:
from src.api.handlers.base.cli_protocol import CliHandlerProtocol
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
@@ -60,7 +61,7 @@ class CliStreamMixin:
"""流式处理核心方法的 Mixin""" """流式处理核心方法的 Mixin"""
async def process_stream( async def process_stream(
self, self: CliHandlerProtocol,
original_request_body: dict[str, Any], original_request_body: dict[str, Any],
original_headers: dict[str, str], original_headers: dict[str, str],
query_params: dict[str, str] | None = None, query_params: dict[str, str] | None = None,

View File

@@ -39,6 +39,7 @@ from src.services.provider.stream_policy import (
from src.services.provider.transport import build_provider_url from src.services.provider.transport import build_provider_url
if TYPE_CHECKING: if TYPE_CHECKING:
from src.api.handlers.base.cli_protocol import CliHandlerProtocol
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
@@ -46,7 +47,7 @@ class CliSyncMixin:
"""同步处理相关方法的 Mixin""" """同步处理相关方法的 Mixin"""
async def process_sync( async def process_sync(
self, self: CliHandlerProtocol,
original_request_body: dict[str, Any], original_request_body: dict[str, Any],
original_headers: dict[str, str], original_headers: dict[str, str],
query_params: dict[str, str] | None = None, query_params: dict[str, str] | None = None,

View File

@@ -158,12 +158,26 @@ class ModuleRegistry:
# ========== 激活状态检查 ========== # ========== 激活状态检查 ==========
def is_active(self, name: str, db: Session) -> bool: def is_active(self, name: str, db: Session, _visited: set[str] | None = None) -> bool:
""" """
检查模块是否最终激活 检查模块是否最终激活
激活条件available && enabled && 依赖模块都激活 激活条件available && enabled && 依赖模块都激活
Args:
name: 模块名称
db: 数据库会话
_visited: 内部递归防御,防止循环依赖导致无限递归
""" """
if _visited is None:
_visited = set()
if name in _visited:
logger.warning(f"Circular dependency detected in module activation chain: {name}")
return False
_visited.add(name)
if not self.is_available(name): if not self.is_available(name):
return False return False
if not self.is_enabled(name, db): if not self.is_enabled(name, db):
@@ -172,7 +186,7 @@ class ModuleRegistry:
# 检查依赖模块 # 检查依赖模块
module = self._modules[name] module = self._modules[name]
for dep in module.metadata.dependencies: for dep in module.metadata.dependencies:
if not self.is_active(dep, db): if not self.is_active(dep, db, _visited):
return False return False
return True return True
@@ -205,6 +219,28 @@ class ModuleRegistry:
logger.warning(f"Module [{name}] config validation error: {e}") logger.warning(f"Module [{name}] config validation error: {e}")
return False, f"配置验证出错: {str(e)}" return False, f"配置验证出错: {str(e)}"
def reconcile_module_state(self, name: str, db: Session) -> None:
"""
修复模块启用状态与配置的一致性
如果模块已启用但配置验证失败(例如依赖的 Provider Key 被删除),
则自动禁用该模块以保证状态一致性。
此方法是显式的写操作,应在需要状态修复的场景中调用,
而非在纯查询方法中隐式执行。
"""
if name not in self._modules:
return
if not self.is_available(name):
return
config_validated, config_error = self.validate_config(name, db)
if self.is_enabled(name, db) and not config_validated:
self.set_enabled(name, False, db)
logger.info(
f"Module [{name}] auto-disabled: config validation failed" f" ({config_error})"
)
# ========== 状态查询 ========== # ========== 状态查询 ==========
def get_module_status( def get_module_status(
@@ -236,14 +272,6 @@ class ModuleRegistry:
# 获取启用状态 # 获取启用状态
enabled = self.is_enabled(name, db) if available else False enabled = self.is_enabled(name, db) if available else False
# 配置验证失败时自动禁用模块
# 注意:此处故意在 get_status() 中写入,以确保模块状态与配置同步
# 场景:用户删除了模块所依赖的 Provider Key 后,模块应自动关闭
# 权衡:查询方法中的写操作副作用 vs 状态一致性保证
if enabled and not config_validated:
self.set_enabled(name, False, db)
enabled = False
# 计算激活状态available && enabled && config_validated && 依赖模块都激活 # 计算激活状态available && enabled && config_validated && 依赖模块都激活
is_active = self.is_active(name, db) if available else False is_active = self.is_active(name, db) if available else False
active = is_active and config_validated active = is_active and config_validated
@@ -293,6 +321,7 @@ class ModuleRegistry:
if name not in self._modules: if name not in self._modules:
return None return None
self.reconcile_module_state(name, db)
health = await self.check_health(name) if self.is_available(name) else ModuleHealth.UNKNOWN health = await self.check_health(name) if self.is_available(name) else ModuleHealth.UNKNOWN
return self.get_module_status(name, db, health=health) return self.get_module_status(name, db, health=health)
@@ -309,6 +338,7 @@ class ModuleRegistry:
"""获取所有模块状态(同步版本,不含健康检查)""" """获取所有模块状态(同步版本,不含健康检查)"""
result = {} result = {}
for name in self._modules: for name in self._modules:
self.reconcile_module_state(name, db)
status = self.get_module_status(name, db) status = self.get_module_status(name, db)
if status: if status:
result[name] = status result[name] = status

View File

@@ -434,7 +434,7 @@ class PluginManager:
# 创建插件名称到插件对象的映射 # 创建插件名称到插件对象的映射
plugin_map = {plugin.name: plugin for plugin in plugins} plugin_map = {plugin.name: plugin for plugin in plugins}
# 计算每个插件的入度(被依赖的次数 # 计算每个插件的入度(未满足的依赖数量
in_degree = {plugin.name: 0 for plugin in plugins} in_degree = {plugin.name: 0 for plugin in plugins}
# 构建依赖图 # 构建依赖图

591
src/services/cache/_candidate_builder.py vendored Normal file
View File

@@ -0,0 +1,591 @@
"""
候选构建器 (CandidateBuilder)
从 CacheAwareScheduler 拆分出的候选构建逻辑,负责:
- 查询活跃 Provider
- 检查模型支持
- 检查 Key 可用性
- 构建候选列表
"""
from __future__ import annotations
import re
from collections.abc import Sequence
from typing import TYPE_CHECKING
from sqlalchemy.orm import Session, selectinload
from src.core.api_format.conversion.compatibility import is_format_compatible
from src.core.api_format.enums import EndpointKind
from src.core.api_format.signature import make_signature_key, parse_signature_key
from src.core.key_capabilities import check_capability_match
from src.core.logger import logger
from src.core.model_permissions import check_model_allowed_with_mappings
from src.models.database import (
Model,
Provider,
ProviderAPIKey,
ProviderEndpoint,
)
from src.services.cache.quota_skipper import is_key_quota_exhausted
from src.services.health.monitor import health_monitor
from src.services.provider.format import normalize_endpoint_signature
if TYPE_CHECKING:
from src.models.database import GlobalModel
from src.services.cache.aware_scheduler import CacheAwareScheduler, ProviderCandidate
from src.services.cache.model_cache import ModelCacheService
def _sort_endpoints_by_family_priority(
eps: Sequence[ProviderEndpoint],
) -> list[ProviderEndpoint]:
"""按 ApiFamily 优先级对端点排序(同分组内使用)。"""
from src.core.api_format.enums import ApiFamily
def sort_key(ep: ProviderEndpoint) -> int:
family_str = str(getattr(ep, "api_family", "") or "").strip().lower()
try:
return ApiFamily(family_str).priority
except ValueError:
return 99
return sorted(eps, key=sort_key)
class CandidateBuilder:
"""候选构建器,负责查询 Provider、检查模型支持和 Key 可用性、构建候选列表。"""
def __init__(self, scheduler: CacheAwareScheduler) -> None:
self._scheduler = scheduler
def _query_providers(
self,
db: Session,
provider_offset: int = 0,
provider_limit: int | None = None,
) -> list[Provider]:
"""
查询活跃的 Providers带预加载
Args:
db: 数据库会话
provider_offset: 分页偏移
provider_limit: 分页限制
Returns:
Provider 列表
"""
provider_query = (
db.query(Provider)
.options(
# 预加载 Provider 级别的 api_keys
selectinload(Provider.api_keys),
# 预加载 endpoints用于按 api_format 选择请求配置)
selectinload(Provider.endpoints),
# 同时加载 models 和 global_model 关系
selectinload(Provider.models).selectinload(Model.global_model),
)
.filter(Provider.is_active.is_(True))
.order_by(Provider.provider_priority.asc())
)
if provider_offset:
provider_query = provider_query.offset(provider_offset)
if provider_limit:
provider_query = provider_query.limit(provider_limit)
return provider_query.all()
async def _check_model_support(
self,
db: Session,
provider: Provider,
model_name: str,
api_format: str | None = None,
is_stream: bool = False,
capability_requirements: dict[str, bool] | None = None,
) -> tuple[bool, str | None, list[str] | None, set[str] | None]:
"""
检查 Provider 是否支持指定模型(可选检查流式支持和能力需求)
模型能力检查在这里进行(而不是在 Key 级别),因为:
- 模型支持的能力是全局的,与具体的 Key 无关
- 如果模型不支持某能力,整个 Provider 的所有 Key 都应该被跳过
仅支持直接匹配 GlobalModel.name外部请求不接受映射名
Args:
db: 数据库会话
provider: Provider 对象
model_name: 模型名称(必须是 GlobalModel.name
is_stream: 是否是流式请求,如果为 True 则同时检查流式支持
capability_requirements: 能力需求(可选),用于检查模型是否支持所需能力
Returns:
(is_supported, skip_reason, supported_capabilities, provider_model_names)
- is_supported: 是否支持
- skip_reason: 跳过原因
- supported_capabilities: 模型支持的能力列表
- provider_model_names: Provider 侧可用的模型名称集合(主名称 + 映射名称,按 api_format 过滤)
"""
# Avoid holding a DB connection while awaiting cache/Redis inside ModelCacheService.
self._scheduler._release_db_connection_before_await(db)
# 仅接受 GlobalModel.name不允许映射名
normalized_name = model_name.strip() if isinstance(model_name, str) else ""
if not normalized_name:
return False, "模型不存在或名称无效", None, None
global_model = await ModelCacheService.get_global_model_by_name(db, normalized_name)
if not global_model or not global_model.is_active:
return False, "模型不存在或已停用", None, None
# 找到 GlobalModel 后,检查当前 Provider 是否支持
is_supported, skip_reason, caps, provider_model_names = (
await self._check_model_support_for_global_model(
db,
provider,
global_model,
model_name,
api_format,
is_stream,
capability_requirements,
)
)
return is_supported, skip_reason, caps, provider_model_names
async def _check_model_support_for_global_model(
self,
db: Session,
provider: Provider,
global_model: GlobalModel,
model_name: str,
api_format: str | None = None,
is_stream: bool = False,
capability_requirements: dict[str, bool] | None = None,
) -> tuple[bool, str | None, list[str] | None, set[str] | None]:
"""
检查 Provider 是否支持指定的 GlobalModel
Args:
db: 数据库会话
provider: Provider 对象
global_model: GlobalModel 对象
model_name: 用户请求的模型名称(用于错误消息)
is_stream: 是否是流式请求
capability_requirements: 能力需求
Returns:
(is_supported, skip_reason, supported_capabilities, provider_model_names)
"""
# 确保 global_model 附加到当前 Session
# 注意:从缓存重建的对象是 transient 状态,不能使用 load=False
# 使用 load=True默认允许 SQLAlchemy 正确处理 transient 对象
from sqlalchemy import inspect
insp = inspect(global_model)
if insp.transient or insp.detached:
# transient/detached 对象:使用默认 merge会查询 DB 检查是否存在)
global_model = db.merge(global_model)
else:
# persistent 对象:已经附加到 session无需 merge
pass
# 获取模型支持的能力列表
model_supported_capabilities: list[str] = list(global_model.supported_capabilities or [])
# 查询该 Provider 是否有实现这个 GlobalModel
for model in provider.models:
if model.global_model_id == global_model.id and model.is_active:
# 检查流式支持
if is_stream:
supports_streaming = model.get_effective_supports_streaming()
if not supports_streaming:
return False, f"模型 {model_name} 在此 Provider 不支持流式", None, None
# 检查模型是否支持所需的能力(在 Provider 级别检查,而不是 Key 级别)
# 只有当 model_supported_capabilities 非空时才进行检查
# 空列表意味着模型没有配置能力限制,默认支持所有能力
if capability_requirements and model_supported_capabilities:
for cap_name, is_required in capability_requirements.items():
if is_required and cap_name not in model_supported_capabilities:
return (
False,
f"模型 {model_name} 不支持能力: {cap_name}",
list(model_supported_capabilities),
None,
)
provider_model_names: set[str] = {model.provider_model_name}
raw_mappings = model.provider_model_mappings
if isinstance(raw_mappings, list):
for raw in raw_mappings:
if not isinstance(raw, dict):
continue
name = raw.get("name")
if not isinstance(name, str) or not name.strip():
continue
mapping_api_formats = raw.get("api_formats")
if api_format and mapping_api_formats:
# 新模式endpoint signaturefamily:kind按小写 canonical 比较
if isinstance(mapping_api_formats, list):
target = str(api_format).strip().lower()
allowed = {
str(fmt).strip().lower() for fmt in mapping_api_formats if fmt
}
if target not in allowed:
continue
provider_model_names.add(name.strip())
return True, None, list(model_supported_capabilities), provider_model_names
return False, "Provider 未实现此模型", None, None
def _check_key_availability(
self,
key: ProviderAPIKey,
api_format: str | None,
model_name: str,
capability_requirements: dict[str, bool] | None = None,
model_mappings: list[str] | None = None,
candidate_models: set[str] | None = None,
*,
provider_type: str | None = None,
) -> tuple[bool, str | None, str | None]:
"""
检查 API Key 的可用性
注意:模型能力检查已移到 _check_model_support 中进行Provider 级别),
这里只检查 Key 级别的能力匹配。
Args:
key: API Key 对象
model_name: 模型名称GlobalModel.name
capability_requirements: 能力需求(可选)
model_mappings: GlobalModel 的映射列表(用于通配符匹配)
candidate_models: Provider 侧可用的模型名称集合(用于限制映射匹配范围)
Returns:
(is_available, skip_reason, mapping_matched_model)
- is_available: Key 是否可用
- skip_reason: 不可用时的原因
- mapping_matched_model: 通过映射匹配到的模型名(用于实际请求)
"""
# 检查熔断器状态(使用详细状态方法获取更丰富的跳过原因,按 API 格式)
is_available, circuit_reason = health_monitor.get_circuit_breaker_status(
key, api_format=api_format
)
if not is_available:
return False, circuit_reason or "熔断器已打开", None
# 模型权限检查:使用 allowed_models 白名单
# None = 允许所有模型,[] = 拒绝所有模型,["a","b"] = 只允许指定模型
# 支持通配符映射匹配(通过 model_mappings
try:
is_allowed, mapping_matched_model = check_model_allowed_with_mappings(
model_name=model_name,
allowed_models=key.allowed_models,
model_mappings=model_mappings,
candidate_models=candidate_models,
)
if mapping_matched_model:
logger.debug(
"[Scheduler] Key {}... 模型名匹配: model={} -> {}, allowed_models={}",
key.id[:8],
model_name,
mapping_matched_model,
key.allowed_models,
)
except TimeoutError:
# 正则匹配超时(可能是 ReDoS 攻击或复杂模式)
logger.warning("映射匹配超时: key_id={}, model={}", key.id, model_name)
return False, "映射匹配超时,请简化配置", None
except re.error as e:
# 正则语法错误(配置问题)
logger.warning("映射规则无效: key_id={}, model={}, error={}", key.id, model_name, e)
return False, f"映射规则无效: {str(e)}", None
except Exception as e:
# 其他未知异常
logger.error(
"映射匹配异常: key_id={}, model={}, error={}", key.id, model_name, e, exc_info=True
)
# 异常时保守处理:不允许使用该 Key
return False, "映射匹配失败", None
if not is_allowed:
return (
False,
f"Key 不支持 {model_name}",
None,
)
# Key 级别的能力匹配检查
# 注意:模型级别的能力检查已在 _check_model_support 中完成
# 始终执行检查,即使 capability_requirements 为空
# 因为 check_capability_match 会检查 Key 的 EXCLUSIVE 能力是否被浪费
key_caps: dict[str, bool] = dict(key.capabilities or {})
is_match, skip_reason = check_capability_match(key_caps, capability_requirements)
if not is_match:
return False, skip_reason, None
effective_model_name = mapping_matched_model or model_name
quota_exhausted, quota_reason = is_key_quota_exhausted(
provider_type,
key,
model_name=effective_model_name,
)
if quota_exhausted:
return False, quota_reason, mapping_matched_model
return True, None, mapping_matched_model
async def _build_candidates(
self,
db: Session,
providers: list[Provider],
client_format: str,
model_name: str,
affinity_key: str | None,
model_mappings: list[str] | None = None,
max_candidates: int | None = None,
is_stream: bool = False,
capability_requirements: dict[str, bool] | None = None,
global_conversion_enabled: bool = True,
) -> "list[ProviderCandidate]":
"""
构建候选列表
Key 直属 Provider通过 api_formats 筛选符合端点格式的 Key。
Args:
db: 数据库会话
providers: Provider 列表
client_format: 客户端请求的 API 格式
model_name: 模型名称GlobalModel.name
affinity_key: 亲和性标识符通常为API Key ID
model_mappings: GlobalModel 的映射列表(用于 Key.allowed_models 通配符匹配)
max_candidates: 最大候选数
is_stream: 是否是流式请求,如果为 True 则过滤不支持流式的 Provider
capability_requirements: 能力需求(可选)
global_conversion_enabled: 格式转换总开关(数据库配置),关闭时禁止任何跨格式转换
Returns:
候选列表
"""
from src.services.cache.aware_scheduler import ProviderCandidate
candidates: list[ProviderCandidate] = []
client_format_str = normalize_endpoint_signature(client_format)
client_sig = parse_signature_key(client_format_str)
client_family, client_kind = client_sig.api_family, client_sig.endpoint_kind
# chat/cli 互相可回退用于同协议族下的端点变体video/image 等不跨类回退
if client_kind in {EndpointKind.CHAT, EndpointKind.CLI}:
allowed_kinds = {EndpointKind.CHAT, EndpointKind.CLI}
else:
allowed_kinds = {client_kind}
for provider in providers:
logger.debug(
"[Scheduler] Checking provider: {}, endpoints={}",
provider.name,
len(provider.endpoints) if provider.endpoints else 0,
)
# 按端点格式分别判断兼容性与模型/Key 可用性:
# - 同格式端点优先needs_conversion=False
# - 跨格式端点次之needs_conversion=True
model_support_cache: dict[
str, tuple[bool, str | None, list[str] | None, set[str] | None]
] = {}
exact_candidates: list[ProviderCandidate] = []
convertible_candidates: list[ProviderCandidate] = []
# 使用新架构字段 (api_family, endpoint_kind) 进行预过滤与排序:
# - family/kind 匹配的 endpoint 排在前面(但不做硬过滤,避免破坏格式转换路径)
# - chat/cli 请求允许互相回退(优先同 kind
# - video 等请求只允许同 kind
endpoints = list(provider.endpoints or [])
allowed_kind_values = {k.value for k in allowed_kinds}
preferred: list[ProviderEndpoint] = []
preferred_other_family: list[ProviderEndpoint] = []
fallback: list[ProviderEndpoint] = []
fallback_other_family: list[ProviderEndpoint] = []
for ep in endpoints:
if not getattr(ep, "is_active", False):
continue
raw_family = getattr(ep, "api_family", None)
raw_kind = getattr(ep, "endpoint_kind", None)
if not isinstance(raw_family, str) or not raw_family.strip():
continue
if not isinstance(raw_kind, str) or not raw_kind.strip():
continue
ep_family = raw_family.strip().lower()
ep_kind = raw_kind.strip().lower()
if allowed_kind_values and ep_kind not in allowed_kind_values:
continue
same_family = ep_family == client_family.value
same_kind = ep_kind == client_kind.value
if same_kind and same_family:
preferred.append(ep)
elif same_kind:
preferred_other_family.append(ep)
elif same_family:
fallback.append(ep)
else:
fallback_other_family.append(ep)
endpoints = (
_sort_endpoints_by_family_priority(preferred)
+ _sort_endpoints_by_family_priority(preferred_other_family)
+ _sort_endpoints_by_family_priority(fallback)
+ _sort_endpoints_by_family_priority(fallback_other_family)
)
for endpoint in endpoints:
logger.debug(
"[Scheduler] Checking endpoint: family={}, kind={}, is_active={}, base_url={}",
getattr(endpoint, "api_family", None),
getattr(endpoint, "endpoint_kind", None),
getattr(endpoint, "is_active", None),
(endpoint.base_url[:50] if endpoint.base_url else "N/A"),
)
if not endpoint.is_active:
logger.debug("[Scheduler] Endpoint skipped: not active")
continue
endpoint_format_str = make_signature_key(
str(getattr(endpoint, "api_family", "")).strip().lower(),
str(getattr(endpoint, "endpoint_kind", "")).strip().lower(),
)
# 计算格式转换开关状态(三层优先级)
#
# 1) 全局开关(数据库配置)关闭 -> 禁止任何跨格式转换
# 2) 全局开关开启 -> 允许跨格式转换
# 3) 提供商覆盖Provider.enable_format_conversion开启 -> 强制允许(跳过端点检查)
# 4) 否则 -> 由端点配置 format_acceptance_config 决定是否允许
provider_allows_conversion = getattr(provider, "enable_format_conversion", True)
skip_endpoint_check = global_conversion_enabled or provider_allows_conversion
is_compatible, needs_conversion, _compat_reason = is_format_compatible(
client_format_str,
endpoint_format_str,
getattr(endpoint, "format_acceptance_config", None),
is_stream,
global_conversion_enabled,
skip_endpoint_check=skip_endpoint_check,
)
logger.debug(
"[Scheduler] Format compatibility: client={}, endpoint={}, compatible={}, "
"global={}, provider={}, skip_endpoint={}, reason={}",
client_format_str,
endpoint_format_str,
is_compatible,
global_conversion_enabled,
provider_allows_conversion,
skip_endpoint_check,
_compat_reason,
)
if not is_compatible:
continue
# 检查模型支持(按端点格式过滤 provider_model_mappings
if endpoint_format_str not in model_support_cache:
model_support_cache[endpoint_format_str] = await self._check_model_support(
db,
provider,
model_name,
api_format=endpoint_format_str,
is_stream=is_stream,
capability_requirements=capability_requirements,
)
supports_model, skip_reason, _model_caps, provider_model_names = (
model_support_cache[endpoint_format_str]
)
logger.debug(
"[Scheduler] Model support: provider={}, model={}, supports={}, reason={}",
provider.name,
model_name,
supports_model,
skip_reason,
)
if not supports_model:
logger.debug(
f"Provider {provider.name} 端点 {endpoint_format_str} "
f"不支持模型 {model_name}: {skip_reason}"
)
continue
# Key 直属 Provider通过 api_formats 按端点格式筛选
# api_formats=None 视为"全支持"(兼容历史数据)
active_keys = [
key
for key in provider.api_keys
if key.is_active
and (key.api_formats is None or endpoint_format_str in key.api_formats)
]
if not active_keys:
continue
# 检查是否所有 Key 都是 TTL=0轮换模式
use_random = all((key.cache_ttl_minutes or 0) == 0 for key in active_keys)
if use_random and len(active_keys) > 1:
logger.debug(
f" Provider {provider.name} 启用 Key 轮换模式 "
f"(endpoint_format={endpoint_format_str}, {len(active_keys)} keys)"
)
keys = self._scheduler._candidate_sorter._shuffle_keys_by_internal_priority(
active_keys, affinity_key, use_random
)
for key in keys:
# Key 级别检查(健康度/熔断按 provider_format bucket
# 传入 provider_model_names 作为 candidate_models
# 用于检查 Key 的 allowed_models 是否支持 Provider 定义的模型名称
is_available, key_skip_reason, mapping_matched_model = (
self._check_key_availability(
key,
endpoint_format_str,
model_name,
capability_requirements,
model_mappings=model_mappings,
candidate_models=provider_model_names,
provider_type=getattr(provider, "provider_type", None),
)
)
candidate = ProviderCandidate(
provider=provider,
endpoint=endpoint,
key=key,
is_skipped=not is_available,
skip_reason=key_skip_reason,
mapping_matched_model=mapping_matched_model,
needs_conversion=needs_conversion,
provider_api_format=str(endpoint_format_str or ""),
)
if needs_conversion:
convertible_candidates.append(candidate)
else:
exact_candidates.append(candidate)
candidates.extend(exact_candidates)
candidates.extend(convertible_candidates)
# max_candidates 截断应在所有候选收集完成后统一处理,确保优先级排序正确
if max_candidates and len(candidates) > max_candidates:
candidates = candidates[:max_candidates]
return candidates

268
src/services/cache/_candidate_sorter.py vendored Normal file
View File

@@ -0,0 +1,268 @@
"""
候选排序器 (CandidateSorter)
从 CacheAwareScheduler 拆分出的候选排序逻辑,负责:
- 优先级模式排序provider / global_key
- 负载均衡模式排序
- Key 内部按优先级分组打乱
"""
from __future__ import annotations
import random
from collections import defaultdict
from typing import TYPE_CHECKING
from src.core.logger import logger
from src.services.system.config import SystemConfigService
if TYPE_CHECKING:
from sqlalchemy.orm import Session
from src.models.database import ProviderAPIKey
from src.services.cache.aware_scheduler import CacheAwareScheduler, ProviderCandidate
class CandidateSorter:
"""候选排序器,负责优先级模式排序、负载均衡排序和 Key 内部打乱。"""
def __init__(self, scheduler: CacheAwareScheduler) -> None:
self._scheduler = scheduler
def _apply_priority_mode_sort(
self,
candidates: list[ProviderCandidate],
db: Session,
affinity_key: str | None = None,
api_format: str | None = None,
) -> list[ProviderCandidate]:
"""
根据优先级模式对候选列表排序(数字越小越优先)
排序规则(受 keep_priority_on_conversion 配置影响):
1. 如果全局配置 keep_priority_on_conversion=True所有候选保持原优先级
2. 否则,按 needs_conversion 和 provider.keep_priority_on_conversion 分组:
- 保持优先级的候选exact 或 provider.keep_priority_on_conversion=True按原优先级排序
- 需要降级的候选convertible 且 provider.keep_priority_on_conversion=False整体排在后面
3. 在同一组内,按优先级模式排序:
- provider: 按 Provider.provider_priority -> Key.internal_priority 排序
- global_key: 按 Key.global_priority_by_format 排序
"""
if not candidates:
return candidates
s = self._scheduler
# 全局配置:如果开启,所有候选保持原优先级
global_keep_priority = SystemConfigService.is_keep_priority_on_conversion(db)
if global_keep_priority:
# 全局开启:不分组,直接按优先级模式排序
if s.priority_mode == s.PRIORITY_MODE_GLOBAL_KEY:
return self._sort_by_global_priority_with_hash(candidates, affinity_key, api_format)
# 提供商优先模式:保持构建时的顺序(已按 provider_priority 排序)
return candidates
# 全局未开启:按是否需要降级分组
# - 不需要降级exact 候选 或 provider.keep_priority_on_conversion=True 的 convertible 候选
# - 需要降级convertible 且 provider.keep_priority_on_conversion=False
keep_priority_candidates: list[ProviderCandidate] = []
demote_candidates: list[ProviderCandidate] = []
for c in candidates:
if not c.needs_conversion:
# exact 候选:不需要降级
keep_priority_candidates.append(c)
elif getattr(c.provider, "keep_priority_on_conversion", False):
# convertible 但提供商配置了保持优先级
keep_priority_candidates.append(c)
else:
# convertible 且未配置保持优先级:降级
demote_candidates.append(c)
if s.priority_mode == s.PRIORITY_MODE_GLOBAL_KEY:
# 全局 Key 优先模式:分别对两组排序后合并
sorted_keep = self._sort_by_global_priority_with_hash(
keep_priority_candidates, affinity_key, api_format
)
sorted_demote = self._sort_by_global_priority_with_hash(
demote_candidates, affinity_key, api_format
)
return sorted_keep + sorted_demote
# 提供商优先模式:保持优先级的在前,降级的在后(各组内部顺序已由构建时保证)
return keep_priority_candidates + demote_candidates
def _sort_by_global_priority_with_hash(
self,
candidates: list[ProviderCandidate],
affinity_key: str | None = None,
api_format: str | None = None,
) -> list[ProviderCandidate]:
"""
按 global_priority_by_format 分组排序,同优先级内通过哈希分散实现负载均衡
排序逻辑:
1. 按 global_priority_by_format[api_format] 分组数字小的优先NULL 排后面)
2. 同优先级组内,使用 affinity_key 哈希分散
3. 确保同一用户请求稳定选择同一个 Key缓存亲和性
"""
def get_priority(candidate: ProviderCandidate) -> int:
"""获取候选的优先级"""
if not candidate.key:
return 999999
priority_by_format = candidate.key.global_priority_by_format or {}
if api_format and api_format in priority_by_format:
return priority_by_format[api_format]
return 999999 # NULL 排在后面
# 按优先级分组
priority_groups: dict[int, list[ProviderCandidate]] = defaultdict(list)
for candidate in candidates:
priority = get_priority(candidate)
priority_groups[priority].append(candidate)
result = []
for priority in sorted(priority_groups.keys()): # 数字小的优先级高
group = priority_groups[priority]
if len(group) > 1 and affinity_key:
# 同优先级内哈希分散负载均衡
scored_candidates = []
for candidate in group:
key_id = candidate.key.id if candidate.key else ""
hash_value = self._scheduler._affinity_hash(affinity_key, key_id)
scored_candidates.append((hash_value, candidate))
# 按哈希值排序
sorted_group = [c for _, c in sorted(scored_candidates, key=lambda x: x[0])]
result.extend(sorted_group)
else:
# 单个候选或没有 affinity_key按次要排序条件排序
def secondary_sort(c: ProviderCandidate) -> tuple[int, int, str]:
pp = c.provider.provider_priority
ip = c.key.internal_priority if c.key else None
return (
pp if pp is not None else 999999,
ip if ip is not None else 999999,
c.key.id if c.key else "",
)
result.extend(sorted(group, key=secondary_sort))
return result
def _apply_load_balance(
self, candidates: list[ProviderCandidate], api_format: str | None = None
) -> list[ProviderCandidate]:
"""
负载均衡模式:同优先级内随机轮换
排序逻辑:
1. 按优先级分组provider_priority, internal_priority 或 global_priority_by_format
2. 同优先级组内随机打乱
3. 不考虑缓存亲和性
"""
if not candidates:
return candidates
s = self._scheduler
priority_groups: dict[tuple, list[ProviderCandidate]] = defaultdict(list)
# 根据优先级模式选择分组方式
if s.priority_mode == s.PRIORITY_MODE_GLOBAL_KEY:
# 全局 Key 优先模式:按格式特定优先级分组
for candidate in candidates:
priority = 999999
if candidate.key:
priority_by_format = candidate.key.global_priority_by_format or {}
if api_format and api_format in priority_by_format:
priority = priority_by_format[api_format]
priority_groups[(priority,)].append(candidate)
else:
# 提供商优先模式:按 (provider_priority, internal_priority) 分组
for candidate in candidates:
pp = candidate.provider.provider_priority
ip = candidate.key.internal_priority if candidate.key else None
key = (
pp if pp is not None else 999999,
ip if ip is not None else 999999,
)
priority_groups[key].append(candidate)
result: list[ProviderCandidate] = []
for priority in sorted(priority_groups.keys()):
group = priority_groups[priority]
if len(group) > 1:
# 同优先级内随机打乱
shuffled = list(group)
random.shuffle(shuffled)
result.extend(shuffled)
else:
result.extend(group)
return result
def _shuffle_keys_by_internal_priority(
self,
keys: list[ProviderAPIKey],
affinity_key: str | None = None,
use_random: bool = False,
) -> list[ProviderAPIKey]:
"""
对 API Key 按 internal_priority 分组,同优先级内部基于 affinity_key 进行确定性打乱
目的:
- 数字越小越优先使用
- 同优先级 Key 之间实现负载均衡
- 使用 affinity_key 哈希确保同一请求 Key 的请求稳定(避免破坏缓存亲和性)
- 当 use_random=True 时,使用随机排序实现轮换(用于 TTL=0 的场景)
Args:
keys: API Key 列表
affinity_key: 亲和性标识符(通常为 API Key ID用于确定性打乱
use_random: 是否使用随机排序TTL=0 时为 True
Returns:
排序后的 Key 列表
"""
if not keys:
return []
# 按 internal_priority 分组
priority_groups: dict[int, list[ProviderAPIKey]] = defaultdict(list)
for key in keys:
priority = key.internal_priority if key.internal_priority is not None else 999999
priority_groups[priority].append(key)
# 对每个优先级组内的 Key 进行打乱
result = []
for priority in sorted(priority_groups.keys()): # 数字小的优先级高,排前面
group_keys = priority_groups[priority]
if len(group_keys) > 1:
if use_random:
# TTL=0 模式:使用随机排序实现 Key 轮换
shuffled = list(group_keys)
random.shuffle(shuffled)
result.extend(shuffled)
elif affinity_key:
# 正常模式:使用哈希确定性打乱(保持缓存亲和性)
key_scores = []
for key in group_keys:
hash_value = self._scheduler._affinity_hash(affinity_key, key.id)
key_scores.append((hash_value, key))
# 按哈希值排序
sorted_group = [key for _, key in sorted(key_scores, key=lambda x: x[0])]
result.extend(sorted_group)
else:
# 没有 affinity_key 时按 ID 排序保持稳定性
result.extend(sorted(group_keys, key=lambda k: k.id))
else:
# 单个 Key 直接添加
result.extend(group_keys)
return result

View File

@@ -32,46 +32,37 @@ from __future__ import annotations
import hashlib import hashlib
import math import math
import random
import re
import time import time
from collections import defaultdict
from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any from typing import Any
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session
from src.core.api_format.conversion.compatibility import is_format_compatible
from src.core.api_format.enums import ApiFamily, EndpointKind
from src.core.api_format.signature import make_signature_key, parse_signature_key
from src.core.exceptions import ModelNotSupportedException, ProviderNotAvailableException from src.core.exceptions import ModelNotSupportedException, ProviderNotAvailableException
from src.core.key_capabilities import check_capability_match
from src.core.logger import logger from src.core.logger import logger
from src.core.model_permissions import ( from src.core.model_permissions import (
check_model_allowed, check_model_allowed,
check_model_allowed_with_mappings,
get_allowed_models_preview, get_allowed_models_preview,
merge_allowed_models, merge_allowed_models,
) )
from src.models.database import ( from src.models.database import (
ApiKey, ApiKey,
Model,
Provider, Provider,
ProviderAPIKey, ProviderAPIKey,
ProviderEndpoint, ProviderEndpoint,
) )
from src.services.cache.quota_skipper import is_key_quota_exhausted from src.services.cache._candidate_builder import (
CandidateBuilder,
if TYPE_CHECKING: )
from src.models.database import GlobalModel from src.services.cache._candidate_builder import (
_sort_endpoints_by_family_priority as _sort_endpoints_by_family_priority,
)
from src.services.cache._candidate_sorter import CandidateSorter
from src.services.cache.affinity_manager import ( from src.services.cache.affinity_manager import (
CacheAffinityManager, CacheAffinityManager,
get_affinity_manager, get_affinity_manager,
) )
from src.services.cache.model_cache import ModelCacheService from src.services.cache.model_cache import ModelCacheService
from src.services.health.monitor import health_monitor
from src.services.provider.format import normalize_endpoint_signature from src.services.provider.format import normalize_endpoint_signature
from src.services.rate_limit.adaptive_reservation import ( from src.services.rate_limit.adaptive_reservation import (
AdaptiveReservationManager, AdaptiveReservationManager,
@@ -154,21 +145,6 @@ class ConcurrencySnapshot:
) )
def _sort_endpoints_by_family_priority(
eps: Sequence[ProviderEndpoint],
) -> list[ProviderEndpoint]:
"""按 ApiFamily 优先级对端点排序(同分组内使用)。"""
def sort_key(ep: ProviderEndpoint) -> int:
family_str = str(getattr(ep, "api_family", "") or "").strip().lower()
try:
return ApiFamily(family_str).priority
except ValueError:
return 99
return sorted(eps, key=sort_key)
class CacheAwareScheduler: class CacheAwareScheduler:
""" """
缓存感知调度器 缓存感知调度器
@@ -248,6 +224,10 @@ class CacheAwareScheduler:
"last_reservation_result": None, "last_reservation_result": None,
} }
# 初始化拆分出的子模块
self._candidate_builder = CandidateBuilder(self)
self._candidate_sorter = CandidateSorter(self)
@staticmethod @staticmethod
def _release_db_connection_before_await(db: Session) -> None: def _release_db_connection_before_await(db: Session) -> None:
""" """
@@ -436,7 +416,7 @@ class CacheAwareScheduler:
- 总槽位: 有效 RPM 限制(固定值或学习到的值) - 总槽位: 有效 RPM 限制(固定值或学习到的值)
- 预留比例: 由 AdaptiveReservationManager 根据置信度和负载动态计算 - 预留比例: 由 AdaptiveReservationManager 根据置信度和负载动态计算
- 缓存用户可用: 全部槽位 - 缓存用户可用: 全部槽位
- 新用户可用: 总槽位 × (1 - 动态预留比例) - 新用户可用: 总槽位 x (1 - 动态预留比例)
Args: Args:
key: ProviderAPIKey对象 key: ProviderAPIKey对象
@@ -625,8 +605,8 @@ class CacheAwareScheduler:
预先获取所有可用的 Provider/Endpoint/Key 组合 预先获取所有可用的 Provider/Endpoint/Key 组合
重构后的方法将逻辑拆分为: 重构后的方法将逻辑拆分为:
1. _query_providers: 数据库查询逻辑 1. _query_providers: 数据库查询逻辑(委托给 CandidateBuilder
2. _build_candidates: 候选构建逻辑 2. _build_candidates: 候选构建逻辑(委托给 CandidateBuilder
3. _apply_cache_affinity: 缓存亲和性处理 3. _apply_cache_affinity: 缓存亲和性处理
Args: Args:
@@ -714,8 +694,8 @@ class CacheAwareScheduler:
) )
return [], global_model_id, queried_provider_count return [], global_model_id, queried_provider_count
# 1. 查询 Providers # 1. 查询 Providers(委托给 CandidateBuilder
providers = self._query_providers( providers = self._candidate_builder._query_providers(
db=db, db=db,
provider_offset=provider_offset, provider_offset=provider_offset,
provider_limit=provider_limit, provider_limit=provider_limit,
@@ -755,12 +735,12 @@ class CacheAwareScheduler:
if not providers: if not providers:
return [], global_model_id, queried_provider_count return [], global_model_id, queried_provider_count
# 2. 构建候选列表(传入 is_stream 和 capability_requirements 用于过滤 # 2. 构建候选列表(委托给 CandidateBuilder
# 格式转换总开关(数据库配置):关闭时禁止任何跨格式候选进入队列 # 格式转换总开关(数据库配置):关闭时禁止任何跨格式候选进入队列
global_conversion_enabled = SystemConfigService.is_format_conversion_enabled(db) global_conversion_enabled = SystemConfigService.is_format_conversion_enabled(db)
candidates = await self._build_candidates( candidates = await self._candidate_builder._build_candidates(
db=db, db=db,
providers=providers, providers=providers,
client_format=target_format, client_format=target_format,
@@ -818,8 +798,10 @@ class CacheAwareScheduler:
if not candidates: if not candidates:
return candidates return candidates
# 1. 优先级模式排序 # 1. 优先级模式排序(委托给 CandidateSorter
candidates = self._apply_priority_mode_sort(candidates, db, affinity_key, api_format) candidates = self._candidate_sorter._apply_priority_mode_sort(
candidates, db, affinity_key, api_format
)
# 2. 调度模式排序 # 2. 调度模式排序
if self.scheduling_mode == self.SCHEDULING_MODE_CACHE_AFFINITY: if self.scheduling_mode == self.SCHEDULING_MODE_CACHE_AFFINITY:
@@ -832,7 +814,7 @@ class CacheAwareScheduler:
global_model_id=global_model_id, global_model_id=global_model_id,
) )
elif self.scheduling_mode == self.SCHEDULING_MODE_LOAD_BALANCE: elif self.scheduling_mode == self.SCHEDULING_MODE_LOAD_BALANCE:
candidates = self._apply_load_balance(candidates, api_format) candidates = self._candidate_sorter._apply_load_balance(candidates, api_format)
for candidate in candidates: for candidate in candidates:
candidate.is_cached = False candidate.is_cached = False
else: else:
@@ -841,532 +823,6 @@ class CacheAwareScheduler:
return candidates return candidates
def _query_providers(
self,
db: Session,
provider_offset: int = 0,
provider_limit: int | None = None,
) -> list[Provider]:
"""
查询活跃的 Providers带预加载
Args:
db: 数据库会话
provider_offset: 分页偏移
provider_limit: 分页限制
Returns:
Provider 列表
"""
provider_query = (
db.query(Provider)
.options(
# 预加载 Provider 级别的 api_keys
selectinload(Provider.api_keys),
# 预加载 endpoints用于按 api_format 选择请求配置)
selectinload(Provider.endpoints),
# 同时加载 models 和 global_model 关系
selectinload(Provider.models).selectinload(Model.global_model),
)
.filter(Provider.is_active == True)
.order_by(Provider.provider_priority.asc())
)
if provider_offset:
provider_query = provider_query.offset(provider_offset)
if provider_limit:
provider_query = provider_query.limit(provider_limit)
return provider_query.all()
async def _check_model_support(
self,
db: Session,
provider: Provider,
model_name: str,
api_format: str | None = None,
is_stream: bool = False,
capability_requirements: dict[str, bool] | None = None,
) -> tuple[bool, str | None, list[str] | None, set[str] | None]:
"""
检查 Provider 是否支持指定模型(可选检查流式支持和能力需求)
模型能力检查在这里进行(而不是在 Key 级别),因为:
- 模型支持的能力是全局的,与具体的 Key 无关
- 如果模型不支持某能力,整个 Provider 的所有 Key 都应该被跳过
仅支持直接匹配 GlobalModel.name外部请求不接受映射名
Args:
db: 数据库会话
provider: Provider 对象
model_name: 模型名称(必须是 GlobalModel.name
is_stream: 是否是流式请求,如果为 True 则同时检查流式支持
capability_requirements: 能力需求(可选),用于检查模型是否支持所需能力
Returns:
(is_supported, skip_reason, supported_capabilities, provider_model_names)
- is_supported: 是否支持
- skip_reason: 跳过原因
- supported_capabilities: 模型支持的能力列表
- provider_model_names: Provider 侧可用的模型名称集合(主名称 + 映射名称,按 api_format 过滤)
"""
# Avoid holding a DB connection while awaiting cache/Redis inside ModelCacheService.
self._release_db_connection_before_await(db)
# 仅接受 GlobalModel.name不允许映射名
normalized_name = model_name.strip() if isinstance(model_name, str) else ""
if not normalized_name:
return False, "模型不存在或名称无效", None, None
global_model = await ModelCacheService.get_global_model_by_name(db, normalized_name)
if not global_model or not global_model.is_active:
return False, "模型不存在或已停用", None, None
# 找到 GlobalModel 后,检查当前 Provider 是否支持
is_supported, skip_reason, caps, provider_model_names = (
await self._check_model_support_for_global_model(
db,
provider,
global_model,
model_name,
api_format,
is_stream,
capability_requirements,
)
)
return is_supported, skip_reason, caps, provider_model_names
async def _check_model_support_for_global_model(
self,
db: Session,
provider: Provider,
global_model: GlobalModel,
model_name: str,
api_format: str | None = None,
is_stream: bool = False,
capability_requirements: dict[str, bool] | None = None,
) -> tuple[bool, str | None, list[str] | None, set[str] | None]:
"""
检查 Provider 是否支持指定的 GlobalModel
Args:
db: 数据库会话
provider: Provider 对象
global_model: GlobalModel 对象
model_name: 用户请求的模型名称(用于错误消息)
is_stream: 是否是流式请求
capability_requirements: 能力需求
Returns:
(is_supported, skip_reason, supported_capabilities, provider_model_names)
"""
# 确保 global_model 附加到当前 Session
# 注意:从缓存重建的对象是 transient 状态,不能使用 load=False
# 使用 load=True默认允许 SQLAlchemy 正确处理 transient 对象
from sqlalchemy import inspect
insp = inspect(global_model)
if insp.transient or insp.detached:
# transient/detached 对象:使用默认 merge会查询 DB 检查是否存在)
global_model = db.merge(global_model)
else:
# persistent 对象:已经附加到 session无需 merge
pass
# 获取模型支持的能力列表
model_supported_capabilities: list[str] = list(global_model.supported_capabilities or [])
# 查询该 Provider 是否有实现这个 GlobalModel
for model in provider.models:
if model.global_model_id == global_model.id and model.is_active:
# 检查流式支持
if is_stream:
supports_streaming = model.get_effective_supports_streaming()
if not supports_streaming:
return False, f"模型 {model_name} 在此 Provider 不支持流式", None, None
# 检查模型是否支持所需的能力(在 Provider 级别检查,而不是 Key 级别)
# 只有当 model_supported_capabilities 非空时才进行检查
# 空列表意味着模型没有配置能力限制,默认支持所有能力
if capability_requirements and model_supported_capabilities:
for cap_name, is_required in capability_requirements.items():
if is_required and cap_name not in model_supported_capabilities:
return (
False,
f"模型 {model_name} 不支持能力: {cap_name}",
list(model_supported_capabilities),
None,
)
provider_model_names: set[str] = {model.provider_model_name}
raw_mappings = model.provider_model_mappings
if isinstance(raw_mappings, list):
for raw in raw_mappings:
if not isinstance(raw, dict):
continue
name = raw.get("name")
if not isinstance(name, str) or not name.strip():
continue
mapping_api_formats = raw.get("api_formats")
if api_format and mapping_api_formats:
# 新模式endpoint signaturefamily:kind按小写 canonical 比较
if isinstance(mapping_api_formats, list):
target = str(api_format).strip().lower()
allowed = {
str(fmt).strip().lower() for fmt in mapping_api_formats if fmt
}
if target not in allowed:
continue
provider_model_names.add(name.strip())
return True, None, list(model_supported_capabilities), provider_model_names
return False, "Provider 未实现此模型", None, None
def _check_key_availability(
self,
key: ProviderAPIKey,
api_format: str | None,
model_name: str,
capability_requirements: dict[str, bool] | None = None,
model_mappings: list[str] | None = None,
candidate_models: set[str] | None = None,
*,
provider_type: str | None = None,
) -> tuple[bool, str | None, str | None]:
"""
检查 API Key 的可用性
注意:模型能力检查已移到 _check_model_support 中进行Provider 级别),
这里只检查 Key 级别的能力匹配。
Args:
key: API Key 对象
model_name: 模型名称GlobalModel.name
capability_requirements: 能力需求(可选)
model_mappings: GlobalModel 的映射列表(用于通配符匹配)
candidate_models: Provider 侧可用的模型名称集合(用于限制映射匹配范围)
Returns:
(is_available, skip_reason, mapping_matched_model)
- is_available: Key 是否可用
- skip_reason: 不可用时的原因
- mapping_matched_model: 通过映射匹配到的模型名(用于实际请求)
"""
# 检查熔断器状态(使用详细状态方法获取更丰富的跳过原因,按 API 格式)
is_available, circuit_reason = health_monitor.get_circuit_breaker_status(
key, api_format=api_format
)
if not is_available:
return False, circuit_reason or "熔断器已打开", None
# 模型权限检查:使用 allowed_models 白名单
# None = 允许所有模型,[] = 拒绝所有模型,["a","b"] = 只允许指定模型
# 支持通配符映射匹配(通过 model_mappings
try:
is_allowed, mapping_matched_model = check_model_allowed_with_mappings(
model_name=model_name,
allowed_models=key.allowed_models,
model_mappings=model_mappings,
candidate_models=candidate_models,
)
if mapping_matched_model:
logger.debug(
"[Scheduler] Key {}... 模型名匹配: model={} -> {}, allowed_models={}",
key.id[:8],
model_name,
mapping_matched_model,
key.allowed_models,
)
except TimeoutError:
# 正则匹配超时(可能是 ReDoS 攻击或复杂模式)
logger.warning("映射匹配超时: key_id={}, model={}", key.id, model_name)
return False, "映射匹配超时,请简化配置", None
except re.error as e:
# 正则语法错误(配置问题)
logger.warning("映射规则无效: key_id={}, model={}, error={}", key.id, model_name, e)
return False, f"映射规则无效: {str(e)}", None
except Exception as e:
# 其他未知异常
logger.error(
"映射匹配异常: key_id={}, model={}, error={}", key.id, model_name, e, exc_info=True
)
# 异常时保守处理:不允许使用该 Key
return False, "映射匹配失败", None
if not is_allowed:
return (
False,
f"Key 不支持 {model_name}",
None,
)
# Key 级别的能力匹配检查
# 注意:模型级别的能力检查已在 _check_model_support 中完成
# 始终执行检查,即使 capability_requirements 为空
# 因为 check_capability_match 会检查 Key 的 EXCLUSIVE 能力是否被浪费
key_caps: dict[str, bool] = dict(key.capabilities or {})
is_match, skip_reason = check_capability_match(key_caps, capability_requirements)
if not is_match:
return False, skip_reason, None
effective_model_name = mapping_matched_model or model_name
quota_exhausted, quota_reason = is_key_quota_exhausted(
provider_type,
key,
model_name=effective_model_name,
)
if quota_exhausted:
return False, quota_reason, mapping_matched_model
return True, None, mapping_matched_model
async def _build_candidates(
self,
db: Session,
providers: list[Provider],
client_format: str,
model_name: str,
affinity_key: str | None,
model_mappings: list[str] | None = None,
max_candidates: int | None = None,
is_stream: bool = False,
capability_requirements: dict[str, bool] | None = None,
global_conversion_enabled: bool = True,
) -> list[ProviderCandidate]:
"""
构建候选列表
Key 直属 Provider通过 api_formats 筛选符合端点格式的 Key。
Args:
db: 数据库会话
providers: Provider 列表
client_format: 客户端请求的 API 格式
model_name: 模型名称GlobalModel.name
affinity_key: 亲和性标识符通常为API Key ID
model_mappings: GlobalModel 的映射列表(用于 Key.allowed_models 通配符匹配)
max_candidates: 最大候选数
is_stream: 是否是流式请求,如果为 True 则过滤不支持流式的 Provider
capability_requirements: 能力需求(可选)
global_conversion_enabled: 格式转换总开关(数据库配置),关闭时禁止任何跨格式转换
Returns:
候选列表
"""
candidates: list[ProviderCandidate] = []
client_format_str = normalize_endpoint_signature(client_format)
client_sig = parse_signature_key(client_format_str)
client_family, client_kind = client_sig.api_family, client_sig.endpoint_kind
# chat/cli 互相可回退用于同协议族下的端点变体video/image 等不跨类回退
if client_kind in {EndpointKind.CHAT, EndpointKind.CLI}:
allowed_kinds = {EndpointKind.CHAT, EndpointKind.CLI}
else:
allowed_kinds = {client_kind}
for provider in providers:
logger.debug(
"[Scheduler] Checking provider: {}, endpoints={}",
provider.name,
len(provider.endpoints) if provider.endpoints else 0,
)
# 按端点格式分别判断兼容性与模型/Key 可用性:
# - 同格式端点优先needs_conversion=False
# - 跨格式端点次之needs_conversion=True
model_support_cache: dict[
str, tuple[bool, str | None, list[str] | None, set[str] | None]
] = {}
exact_candidates: list[ProviderCandidate] = []
convertible_candidates: list[ProviderCandidate] = []
# 使用新架构字段 (api_family, endpoint_kind) 进行预过滤与排序:
# - family/kind 匹配的 endpoint 排在前面(但不做硬过滤,避免破坏格式转换路径)
# - chat/cli 请求允许互相回退(优先同 kind
# - video 等请求只允许同 kind
endpoints = list(provider.endpoints or [])
allowed_kind_values = {k.value for k in allowed_kinds}
preferred: list[ProviderEndpoint] = []
preferred_other_family: list[ProviderEndpoint] = []
fallback: list[ProviderEndpoint] = []
fallback_other_family: list[ProviderEndpoint] = []
for ep in endpoints:
if not getattr(ep, "is_active", False):
continue
raw_family = getattr(ep, "api_family", None)
raw_kind = getattr(ep, "endpoint_kind", None)
if not isinstance(raw_family, str) or not raw_family.strip():
continue
if not isinstance(raw_kind, str) or not raw_kind.strip():
continue
ep_family = raw_family.strip().lower()
ep_kind = raw_kind.strip().lower()
if allowed_kind_values and ep_kind not in allowed_kind_values:
continue
same_family = ep_family == client_family.value
same_kind = ep_kind == client_kind.value
if same_kind and same_family:
preferred.append(ep)
elif same_kind:
preferred_other_family.append(ep)
elif same_family:
fallback.append(ep)
else:
fallback_other_family.append(ep)
endpoints = (
_sort_endpoints_by_family_priority(preferred)
+ _sort_endpoints_by_family_priority(preferred_other_family)
+ _sort_endpoints_by_family_priority(fallback)
+ _sort_endpoints_by_family_priority(fallback_other_family)
)
for endpoint in endpoints:
logger.debug(
"[Scheduler] Checking endpoint: family={}, kind={}, is_active={}, base_url={}",
getattr(endpoint, "api_family", None),
getattr(endpoint, "endpoint_kind", None),
getattr(endpoint, "is_active", None),
(endpoint.base_url[:50] if endpoint.base_url else "N/A"),
)
if not endpoint.is_active:
logger.debug("[Scheduler] Endpoint skipped: not active")
continue
endpoint_format_str = make_signature_key(
str(getattr(endpoint, "api_family", "")).strip().lower(),
str(getattr(endpoint, "endpoint_kind", "")).strip().lower(),
)
# 计算格式转换开关状态(三层优先级)
#
# 1) 全局开关(数据库配置)关闭 -> 禁止任何跨格式转换
# 2) 全局开关开启 -> 允许跨格式转换
# 3) 提供商覆盖Provider.enable_format_conversion开启 -> 强制允许(跳过端点检查)
# 4) 否则 -> 由端点配置 format_acceptance_config 决定是否允许
provider_allows_conversion = getattr(provider, "enable_format_conversion", True)
skip_endpoint_check = global_conversion_enabled or provider_allows_conversion
is_compatible, needs_conversion, _compat_reason = is_format_compatible(
client_format_str,
endpoint_format_str,
getattr(endpoint, "format_acceptance_config", None),
is_stream,
global_conversion_enabled,
skip_endpoint_check=skip_endpoint_check,
)
logger.debug(
"[Scheduler] Format compatibility: client={}, endpoint={}, compatible={}, "
"global={}, provider={}, skip_endpoint={}, reason={}",
client_format_str,
endpoint_format_str,
is_compatible,
global_conversion_enabled,
provider_allows_conversion,
skip_endpoint_check,
_compat_reason,
)
if not is_compatible:
continue
# 检查模型支持(按端点格式过滤 provider_model_mappings
if endpoint_format_str not in model_support_cache:
model_support_cache[endpoint_format_str] = await self._check_model_support(
db,
provider,
model_name,
api_format=endpoint_format_str,
is_stream=is_stream,
capability_requirements=capability_requirements,
)
supports_model, skip_reason, _model_caps, provider_model_names = (
model_support_cache[endpoint_format_str]
)
logger.debug(
"[Scheduler] Model support: provider={}, model={}, supports={}, reason={}",
provider.name,
model_name,
supports_model,
skip_reason,
)
if not supports_model:
logger.debug(
f"Provider {provider.name} 端点 {endpoint_format_str} 不支持模型 {model_name}: {skip_reason}"
)
continue
# Key 直属 Provider通过 api_formats 按端点格式筛选
# api_formats=None 视为"全支持"(兼容历史数据)
active_keys = [
key
for key in provider.api_keys
if key.is_active
and (key.api_formats is None or endpoint_format_str in key.api_formats)
]
if not active_keys:
continue
# 检查是否所有 Key 都是 TTL=0轮换模式
use_random = all((key.cache_ttl_minutes or 0) == 0 for key in active_keys)
if use_random and len(active_keys) > 1:
logger.debug(
f" Provider {provider.name} 启用 Key 轮换模式 "
f"(endpoint_format={endpoint_format_str}, {len(active_keys)} keys)"
)
keys = self._shuffle_keys_by_internal_priority(
active_keys, affinity_key, use_random
)
for key in keys:
# Key 级别检查(健康度/熔断按 provider_format bucket
# 传入 provider_model_names 作为 candidate_models
# 用于检查 Key 的 allowed_models 是否支持 Provider 定义的模型名称
is_available, key_skip_reason, mapping_matched_model = (
self._check_key_availability(
key,
endpoint_format_str,
model_name,
capability_requirements,
model_mappings=model_mappings,
candidate_models=provider_model_names,
provider_type=getattr(provider, "provider_type", None),
)
)
candidate = ProviderCandidate(
provider=provider,
endpoint=endpoint,
key=key,
is_skipped=not is_available,
skip_reason=key_skip_reason,
mapping_matched_model=mapping_matched_model,
needs_conversion=needs_conversion,
provider_api_format=str(endpoint_format_str or ""),
)
if needs_conversion:
convertible_candidates.append(candidate)
else:
exact_candidates.append(candidate)
candidates.extend(exact_candidates)
candidates.extend(convertible_candidates)
# max_candidates 截断应在所有候选收集完成后统一处理,确保优先级排序正确
if max_candidates and len(candidates) > max_candidates:
candidates = candidates[:max_candidates]
return candidates
async def _apply_cache_affinity( async def _apply_cache_affinity(
self, self,
candidates: list[ProviderCandidate], candidates: list[ProviderCandidate],
@@ -1545,241 +1001,6 @@ class CacheAwareScheduler:
self.scheduling_mode = normalized self.scheduling_mode = normalized
logger.debug(f"[CacheAwareScheduler] 切换调度模式为: {self.scheduling_mode}") logger.debug(f"[CacheAwareScheduler] 切换调度模式为: {self.scheduling_mode}")
def _apply_priority_mode_sort(
self,
candidates: list[ProviderCandidate],
db: Session,
affinity_key: str | None = None,
api_format: str | None = None,
) -> list[ProviderCandidate]:
"""
根据优先级模式对候选列表排序(数字越小越优先)
排序规则(受 keep_priority_on_conversion 配置影响):
1. 如果全局配置 keep_priority_on_conversion=True所有候选保持原优先级
2. 否则,按 needs_conversion 和 provider.keep_priority_on_conversion 分组:
- 保持优先级的候选exact 或 provider.keep_priority_on_conversion=True按原优先级排序
- 需要降级的候选convertible 且 provider.keep_priority_on_conversion=False整体排在后面
3. 在同一组内,按优先级模式排序:
- provider: 按 Provider.provider_priority -> Key.internal_priority 排序
- global_key: 按 Key.global_priority_by_format 排序
"""
if not candidates:
return candidates
# 全局配置:如果开启,所有候选保持原优先级
global_keep_priority = SystemConfigService.is_keep_priority_on_conversion(db)
if global_keep_priority:
# 全局开启:不分组,直接按优先级模式排序
if self.priority_mode == self.PRIORITY_MODE_GLOBAL_KEY:
return self._sort_by_global_priority_with_hash(candidates, affinity_key, api_format)
# 提供商优先模式:保持构建时的顺序(已按 provider_priority 排序)
return candidates
# 全局未开启:按是否需要降级分组
# - 不需要降级exact 候选 或 provider.keep_priority_on_conversion=True 的 convertible 候选
# - 需要降级convertible 且 provider.keep_priority_on_conversion=False
keep_priority_candidates: list[ProviderCandidate] = []
demote_candidates: list[ProviderCandidate] = []
for c in candidates:
if not c.needs_conversion:
# exact 候选:不需要降级
keep_priority_candidates.append(c)
elif getattr(c.provider, "keep_priority_on_conversion", False):
# convertible 但提供商配置了保持优先级
keep_priority_candidates.append(c)
else:
# convertible 且未配置保持优先级:降级
demote_candidates.append(c)
if self.priority_mode == self.PRIORITY_MODE_GLOBAL_KEY:
# 全局 Key 优先模式:分别对两组排序后合并
sorted_keep = self._sort_by_global_priority_with_hash(
keep_priority_candidates, affinity_key, api_format
)
sorted_demote = self._sort_by_global_priority_with_hash(
demote_candidates, affinity_key, api_format
)
return sorted_keep + sorted_demote
# 提供商优先模式:保持优先级的在前,降级的在后(各组内部顺序已由构建时保证)
return keep_priority_candidates + demote_candidates
def _sort_by_global_priority_with_hash(
self,
candidates: list[ProviderCandidate],
affinity_key: str | None = None,
api_format: str | None = None,
) -> list[ProviderCandidate]:
"""
按 global_priority_by_format 分组排序,同优先级内通过哈希分散实现负载均衡
排序逻辑:
1. 按 global_priority_by_format[api_format] 分组数字小的优先NULL 排后面)
2. 同优先级组内,使用 affinity_key 哈希分散
3. 确保同一用户请求稳定选择同一个 Key缓存亲和性
"""
def get_priority(candidate: ProviderCandidate) -> int:
"""获取候选的优先级"""
if not candidate.key:
return 999999
priority_by_format = candidate.key.global_priority_by_format or {}
if api_format and api_format in priority_by_format:
return priority_by_format[api_format]
return 999999 # NULL 排在后面
# 按优先级分组
priority_groups: dict[int, list[ProviderCandidate]] = defaultdict(list)
for candidate in candidates:
priority = get_priority(candidate)
priority_groups[priority].append(candidate)
result = []
for priority in sorted(priority_groups.keys()): # 数字小的优先级高
group = priority_groups[priority]
if len(group) > 1 and affinity_key:
# 同优先级内哈希分散负载均衡
scored_candidates = []
for candidate in group:
key_id = candidate.key.id if candidate.key else ""
hash_value = self._affinity_hash(affinity_key, key_id)
scored_candidates.append((hash_value, candidate))
# 按哈希值排序
sorted_group = [c for _, c in sorted(scored_candidates, key=lambda x: x[0])]
result.extend(sorted_group)
else:
# 单个候选或没有 affinity_key按次要排序条件排序
def secondary_sort(c: ProviderCandidate) -> tuple[int, int, str]:
pp = c.provider.provider_priority
ip = c.key.internal_priority if c.key else None
return (
pp if pp is not None else 999999,
ip if ip is not None else 999999,
c.key.id if c.key else "",
)
result.extend(sorted(group, key=secondary_sort))
return result
def _apply_load_balance(
self, candidates: list[ProviderCandidate], api_format: str | None = None
) -> list[ProviderCandidate]:
"""
负载均衡模式:同优先级内随机轮换
排序逻辑:
1. 按优先级分组provider_priority, internal_priority 或 global_priority_by_format
2. 同优先级组内随机打乱
3. 不考虑缓存亲和性
"""
if not candidates:
return candidates
priority_groups: dict[tuple, list[ProviderCandidate]] = defaultdict(list)
# 根据优先级模式选择分组方式
if self.priority_mode == self.PRIORITY_MODE_GLOBAL_KEY:
# 全局 Key 优先模式:按格式特定优先级分组
for candidate in candidates:
priority = 999999
if candidate.key:
priority_by_format = candidate.key.global_priority_by_format or {}
if api_format and api_format in priority_by_format:
priority = priority_by_format[api_format]
priority_groups[(priority,)].append(candidate)
else:
# 提供商优先模式:按 (provider_priority, internal_priority) 分组
for candidate in candidates:
pp = candidate.provider.provider_priority
ip = candidate.key.internal_priority if candidate.key else None
key = (
pp if pp is not None else 999999,
ip if ip is not None else 999999,
)
priority_groups[key].append(candidate)
result: list[ProviderCandidate] = []
for priority in sorted(priority_groups.keys()):
group = priority_groups[priority]
if len(group) > 1:
# 同优先级内随机打乱
shuffled = list(group)
random.shuffle(shuffled)
result.extend(shuffled)
else:
result.extend(group)
return result
def _shuffle_keys_by_internal_priority(
self,
keys: list[ProviderAPIKey],
affinity_key: str | None = None,
use_random: bool = False,
) -> list[ProviderAPIKey]:
"""
对 API Key 按 internal_priority 分组,同优先级内部基于 affinity_key 进行确定性打乱
目的:
- 数字越小越优先使用
- 同优先级 Key 之间实现负载均衡
- 使用 affinity_key 哈希确保同一请求 Key 的请求稳定(避免破坏缓存亲和性)
- 当 use_random=True 时,使用随机排序实现轮换(用于 TTL=0 的场景)
Args:
keys: API Key 列表
affinity_key: 亲和性标识符(通常为 API Key ID用于确定性打乱
use_random: 是否使用随机排序TTL=0 时为 True
Returns:
排序后的 Key 列表
"""
if not keys:
return []
# 按 internal_priority 分组
priority_groups: dict[int, list[ProviderAPIKey]] = defaultdict(list)
for key in keys:
priority = key.internal_priority if key.internal_priority is not None else 999999
priority_groups[priority].append(key)
# 对每个优先级组内的 Key 进行打乱
result = []
for priority in sorted(priority_groups.keys()): # 数字小的优先级高,排前面
group_keys = priority_groups[priority]
if len(group_keys) > 1:
if use_random:
# TTL=0 模式:使用随机排序实现 Key 轮换
shuffled = list(group_keys)
random.shuffle(shuffled)
result.extend(shuffled)
elif affinity_key:
# 正常模式:使用哈希确定性打乱(保持缓存亲和性)
key_scores = []
for key in group_keys:
hash_value = self._affinity_hash(affinity_key, key.id)
key_scores.append((hash_value, key))
# 按哈希值排序
sorted_group = [key for _, key in sorted(key_scores, key=lambda x: x[0])]
result.extend(sorted_group)
else:
# 没有 affinity_key 时按 ID 排序保持稳定性
result.extend(sorted(group_keys, key=lambda k: k.id))
else:
# 单个 Key 直接添加
result.extend(group_keys)
return result
async def invalidate_cache( async def invalidate_cache(
self, self,
affinity_key: str, affinity_key: str,

View File

@@ -0,0 +1,216 @@
from __future__ import annotations
from typing import Any
from src.core.api_format.signature import normalize_signature_key
from src.services.billing.token_normalization import normalize_input_tokens_for_billing
from src.services.usage._recording_helpers import (
build_usage_params,
sanitize_request_metadata,
)
from src.services.usage._types import UsageCostInfo, UsageRecordParams
class UsageBillingIntegrationMixin:
"""计费集成方法 -- 准备用量记录的共享逻辑"""
@classmethod
async def _prepare_usage_record(
cls,
params: UsageRecordParams,
) -> tuple[dict[str, Any], float]:
"""准备用量记录的共享逻辑
此方法提取了 record_usage 和 record_usage_async 的公共处理逻辑:
- 获取费率倍数
- 计算成本
- 构建 Usage 参数
Args:
params: 用量记录参数数据类
Returns:
(usage_params 字典, total_cost 总成本)
"""
# 计费口径以 Provider 为准(优先 endpoint_api_format
billing_api_format: str | None = None
if params.endpoint_api_format:
try:
billing_api_format = normalize_signature_key(str(params.endpoint_api_format))
except Exception:
billing_api_format = None
if billing_api_format is None and params.api_format:
try:
billing_api_format = normalize_signature_key(str(params.api_format))
except Exception:
billing_api_format = None
input_tokens_for_billing = normalize_input_tokens_for_billing(
billing_api_format,
params.input_tokens,
params.cache_read_input_tokens,
)
# 获取费率倍数和是否免费套餐(传递 api_format 支持按格式配置的倍率)
actual_rate_multiplier, is_free_tier = await cls._get_rate_multiplier_and_free_tier(
params.db, params.provider_api_key_id, params.provider_id, billing_api_format
)
metadata = dict(params.metadata or {})
is_failed_request = params.status_code >= 400 or params.error_message is not None
# Helper: compute billing task_type (billing domain)
billing_task_type = (params.request_type or "").lower()
if billing_task_type not in {"chat", "cli", "video", "image", "audio"}:
billing_task_type = "chat"
# 使用新计费系统计算费用
from src.services.billing.service import BillingService
request_count = 0 if is_failed_request else 1
dims: dict[str, Any] = {
"input_tokens": input_tokens_for_billing,
"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 (pruned later by sanitize_request_metadata)
metadata["billing_snapshot"] = snap.to_dict()
# Best-effort prune metadata to reduce DB/memory pressure.
metadata = sanitize_request_metadata(metadata)
# 构建 Usage 参数
usage_params = build_usage_params(
db=params.db,
user=params.user,
api_key=params.api_key,
provider=params.provider,
model=params.model,
input_tokens=input_tokens_for_billing,
output_tokens=params.output_tokens,
cache_creation_input_tokens=params.cache_creation_input_tokens,
cache_read_input_tokens=params.cache_read_input_tokens,
request_type=params.request_type,
api_format=params.api_format,
endpoint_api_format=params.endpoint_api_format,
has_format_conversion=params.has_format_conversion,
is_stream=params.is_stream,
response_time_ms=params.response_time_ms,
first_byte_time_ms=params.first_byte_time_ms,
status_code=params.status_code,
error_message=params.error_message,
metadata=metadata,
request_headers=params.request_headers,
request_body=params.request_body,
provider_request_headers=params.provider_request_headers,
response_headers=params.response_headers,
client_response_headers=params.client_response_headers,
response_body=params.response_body,
request_id=params.request_id,
provider_id=params.provider_id,
provider_endpoint_id=params.provider_endpoint_id,
provider_api_key_id=params.provider_api_key_id,
status=params.status,
target_model=params.target_model,
cost=UsageCostInfo(
input_cost=input_cost,
output_cost=output_cost,
cache_creation_cost=cache_creation_cost,
cache_read_cost=cache_read_cost,
cache_cost=cache_cost,
request_cost=request_cost,
total_cost=total_cost,
input_price=input_price,
output_price=output_price,
cache_creation_price=cache_creation_price,
cache_read_price=cache_read_price,
request_price=request_price,
actual_rate_multiplier=actual_rate_multiplier,
is_free_tier=is_free_tier,
),
)
return usage_params, total_cost
@classmethod
async def _prepare_usage_records_batch(
cls,
params_list: list[UsageRecordParams],
) -> list[tuple[dict[str, Any], float, Exception | None]]:
"""批量并行准备用量记录(性能优化)
并行调用 _prepare_usage_record提高批量处理效率。
Args:
params_list: 用量记录参数列表
Returns:
列表,每项为 (usage_params, total_cost, exception)
如果处理成功exception 为 None
"""
import asyncio
async def prepare_single(
params: UsageRecordParams,
) -> tuple[dict[str, Any], float, Exception | None]:
try:
usage_params, total_cost = await cls._prepare_usage_record(params)
return (usage_params, total_cost, None)
except Exception as e:
return ({}, 0.0, e)
if not params_list:
return []
# 避免一次性创建过多 task并且 _prepare_usage_record 内部也可能包含并行调用)
# 这里采用分批 gather 来限制并发量。
chunk_size = 50
results: list[tuple[dict[str, Any], float, Exception | None]] = []
for i in range(0, len(params_list), chunk_size):
chunk = params_list[i : i + chunk_size]
chunk_results = await asyncio.gather(*(prepare_single(p) for p in chunk))
results.extend(chunk_results)
return results

View File

@@ -0,0 +1,310 @@
from __future__ import annotations
import json
from typing import Any
from sqlalchemy.orm import Session
from src.models.database import ApiKey, Usage, User
from src.services.system.config import SystemConfigService
from src.services.usage._types import UsageCostInfo
from src.services.usage.error_classifier import classify_error
# 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_updated_at",
"perf",
"_metadata_truncated",
}
)
def build_usage_params(
*,
db: Session,
user: User | None,
api_key: ApiKey | None,
provider: str,
model: str,
input_tokens: int,
output_tokens: int,
cache_creation_input_tokens: int,
cache_read_input_tokens: int,
request_type: str,
api_format: str | None,
endpoint_api_format: str | None,
has_format_conversion: bool,
is_stream: bool,
response_time_ms: int | None,
first_byte_time_ms: int | None,
status_code: int,
error_message: str | None,
metadata: dict[str, Any] | None,
request_headers: dict[str, Any] | None,
request_body: Any | None,
provider_request_headers: dict[str, Any] | None,
response_headers: dict[str, Any] | None,
client_response_headers: dict[str, Any] | None,
response_body: Any | None,
request_id: str,
provider_id: str | None,
provider_endpoint_id: str | None,
provider_api_key_id: str | None,
status: str,
target_model: str | None,
cost: UsageCostInfo,
) -> dict[str, Any]:
"""构建 Usage 记录的参数字典(内部方法,避免代码重复)"""
# 展开成本信息
input_cost = cost.input_cost
output_cost = cost.output_cost
cache_creation_cost = cost.cache_creation_cost
cache_read_cost = cost.cache_read_cost
cache_cost = cost.cache_cost
request_cost = cost.request_cost
total_cost = cost.total_cost
input_price = cost.input_price
output_price = cost.output_price
cache_creation_price = cost.cache_creation_price
cache_read_price = cost.cache_read_price
request_price = cost.request_price
actual_rate_multiplier = cost.actual_rate_multiplier
is_free_tier = cost.is_free_tier
# 根据配置决定是否记录请求详情
should_log_headers = SystemConfigService.should_log_headers(db)
should_log_body = SystemConfigService.should_log_body(db)
# 处理请求头(可能需要脱敏)
processed_request_headers = None
if should_log_headers and request_headers:
processed_request_headers = SystemConfigService.mask_sensitive_headers(db, request_headers)
# 处理提供商请求头(可能需要脱敏)
processed_provider_request_headers = None
if should_log_headers and provider_request_headers:
processed_provider_request_headers = SystemConfigService.mask_sensitive_headers(
db, provider_request_headers
)
# 处理请求体和响应体(可能需要截断)
processed_request_body = None
processed_response_body = None
if should_log_body:
if request_body:
processed_request_body = SystemConfigService.truncate_body(
db, request_body, is_request=True
)
if response_body:
processed_response_body = SystemConfigService.truncate_body(
db, response_body, is_request=False
)
# 处理响应头
processed_response_headers = None
if should_log_headers and response_headers:
processed_response_headers = SystemConfigService.mask_sensitive_headers(
db, response_headers
)
# 处理返回给客户端的响应头
processed_client_response_headers = None
if should_log_headers and client_response_headers:
processed_client_response_headers = SystemConfigService.mask_sensitive_headers(
db, client_response_headers
)
# 计算真实成本(表面成本 * 倍率),免费套餐实际费用为 0
if is_free_tier:
actual_input_cost = 0.0
actual_output_cost = 0.0
actual_cache_creation_cost = 0.0
actual_cache_read_cost = 0.0
actual_request_cost = 0.0
actual_total_cost = 0.0
else:
actual_input_cost = input_cost * actual_rate_multiplier
actual_output_cost = output_cost * actual_rate_multiplier
actual_cache_creation_cost = cache_creation_cost * actual_rate_multiplier
actual_cache_read_cost = cache_read_cost * actual_rate_multiplier
actual_request_cost = request_cost * actual_rate_multiplier
actual_total_cost = total_cost * actual_rate_multiplier
error_category = None
if status_code >= 400 or error_message or status in {"failed", "cancelled"}:
error_category = classify_error(status_code, error_message, status).value
return {
"user_id": user.id if user else None,
"api_key_id": api_key.id if api_key else None,
"request_id": request_id,
"provider_name": provider,
"model": model,
"target_model": target_model,
"provider_id": provider_id,
"provider_endpoint_id": provider_endpoint_id,
"provider_api_key_id": provider_api_key_id,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
"cache_creation_input_tokens": cache_creation_input_tokens,
"cache_read_input_tokens": cache_read_input_tokens,
"input_cost_usd": input_cost,
"output_cost_usd": output_cost,
"cache_cost_usd": cache_cost,
"cache_creation_cost_usd": cache_creation_cost,
"cache_read_cost_usd": cache_read_cost,
"request_cost_usd": request_cost,
"total_cost_usd": total_cost,
"actual_input_cost_usd": actual_input_cost,
"actual_output_cost_usd": actual_output_cost,
"actual_cache_creation_cost_usd": actual_cache_creation_cost,
"actual_cache_read_cost_usd": actual_cache_read_cost,
"actual_request_cost_usd": actual_request_cost,
"actual_total_cost_usd": actual_total_cost,
"rate_multiplier": actual_rate_multiplier,
"input_price_per_1m": input_price,
"output_price_per_1m": output_price,
"cache_creation_price_per_1m": cache_creation_price,
"cache_read_price_per_1m": cache_read_price,
"price_per_request": request_price,
"request_type": request_type,
"api_format": api_format,
"endpoint_api_format": endpoint_api_format,
"has_format_conversion": has_format_conversion,
"is_stream": is_stream,
"status_code": status_code,
"error_message": error_message,
"error_category": error_category,
"response_time_ms": response_time_ms,
"first_byte_time_ms": first_byte_time_ms,
"status": status,
"request_metadata": metadata,
"request_headers": processed_request_headers,
"request_body": processed_request_body,
"provider_request_headers": processed_provider_request_headers,
"response_headers": processed_response_headers,
"client_response_headers": processed_client_response_headers,
"response_body": processed_response_body,
}
def update_existing_usage(
existing_usage: Usage,
usage_params: dict[str, Any],
target_model: str | None,
) -> None:
"""更新已存在的 Usage 记录(内部方法)"""
# 更新关键字段
existing_usage.provider_name = usage_params["provider_name"]
existing_usage.model = usage_params["model"]
existing_usage.request_type = usage_params["request_type"]
existing_usage.api_format = usage_params["api_format"]
existing_usage.endpoint_api_format = usage_params["endpoint_api_format"]
existing_usage.has_format_conversion = usage_params["has_format_conversion"]
existing_usage.is_stream = usage_params["is_stream"]
existing_usage.status = usage_params["status"]
existing_usage.status_code = usage_params["status_code"]
existing_usage.error_message = usage_params["error_message"]
existing_usage.error_category = usage_params.get("error_category")
existing_usage.response_time_ms = usage_params["response_time_ms"]
existing_usage.first_byte_time_ms = usage_params["first_byte_time_ms"]
# 更新请求头和请求体(如果有新值)
if usage_params["request_headers"] is not None:
existing_usage.request_headers = usage_params["request_headers"]
if usage_params["request_body"] is not None:
existing_usage.request_body = usage_params["request_body"]
if usage_params["provider_request_headers"] is not None:
existing_usage.provider_request_headers = usage_params["provider_request_headers"]
existing_usage.response_body = usage_params["response_body"]
existing_usage.response_headers = usage_params["response_headers"]
existing_usage.client_response_headers = usage_params["client_response_headers"]
# 更新 token 和费用信息
existing_usage.input_tokens = usage_params["input_tokens"]
existing_usage.output_tokens = usage_params["output_tokens"]
existing_usage.total_tokens = usage_params["total_tokens"]
existing_usage.cache_creation_input_tokens = usage_params["cache_creation_input_tokens"]
existing_usage.cache_read_input_tokens = usage_params["cache_read_input_tokens"]
existing_usage.input_cost_usd = usage_params["input_cost_usd"]
existing_usage.output_cost_usd = usage_params["output_cost_usd"]
existing_usage.cache_cost_usd = usage_params["cache_cost_usd"]
existing_usage.cache_creation_cost_usd = usage_params["cache_creation_cost_usd"]
existing_usage.cache_read_cost_usd = usage_params["cache_read_cost_usd"]
existing_usage.request_cost_usd = usage_params["request_cost_usd"]
existing_usage.total_cost_usd = usage_params["total_cost_usd"]
existing_usage.actual_input_cost_usd = usage_params["actual_input_cost_usd"]
existing_usage.actual_output_cost_usd = usage_params["actual_output_cost_usd"]
existing_usage.actual_cache_creation_cost_usd = usage_params["actual_cache_creation_cost_usd"]
existing_usage.actual_cache_read_cost_usd = usage_params["actual_cache_read_cost_usd"]
existing_usage.actual_request_cost_usd = usage_params["actual_request_cost_usd"]
existing_usage.actual_total_cost_usd = usage_params["actual_total_cost_usd"]
existing_usage.rate_multiplier = usage_params["rate_multiplier"]
# 更新 Provider 侧追踪信息
existing_usage.provider_id = usage_params["provider_id"]
existing_usage.provider_endpoint_id = usage_params["provider_endpoint_id"]
existing_usage.provider_api_key_id = usage_params["provider_api_key_id"]
# 更新元数据(如 billing_snapshot/dimensions 等)
if usage_params.get("request_metadata") is not None:
existing_usage.request_metadata = usage_params["request_metadata"]
# 更新模型映射信息
if target_model is not None:
existing_usage.target_model = target_model
def sanitize_request_metadata(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 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 METADATA_KEEP_KEYS if k in metadata}
return reduced

View File

@@ -1,217 +1,39 @@
from __future__ import annotations from __future__ import annotations
import json
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.core.api_format.signature import normalize_signature_key
from src.core.logger import logger from src.core.logger import logger
from src.models.database import ApiKey, Provider, Usage, User from src.models.database import ApiKey, Provider, Usage, User
from src.services.billing.token_normalization import normalize_input_tokens_for_billing from src.services.usage._billing_integration import UsageBillingIntegrationMixin
from src.services.system.config import SystemConfigService from src.services.usage._recording_helpers import (
METADATA_KEEP_KEYS,
METADATA_PRUNE_KEYS,
build_usage_params,
sanitize_request_metadata,
update_existing_usage,
)
from src.services.usage._types import UsageCostInfo, UsageRecordParams from src.services.usage._types import UsageCostInfo, UsageRecordParams
from src.services.usage.error_classifier import classify_error
class UsageRecordingMixin: class UsageRecordingMixin(UsageBillingIntegrationMixin):
"""记录用量相关方法""" """记录用量相关方法"""
# Metadata pruning configuration (ordered by priority - drop first to last) # Metadata pruning configuration -- re-export from helpers for backward compatibility
_METADATA_PRUNE_KEYS: tuple[str, ...] = ( _METADATA_PRUNE_KEYS: tuple[str, ...] = METADATA_PRUNE_KEYS
"raw_response_ref", _METADATA_KEEP_KEYS: frozenset[str] = METADATA_KEEP_KEYS
"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( # Backward-compatible thin wrappers
{ # ------------------------------------------------------------------
"billing_snapshot",
"billing_updated_at",
"perf",
"_metadata_truncated",
}
)
@staticmethod @staticmethod
def _build_usage_params( def _build_usage_params(**kwargs: Any) -> dict[str, Any]:
*, """构建 Usage 记录的参数字典(委托到模块级函数)"""
db: Session, return build_usage_params(**kwargs)
user: User | None,
api_key: ApiKey | None,
provider: str,
model: str,
input_tokens: int,
output_tokens: int,
cache_creation_input_tokens: int,
cache_read_input_tokens: int,
request_type: str,
api_format: str | None,
endpoint_api_format: str | None,
has_format_conversion: bool,
is_stream: bool,
response_time_ms: int | None,
first_byte_time_ms: int | None,
status_code: int,
error_message: str | None,
metadata: dict[str, Any] | None,
request_headers: dict[str, Any] | None,
request_body: Any | None,
provider_request_headers: dict[str, Any] | None,
response_headers: dict[str, Any] | None,
client_response_headers: dict[str, Any] | None,
response_body: Any | None,
request_id: str,
provider_id: str | None,
provider_endpoint_id: str | None,
provider_api_key_id: str | None,
status: str,
target_model: str | None,
cost: UsageCostInfo,
) -> dict[str, Any]:
"""构建 Usage 记录的参数字典(内部方法,避免代码重复)"""
# 展开成本信息
input_cost = cost.input_cost
output_cost = cost.output_cost
cache_creation_cost = cost.cache_creation_cost
cache_read_cost = cost.cache_read_cost
cache_cost = cost.cache_cost
request_cost = cost.request_cost
total_cost = cost.total_cost
input_price = cost.input_price
output_price = cost.output_price
cache_creation_price = cost.cache_creation_price
cache_read_price = cost.cache_read_price
request_price = cost.request_price
actual_rate_multiplier = cost.actual_rate_multiplier
is_free_tier = cost.is_free_tier
# 根据配置决定是否记录请求详情
should_log_headers = SystemConfigService.should_log_headers(db)
should_log_body = SystemConfigService.should_log_body(db)
# 处理请求头(可能需要脱敏)
processed_request_headers = None
if should_log_headers and request_headers:
processed_request_headers = SystemConfigService.mask_sensitive_headers(
db, request_headers
)
# 处理提供商请求头(可能需要脱敏)
processed_provider_request_headers = None
if should_log_headers and provider_request_headers:
processed_provider_request_headers = SystemConfigService.mask_sensitive_headers(
db, provider_request_headers
)
# 处理请求体和响应体(可能需要截断)
processed_request_body = None
processed_response_body = None
if should_log_body:
if request_body:
processed_request_body = SystemConfigService.truncate_body(
db, request_body, is_request=True
)
if response_body:
processed_response_body = SystemConfigService.truncate_body(
db, response_body, is_request=False
)
# 处理响应头
processed_response_headers = None
if should_log_headers and response_headers:
processed_response_headers = SystemConfigService.mask_sensitive_headers(
db, response_headers
)
# 处理返回给客户端的响应头
processed_client_response_headers = None
if should_log_headers and client_response_headers:
processed_client_response_headers = SystemConfigService.mask_sensitive_headers(
db, client_response_headers
)
# 计算真实成本(表面成本 * 倍率),免费套餐实际费用为 0
if is_free_tier:
actual_input_cost = 0.0
actual_output_cost = 0.0
actual_cache_creation_cost = 0.0
actual_cache_read_cost = 0.0
actual_request_cost = 0.0
actual_total_cost = 0.0
else:
actual_input_cost = input_cost * actual_rate_multiplier
actual_output_cost = output_cost * actual_rate_multiplier
actual_cache_creation_cost = cache_creation_cost * actual_rate_multiplier
actual_cache_read_cost = cache_read_cost * actual_rate_multiplier
actual_request_cost = request_cost * actual_rate_multiplier
actual_total_cost = total_cost * actual_rate_multiplier
error_category = None
if status_code >= 400 or error_message or status in {"failed", "cancelled"}:
error_category = classify_error(status_code, error_message, status).value
return {
"user_id": user.id if user else None,
"api_key_id": api_key.id if api_key else None,
"request_id": request_id,
"provider_name": provider,
"model": model,
"target_model": target_model,
"provider_id": provider_id,
"provider_endpoint_id": provider_endpoint_id,
"provider_api_key_id": provider_api_key_id,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
"cache_creation_input_tokens": cache_creation_input_tokens,
"cache_read_input_tokens": cache_read_input_tokens,
"input_cost_usd": input_cost,
"output_cost_usd": output_cost,
"cache_cost_usd": cache_cost,
"cache_creation_cost_usd": cache_creation_cost,
"cache_read_cost_usd": cache_read_cost,
"request_cost_usd": request_cost,
"total_cost_usd": total_cost,
"actual_input_cost_usd": actual_input_cost,
"actual_output_cost_usd": actual_output_cost,
"actual_cache_creation_cost_usd": actual_cache_creation_cost,
"actual_cache_read_cost_usd": actual_cache_read_cost,
"actual_request_cost_usd": actual_request_cost,
"actual_total_cost_usd": actual_total_cost,
"rate_multiplier": actual_rate_multiplier,
"input_price_per_1m": input_price,
"output_price_per_1m": output_price,
"cache_creation_price_per_1m": cache_creation_price,
"cache_read_price_per_1m": cache_read_price,
"price_per_request": request_price,
"request_type": request_type,
"api_format": api_format,
"endpoint_api_format": endpoint_api_format,
"has_format_conversion": has_format_conversion,
"is_stream": is_stream,
"status_code": status_code,
"error_message": error_message,
"error_category": error_category,
"response_time_ms": response_time_ms,
"first_byte_time_ms": first_byte_time_ms,
"status": status,
"request_metadata": metadata,
"request_headers": processed_request_headers,
"request_body": processed_request_body,
"provider_request_headers": processed_provider_request_headers,
"response_headers": processed_response_headers,
"client_response_headers": processed_client_response_headers,
"response_body": processed_response_body,
}
@staticmethod @staticmethod
def _update_existing_usage( def _update_existing_usage(
@@ -219,309 +41,17 @@ class UsageRecordingMixin:
usage_params: dict[str, Any], usage_params: dict[str, Any],
target_model: str | None, target_model: str | None,
) -> None: ) -> None:
"""更新已存在的 Usage 记录(内部方法""" """更新已存在的 Usage 记录(委托到模块级函数"""
# 更新关键字段 update_existing_usage(existing_usage, usage_params, target_model)
existing_usage.provider_name = usage_params["provider_name"]
existing_usage.model = usage_params["model"]
existing_usage.request_type = usage_params["request_type"]
existing_usage.api_format = usage_params["api_format"]
existing_usage.endpoint_api_format = usage_params["endpoint_api_format"]
existing_usage.has_format_conversion = usage_params["has_format_conversion"]
existing_usage.is_stream = usage_params["is_stream"]
existing_usage.status = usage_params["status"]
existing_usage.status_code = usage_params["status_code"]
existing_usage.error_message = usage_params["error_message"]
existing_usage.error_category = usage_params.get("error_category")
existing_usage.response_time_ms = usage_params["response_time_ms"]
existing_usage.first_byte_time_ms = usage_params["first_byte_time_ms"]
# 更新请求头和请求体(如果有新值)
if usage_params["request_headers"] is not None:
existing_usage.request_headers = usage_params["request_headers"]
if usage_params["request_body"] is not None:
existing_usage.request_body = usage_params["request_body"]
if usage_params["provider_request_headers"] is not None:
existing_usage.provider_request_headers = usage_params["provider_request_headers"]
existing_usage.response_body = usage_params["response_body"]
existing_usage.response_headers = usage_params["response_headers"]
existing_usage.client_response_headers = usage_params["client_response_headers"]
# 更新 token 和费用信息
existing_usage.input_tokens = usage_params["input_tokens"]
existing_usage.output_tokens = usage_params["output_tokens"]
existing_usage.total_tokens = usage_params["total_tokens"]
existing_usage.cache_creation_input_tokens = usage_params["cache_creation_input_tokens"]
existing_usage.cache_read_input_tokens = usage_params["cache_read_input_tokens"]
existing_usage.input_cost_usd = usage_params["input_cost_usd"]
existing_usage.output_cost_usd = usage_params["output_cost_usd"]
existing_usage.cache_cost_usd = usage_params["cache_cost_usd"]
existing_usage.cache_creation_cost_usd = usage_params["cache_creation_cost_usd"]
existing_usage.cache_read_cost_usd = usage_params["cache_read_cost_usd"]
existing_usage.request_cost_usd = usage_params["request_cost_usd"]
existing_usage.total_cost_usd = usage_params["total_cost_usd"]
existing_usage.actual_input_cost_usd = usage_params["actual_input_cost_usd"]
existing_usage.actual_output_cost_usd = usage_params["actual_output_cost_usd"]
existing_usage.actual_cache_creation_cost_usd = usage_params[
"actual_cache_creation_cost_usd"
]
existing_usage.actual_cache_read_cost_usd = usage_params["actual_cache_read_cost_usd"]
existing_usage.actual_request_cost_usd = usage_params["actual_request_cost_usd"]
existing_usage.actual_total_cost_usd = usage_params["actual_total_cost_usd"]
existing_usage.rate_multiplier = usage_params["rate_multiplier"]
# 更新 Provider 侧追踪信息
existing_usage.provider_id = usage_params["provider_id"]
existing_usage.provider_endpoint_id = usage_params["provider_endpoint_id"]
existing_usage.provider_api_key_id = usage_params["provider_api_key_id"]
# 更新元数据(如 billing_snapshot/dimensions 等)
if usage_params.get("request_metadata") is not None:
existing_usage.request_metadata = usage_params["request_metadata"]
# 更新模型映射信息
if target_model is not None:
existing_usage.target_model = target_model
@classmethod @classmethod
def _sanitize_request_metadata(cls, metadata: dict[str, Any]) -> dict[str, Any]: def _sanitize_request_metadata(cls, metadata: dict[str, Any]) -> dict[str, Any]:
""" """元数据清理(委托到模块级函数)"""
Best-effort metadata pruning to reduce DB/CPU/memory pressure. return sanitize_request_metadata(metadata)
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). # Recording methods
""" # ------------------------------------------------------------------
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
async def _prepare_usage_record(
cls,
params: UsageRecordParams,
) -> tuple[dict[str, Any], float]:
"""准备用量记录的共享逻辑
此方法提取了 record_usage 和 record_usage_async 的公共处理逻辑:
- 获取费率倍数
- 计算成本
- 构建 Usage 参数
Args:
params: 用量记录参数数据类
Returns:
(usage_params 字典, total_cost 总成本)
"""
# 计费口径以 Provider 为准(优先 endpoint_api_format
billing_api_format: str | None = None
if params.endpoint_api_format:
try:
billing_api_format = normalize_signature_key(str(params.endpoint_api_format))
except Exception:
billing_api_format = None
if billing_api_format is None and params.api_format:
try:
billing_api_format = normalize_signature_key(str(params.api_format))
except Exception:
billing_api_format = None
input_tokens_for_billing = normalize_input_tokens_for_billing(
billing_api_format,
params.input_tokens,
params.cache_read_input_tokens,
)
# 获取费率倍数和是否免费套餐(传递 api_format 支持按格式配置的倍率)
actual_rate_multiplier, is_free_tier = await cls._get_rate_multiplier_and_free_tier(
params.db, params.provider_api_key_id, params.provider_id, billing_api_format
)
metadata = dict(params.metadata or {})
is_failed_request = params.status_code >= 400 or params.error_message is not None
# Helper: compute billing task_type (billing domain)
billing_task_type = (params.request_type or "").lower()
if billing_task_type not in {"chat", "cli", "video", "image", "audio"}:
billing_task_type = "chat"
# 使用新计费系统计算费用
from src.services.billing.service import BillingService
request_count = 0 if is_failed_request else 1
dims: dict[str, Any] = {
"input_tokens": input_tokens_for_billing,
"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 (pruned later by _sanitize_request_metadata)
metadata["billing_snapshot"] = snap.to_dict()
# Best-effort prune metadata to reduce DB/memory pressure.
metadata = cls._sanitize_request_metadata(metadata)
# 构建 Usage 参数
usage_params = cls._build_usage_params(
db=params.db,
user=params.user,
api_key=params.api_key,
provider=params.provider,
model=params.model,
input_tokens=input_tokens_for_billing,
output_tokens=params.output_tokens,
cache_creation_input_tokens=params.cache_creation_input_tokens,
cache_read_input_tokens=params.cache_read_input_tokens,
request_type=params.request_type,
api_format=params.api_format,
endpoint_api_format=params.endpoint_api_format,
has_format_conversion=params.has_format_conversion,
is_stream=params.is_stream,
response_time_ms=params.response_time_ms,
first_byte_time_ms=params.first_byte_time_ms,
status_code=params.status_code,
error_message=params.error_message,
metadata=metadata,
request_headers=params.request_headers,
request_body=params.request_body,
provider_request_headers=params.provider_request_headers,
response_headers=params.response_headers,
client_response_headers=params.client_response_headers,
response_body=params.response_body,
request_id=params.request_id,
provider_id=params.provider_id,
provider_endpoint_id=params.provider_endpoint_id,
provider_api_key_id=params.provider_api_key_id,
status=params.status,
target_model=params.target_model,
cost=UsageCostInfo(
input_cost=input_cost,
output_cost=output_cost,
cache_creation_cost=cache_creation_cost,
cache_read_cost=cache_read_cost,
cache_cost=cache_cost,
request_cost=request_cost,
total_cost=total_cost,
input_price=input_price,
output_price=output_price,
cache_creation_price=cache_creation_price,
cache_read_price=cache_read_price,
request_price=request_price,
actual_rate_multiplier=actual_rate_multiplier,
is_free_tier=is_free_tier,
),
)
return usage_params, total_cost
@classmethod
async def _prepare_usage_records_batch(
cls,
params_list: list[UsageRecordParams],
) -> list[tuple[dict[str, Any], float, Exception | None]]:
"""批量并行准备用量记录(性能优化)
并行调用 _prepare_usage_record提高批量处理效率。
Args:
params_list: 用量记录参数列表
Returns:
列表,每项为 (usage_params, total_cost, exception)
如果处理成功exception 为 None
"""
import asyncio
async def prepare_single(
params: UsageRecordParams,
) -> tuple[dict[str, Any], float, Exception | None]:
try:
usage_params, total_cost = await cls._prepare_usage_record(params)
return (usage_params, total_cost, None)
except Exception as e:
return ({}, 0.0, e)
if not params_list:
return []
# 避免一次性创建过多 task并且 _prepare_usage_record 内部也可能包含并行调用)
# 这里采用分批 gather 来限制并发量。
chunk_size = 50
results: list[tuple[dict[str, Any], float, Exception | None]] = []
for i in range(0, len(params_list), chunk_size):
chunk = params_list[i : i + chunk_size]
chunk_results = await asyncio.gather(*(prepare_single(p) for p in chunk))
results.extend(chunk_results)
return results
@classmethod @classmethod
async def record_usage_async( async def record_usage_async(
@@ -891,7 +421,7 @@ class UsageRecordingMixin:
) )
total_cost = float(total_cost_usd) total_cost = float(total_cost_usd)
usage_params = cls._build_usage_params( usage_params = build_usage_params(
db=db, db=db,
user=user, user=user,
api_key=api_key, api_key=api_key,

View File

@@ -69,7 +69,7 @@ async def test_list_all_candidates_returns_provider_batch_count_even_when_candid
global_model = _make_global_model(gid="gm1", name="gpt-4o") global_model = _make_global_model(gid="gm1", name="gpt-4o")
with patch.object(scheduler, "_ensure_initialized", new=AsyncMock(return_value=None)): with patch.object(scheduler, "_ensure_initialized", new=AsyncMock(return_value=None)):
with patch.object(scheduler, "_query_providers", return_value=providers): with patch.object(scheduler._candidate_builder, "_query_providers", return_value=providers):
with patch( with patch(
"src.services.cache.aware_scheduler.ModelCacheService.get_global_model_by_name", "src.services.cache.aware_scheduler.ModelCacheService.get_global_model_by_name",
new=AsyncMock(return_value=global_model), new=AsyncMock(return_value=global_model),
@@ -106,7 +106,7 @@ async def test_list_all_candidates_returns_zero_provider_batch_count_when_provid
global_model = _make_global_model(gid="gm1", name="gpt-4o") global_model = _make_global_model(gid="gm1", name="gpt-4o")
with patch.object(scheduler, "_ensure_initialized", new=AsyncMock(return_value=None)): with patch.object(scheduler, "_ensure_initialized", new=AsyncMock(return_value=None)):
with patch.object(scheduler, "_query_providers", return_value=[]): with patch.object(scheduler._candidate_builder, "_query_providers", return_value=[]):
with patch( with patch(
"src.services.cache.aware_scheduler.ModelCacheService.get_global_model_by_name", "src.services.cache.aware_scheduler.ModelCacheService.get_global_model_by_name",
new=AsyncMock(return_value=global_model), new=AsyncMock(return_value=global_model),

View File

@@ -22,7 +22,7 @@ class TestCheckModelSupportForGlobalModel:
"""测试 _check_model_support_for_global_model 方法""" """测试 _check_model_support_for_global_model 方法"""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_provider_without_model_should_return_false(self): async def test_provider_without_model_should_return_false(self) -> None:
"""Provider 没有配置对应的 Model 时应该返回 False""" """Provider 没有配置对应的 Model 时应该返回 False"""
scheduler = CacheAwareScheduler() scheduler = CacheAwareScheduler()
@@ -45,7 +45,7 @@ class TestCheckModelSupportForGlobalModel:
with patch("sqlalchemy.inspect", return_value=mock_inspect): with patch("sqlalchemy.inspect", return_value=mock_inspect):
is_supported, skip_reason, caps, provider_model_names = ( is_supported, skip_reason, caps, provider_model_names = (
await scheduler._check_model_support_for_global_model( await scheduler._candidate_builder._check_model_support_for_global_model(
db=db, db=db,
provider=provider, provider=provider,
global_model=global_model, global_model=global_model,
@@ -59,7 +59,7 @@ class TestCheckModelSupportForGlobalModel:
assert provider_model_names is None assert provider_model_names is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_provider_with_different_model_should_return_false(self): async def test_provider_with_different_model_should_return_false(self) -> None:
"""Provider 配置了其他模型但没有目标模型时应该返回 False""" """Provider 配置了其他模型但没有目标模型时应该返回 False"""
scheduler = CacheAwareScheduler() scheduler = CacheAwareScheduler()
@@ -93,7 +93,7 @@ class TestCheckModelSupportForGlobalModel:
with patch("sqlalchemy.inspect", return_value=mock_inspect): with patch("sqlalchemy.inspect", return_value=mock_inspect):
is_supported, skip_reason, caps, provider_model_names = ( is_supported, skip_reason, caps, provider_model_names = (
await scheduler._check_model_support_for_global_model( await scheduler._candidate_builder._check_model_support_for_global_model(
db=db, db=db,
provider=provider, provider=provider,
global_model=global_model, global_model=global_model,
@@ -105,7 +105,7 @@ class TestCheckModelSupportForGlobalModel:
assert skip_reason == "Provider 未实现此模型" assert skip_reason == "Provider 未实现此模型"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_provider_with_matching_model_should_return_true(self): async def test_provider_with_matching_model_should_return_true(self) -> None:
"""Provider 配置了目标模型时应该返回 True""" """Provider 配置了目标模型时应该返回 True"""
scheduler = CacheAwareScheduler() scheduler = CacheAwareScheduler()
@@ -136,7 +136,7 @@ class TestCheckModelSupportForGlobalModel:
with patch("sqlalchemy.inspect", return_value=mock_inspect): with patch("sqlalchemy.inspect", return_value=mock_inspect):
is_supported, skip_reason, caps, provider_model_names = ( is_supported, skip_reason, caps, provider_model_names = (
await scheduler._check_model_support_for_global_model( await scheduler._candidate_builder._check_model_support_for_global_model(
db=db, db=db,
provider=provider, provider=provider,
global_model=global_model, global_model=global_model,
@@ -150,7 +150,7 @@ class TestCheckModelSupportForGlobalModel:
assert provider_model_names == {"claude-3-haiku-20240307"} assert provider_model_names == {"claude-3-haiku-20240307"}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_provider_with_inactive_model_should_return_false(self): async def test_provider_with_inactive_model_should_return_false(self) -> None:
"""Provider 的模型未激活时应该返回 False""" """Provider 的模型未激活时应该返回 False"""
scheduler = CacheAwareScheduler() scheduler = CacheAwareScheduler()
@@ -179,7 +179,7 @@ class TestCheckModelSupportForGlobalModel:
with patch("sqlalchemy.inspect", return_value=mock_inspect): with patch("sqlalchemy.inspect", return_value=mock_inspect):
is_supported, skip_reason, caps, provider_model_names = ( is_supported, skip_reason, caps, provider_model_names = (
await scheduler._check_model_support_for_global_model( await scheduler._candidate_builder._check_model_support_for_global_model(
db=db, db=db,
provider=provider, provider=provider,
global_model=global_model, global_model=global_model,

View File

@@ -20,14 +20,14 @@ def _make_key(
@patch( @patch(
"src.services.cache.aware_scheduler.health_monitor.get_circuit_breaker_status", "src.services.cache._candidate_builder.health_monitor.get_circuit_breaker_status",
return_value=(True, None), return_value=(True, None),
) )
def test_kiro_quota_remaining_zero_skips(_mock_cb: MagicMock) -> None: def test_kiro_quota_remaining_zero_skips(_mock_cb: MagicMock) -> None:
scheduler = CacheAwareScheduler() scheduler = CacheAwareScheduler()
key = _make_key(upstream_metadata={"kiro": {"remaining": 0.0}}) key = _make_key(upstream_metadata={"kiro": {"remaining": 0.0}})
ok, reason, _mapped = scheduler._check_key_availability( ok, reason, _mapped = scheduler._candidate_builder._check_key_availability(
key, key,
api_format="openai:chat", api_format="openai:chat",
model_name="any-model", model_name="any-model",
@@ -39,14 +39,14 @@ def test_kiro_quota_remaining_zero_skips(_mock_cb: MagicMock) -> None:
@patch( @patch(
"src.services.cache.aware_scheduler.health_monitor.get_circuit_breaker_status", "src.services.cache._candidate_builder.health_monitor.get_circuit_breaker_status",
return_value=(True, None), return_value=(True, None),
) )
def test_kiro_quota_remaining_positive_allows(_mock_cb: MagicMock) -> None: def test_kiro_quota_remaining_positive_allows(_mock_cb: MagicMock) -> None:
scheduler = CacheAwareScheduler() scheduler = CacheAwareScheduler()
key = _make_key(upstream_metadata={"kiro": {"remaining": 1.0}}) key = _make_key(upstream_metadata={"kiro": {"remaining": 1.0}})
ok, reason, _mapped = scheduler._check_key_availability( ok, reason, _mapped = scheduler._candidate_builder._check_key_availability(
key, key,
api_format="openai:chat", api_format="openai:chat",
model_name="any-model", model_name="any-model",
@@ -58,7 +58,7 @@ def test_kiro_quota_remaining_positive_allows(_mock_cb: MagicMock) -> None:
@patch( @patch(
"src.services.cache.aware_scheduler.health_monitor.get_circuit_breaker_status", "src.services.cache._candidate_builder.health_monitor.get_circuit_breaker_status",
return_value=(True, None), return_value=(True, None),
) )
def test_codex_weekly_quota_exhausted_skips(_mock_cb: MagicMock) -> None: def test_codex_weekly_quota_exhausted_skips(_mock_cb: MagicMock) -> None:
@@ -73,7 +73,7 @@ def test_codex_weekly_quota_exhausted_skips(_mock_cb: MagicMock) -> None:
} }
) )
ok, reason, _mapped = scheduler._check_key_availability( ok, reason, _mapped = scheduler._candidate_builder._check_key_availability(
key, key,
api_format="openai:cli", api_format="openai:cli",
model_name="any-model", model_name="any-model",
@@ -85,7 +85,7 @@ def test_codex_weekly_quota_exhausted_skips(_mock_cb: MagicMock) -> None:
@patch( @patch(
"src.services.cache.aware_scheduler.health_monitor.get_circuit_breaker_status", "src.services.cache._candidate_builder.health_monitor.get_circuit_breaker_status",
return_value=(True, None), return_value=(True, None),
) )
def test_codex_5h_quota_exhausted_skips(_mock_cb: MagicMock) -> None: def test_codex_5h_quota_exhausted_skips(_mock_cb: MagicMock) -> None:
@@ -99,7 +99,7 @@ def test_codex_5h_quota_exhausted_skips(_mock_cb: MagicMock) -> None:
} }
) )
ok, reason, _mapped = scheduler._check_key_availability( ok, reason, _mapped = scheduler._candidate_builder._check_key_availability(
key, key,
api_format="openai:cli", api_format="openai:cli",
model_name="any-model", model_name="any-model",
@@ -111,7 +111,7 @@ def test_codex_5h_quota_exhausted_skips(_mock_cb: MagicMock) -> None:
@patch( @patch(
"src.services.cache.aware_scheduler.health_monitor.get_circuit_breaker_status", "src.services.cache._candidate_builder.health_monitor.get_circuit_breaker_status",
return_value=(True, None), return_value=(True, None),
) )
def test_codex_ignores_code_review_quota(_mock_cb: MagicMock) -> None: def test_codex_ignores_code_review_quota(_mock_cb: MagicMock) -> None:
@@ -126,7 +126,7 @@ def test_codex_ignores_code_review_quota(_mock_cb: MagicMock) -> None:
} }
) )
ok, reason, _mapped = scheduler._check_key_availability( ok, reason, _mapped = scheduler._candidate_builder._check_key_availability(
key, key,
api_format="openai:cli", api_format="openai:cli",
model_name="any-model", model_name="any-model",
@@ -138,7 +138,7 @@ def test_codex_ignores_code_review_quota(_mock_cb: MagicMock) -> None:
@patch( @patch(
"src.services.cache.aware_scheduler.health_monitor.get_circuit_breaker_status", "src.services.cache._candidate_builder.health_monitor.get_circuit_breaker_status",
return_value=(True, None), return_value=(True, None),
) )
def test_antigravity_model_quota_exhausted_skips(_mock_cb: MagicMock) -> None: def test_antigravity_model_quota_exhausted_skips(_mock_cb: MagicMock) -> None:
@@ -154,7 +154,7 @@ def test_antigravity_model_quota_exhausted_skips(_mock_cb: MagicMock) -> None:
}, },
) )
ok, reason, _mapped = scheduler._check_key_availability( ok, reason, _mapped = scheduler._candidate_builder._check_key_availability(
key, key,
api_format="gemini:chat", api_format="gemini:chat",
model_name="ag-model", model_name="ag-model",
@@ -166,7 +166,7 @@ def test_antigravity_model_quota_exhausted_skips(_mock_cb: MagicMock) -> None:
@patch( @patch(
"src.services.cache.aware_scheduler.health_monitor.get_circuit_breaker_status", "src.services.cache._candidate_builder.health_monitor.get_circuit_breaker_status",
return_value=(True, None), return_value=(True, None),
) )
def test_antigravity_other_model_not_exhausted_allows(_mock_cb: MagicMock) -> None: def test_antigravity_other_model_not_exhausted_allows(_mock_cb: MagicMock) -> None:
@@ -182,7 +182,7 @@ def test_antigravity_other_model_not_exhausted_allows(_mock_cb: MagicMock) -> No
}, },
) )
ok, reason, _mapped = scheduler._check_key_availability( ok, reason, _mapped = scheduler._candidate_builder._check_key_availability(
key, key,
api_format="gemini:chat", api_format="gemini:chat",
model_name="other", model_name="other",
@@ -194,7 +194,7 @@ def test_antigravity_other_model_not_exhausted_allows(_mock_cb: MagicMock) -> No
@patch( @patch(
"src.services.cache.aware_scheduler.health_monitor.get_circuit_breaker_status", "src.services.cache._candidate_builder.health_monitor.get_circuit_breaker_status",
return_value=(True, None), return_value=(True, None),
) )
def test_antigravity_quota_uses_mapping_matched_model(_mock_cb: MagicMock) -> None: def test_antigravity_quota_uses_mapping_matched_model(_mock_cb: MagicMock) -> None:
@@ -212,7 +212,7 @@ def test_antigravity_quota_uses_mapping_matched_model(_mock_cb: MagicMock) -> No
allowed_models=["ag-model"], allowed_models=["ag-model"],
) )
ok, reason, mapped = scheduler._check_key_availability( ok, reason, mapped = scheduler._candidate_builder._check_key_availability(
key, key,
api_format="gemini:chat", api_format="gemini:chat",
model_name="global-model", model_name="global-model",

View File

@@ -38,8 +38,9 @@ async def test_build_candidates_allows_cross_format_when_endpoint_accepts_and_ov
register_default_normalizers() register_default_normalizers()
scheduler = CacheAwareScheduler() scheduler = CacheAwareScheduler()
scheduler._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[method-assign] builder = scheduler._candidate_builder
scheduler._check_key_availability = MagicMock(return_value=(True, None, None)) # type: ignore[method-assign] builder._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[method-assign]
builder._check_key_availability = MagicMock(return_value=(True, None, None)) # type: ignore[method-assign]
provider = MagicMock() provider = MagicMock()
provider.name = "p1" provider.name = "p1"
@@ -52,7 +53,7 @@ async def test_build_candidates_allows_cross_format_when_endpoint_accepts_and_ov
] ]
provider.api_keys = [_mock_key("k1", ["openai:chat"])] provider.api_keys = [_mock_key("k1", ["openai:chat"])]
candidates = await scheduler._build_candidates( candidates = await builder._build_candidates(
db=MagicMock(), db=MagicMock(),
providers=[provider], providers=[provider],
client_format="claude:chat", client_format="claude:chat",
@@ -75,8 +76,9 @@ async def test_build_candidates_allows_cross_format_when_global_off_but_endpoint
register_default_normalizers() register_default_normalizers()
scheduler = CacheAwareScheduler() scheduler = CacheAwareScheduler()
scheduler._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[method-assign] builder = scheduler._candidate_builder
scheduler._check_key_availability = MagicMock(return_value=(True, None, None)) # type: ignore[method-assign] builder._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[method-assign]
builder._check_key_availability = MagicMock(return_value=(True, None, None)) # type: ignore[method-assign]
provider = MagicMock() provider = MagicMock()
provider.name = "p1" provider.name = "p1"
@@ -89,7 +91,7 @@ async def test_build_candidates_allows_cross_format_when_global_off_but_endpoint
] ]
provider.api_keys = [_mock_key("k1", ["openai:chat"])] provider.api_keys = [_mock_key("k1", ["openai:chat"])]
candidates = await scheduler._build_candidates( candidates = await builder._build_candidates(
db=MagicMock(), db=MagicMock(),
providers=[provider], providers=[provider],
client_format="claude:chat", client_format="claude:chat",
@@ -114,8 +116,9 @@ async def test_build_candidates_blocks_cross_format_when_global_off_and_endpoint
register_default_normalizers() register_default_normalizers()
scheduler = CacheAwareScheduler() scheduler = CacheAwareScheduler()
scheduler._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[method-assign] builder = scheduler._candidate_builder
scheduler._check_key_availability = MagicMock(return_value=(True, None, None)) # type: ignore[method-assign] builder._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[method-assign]
builder._check_key_availability = MagicMock(return_value=(True, None, None)) # type: ignore[method-assign]
provider = MagicMock() provider = MagicMock()
provider.name = "p1" provider.name = "p1"
@@ -123,7 +126,7 @@ async def test_build_candidates_blocks_cross_format_when_global_off_and_endpoint
provider.endpoints = [_mock_endpoint("openai:chat", None)] # 端点未配置格式接受策略 provider.endpoints = [_mock_endpoint("openai:chat", None)] # 端点未配置格式接受策略
provider.api_keys = [_mock_key("k1", ["openai:chat"])] provider.api_keys = [_mock_key("k1", ["openai:chat"])]
candidates = await scheduler._build_candidates( candidates = await builder._build_candidates(
db=MagicMock(), db=MagicMock(),
providers=[provider], providers=[provider],
client_format="claude:chat", client_format="claude:chat",
@@ -141,8 +144,9 @@ async def test_build_candidates_includes_cross_format_when_enabled() -> None:
register_default_normalizers() register_default_normalizers()
scheduler = CacheAwareScheduler() scheduler = CacheAwareScheduler()
scheduler._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[method-assign] builder = scheduler._candidate_builder
scheduler._check_key_availability = MagicMock(return_value=(True, None, None)) # type: ignore[method-assign] builder._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[method-assign]
builder._check_key_availability = MagicMock(return_value=(True, None, None)) # type: ignore[method-assign]
provider = MagicMock() provider = MagicMock()
provider.name = "p1" provider.name = "p1"
@@ -153,7 +157,7 @@ async def test_build_candidates_includes_cross_format_when_enabled() -> None:
] ]
provider.api_keys = [_mock_key("k1", ["openai:chat"])] provider.api_keys = [_mock_key("k1", ["openai:chat"])]
candidates = await scheduler._build_candidates( candidates = await builder._build_candidates(
db=MagicMock(), db=MagicMock(),
providers=[provider], providers=[provider],
client_format="claude:chat", client_format="claude:chat",
@@ -172,8 +176,9 @@ async def test_exact_matches_rank_before_convertible() -> None:
register_default_normalizers() register_default_normalizers()
scheduler = CacheAwareScheduler() scheduler = CacheAwareScheduler()
scheduler._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[method-assign] builder = scheduler._candidate_builder
scheduler._check_key_availability = MagicMock(return_value=(True, None, None)) # type: ignore[method-assign] builder._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[method-assign]
builder._check_key_availability = MagicMock(return_value=(True, None, None)) # type: ignore[method-assign]
provider = MagicMock() provider = MagicMock()
provider.name = "p1" provider.name = "p1"
@@ -191,7 +196,7 @@ async def test_exact_matches_rank_before_convertible() -> None:
_mock_key("k_claude", ["claude:chat"]), _mock_key("k_claude", ["claude:chat"]),
] ]
candidates = await scheduler._build_candidates( candidates = await builder._build_candidates(
db=MagicMock(), db=MagicMock(),
providers=[provider], providers=[provider],
client_format="claude:chat", client_format="claude:chat",