mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
288
frontend/src/features/providers/components/ProviderTableRow.vue
Normal file
288
frontend/src/features/providers/components/ProviderTableRow.vue
Normal 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>
|
||||||
@@ -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)}%`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 状态的 provider,3秒后自动重试
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
48
frontend/src/router/guards/adminGuard.ts
Normal file
48
frontend/src/router/guards/adminGuard.ts
Normal 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
|
||||||
|
}
|
||||||
37
frontend/src/router/guards/authGuard.ts
Normal file
37
frontend/src/router/guards/authGuard.ts
Normal 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
|
||||||
|
}
|
||||||
37
frontend/src/router/guards/homeGuard.ts
Normal file
37
frontend/src/router/guards/homeGuard.ts
Normal 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'
|
||||||
|
}
|
||||||
4
frontend/src/router/guards/index.ts
Normal file
4
frontend/src/router/guards/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { ensureUserLoaded } from './authGuard'
|
||||||
|
export { resolveHomeRedirect } from './homeGuard'
|
||||||
|
export { checkAdminAccess } from './adminGuard'
|
||||||
|
export { checkModuleAccess } from './moduleGuard'
|
||||||
39
frontend/src/router/guards/moduleGuard.ts
Normal file
39
frontend/src/router/guards/moduleGuard.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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
148
frontend/src/views/admin/system-settings/BasicConfigSection.vue
Normal file
148
frontend/src/views/admin/system-settings/BasicConfigSection.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
228
frontend/src/views/admin/system-settings/ConfigImportDialog.vue
Normal file
228
frontend/src/views/admin/system-settings/ConfigImportDialog.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
139
frontend/src/views/admin/system-settings/RequestLogSection.vue
Normal file
139
frontend/src/views/admin/system-settings/RequestLogSection.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
76
frontend/src/views/admin/system-settings/SiteInfoSection.vue
Normal file
76
frontend/src/views/admin/system-settings/SiteInfoSection.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
67
frontend/src/views/admin/system-settings/UserDataSection.vue
Normal file
67
frontend/src/views/admin/system-settings/UserDataSection.vue
Normal 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>
|
||||||
183
frontend/src/views/admin/system-settings/UsersImportDialog.vue
Normal file
183
frontend/src/views/admin/system-settings/UsersImportDialog.vue
Normal 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>
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/api/handlers/base/chat_error_utils.py
Normal file
150
src/api/handlers/base/chat_error_utils.py
Normal 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)
|
||||||
@@ -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}"
|
|
||||||
|
|||||||
729
src/api/handlers/base/chat_sync_executor.py
Normal file
729
src/api/handlers/base/chat_sync_executor.py
Normal 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}"
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
252
src/api/handlers/base/cli_protocol.py
Normal file
252
src/api/handlers/base/cli_protocol.py
Normal 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: ...
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
591
src/services/cache/_candidate_builder.py
vendored
Normal 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 signature(family: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
268
src/services/cache/_candidate_sorter.py
vendored
Normal 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
|
||||||
829
src/services/cache/aware_scheduler.py
vendored
829
src/services/cache/aware_scheduler.py
vendored
@@ -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 signature(family: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,
|
||||||
|
|||||||
216
src/services/usage/_billing_integration.py
Normal file
216
src/services/usage/_billing_integration.py
Normal 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
|
||||||
310
src/services/usage/_recording_helpers.py
Normal file
310
src/services/usage/_recording_helpers.py
Normal 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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user