feat: 实现跨 API 格式自动转换功能

- 新增端点级 format_acceptance_config 配置,控制是否接受跨格式请求
- 重构 EndpointFormDialog 为卡片式布局,支持内联编辑和格式转换开关
- StreamProcessor 实现流式响应的跨格式转换,支持 OpenAI/Claude/Gemini 互转
- CacheAwareScheduler 按端点格式筛选候选,同格式优先于跨格式
- 健康度/熔断按 Provider 端点格式分桶,而非客户端请求格式
- 新增 format_conversion_total 和 format_conversion_duration_seconds 指标
- 新增全局配置 format_conversion_enabled 控制总开关
- Input 组件新增 size="sm" 尺寸选项
This commit is contained in:
fawney19
2026-01-22 01:48:56 +08:00
parent 99388bfa33
commit cc5db20c58
25 changed files with 1818 additions and 547 deletions

View File

@@ -0,0 +1,51 @@
"""add_format_acceptance_config_to_provider_endpoints
Revision ID: 4b4c7b0df1a2
Revises: c868729753ad
Create Date: 2026-01-21 18:45:00+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = "4b4c7b0df1a2"
down_revision = "c868729753ad"
branch_labels = None
depends_on = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col["name"] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
if not table_exists("provider_endpoints"):
return
if column_exists("provider_endpoints", "format_acceptance_config"):
return
op.add_column(
"provider_endpoints",
sa.Column("format_acceptance_config", sa.JSON(), nullable=True),
)
def downgrade() -> None:
if not table_exists("provider_endpoints"):
return
if not column_exists("provider_endpoints", "format_acceptance_config"):
return
op.drop_column("provider_endpoints", "format_acceptance_config")

View File

@@ -1,5 +1,5 @@
import client from '../client' import client from '../client'
import type { ProviderEndpoint, ProxyConfig, HeaderRule } from './types' import type { ProviderEndpoint, ProxyConfig, HeaderRule, FormatAcceptanceConfig } from './types'
/** /**
* 获取指定 Provider 的所有 Endpoints * 获取指定 Provider 的所有 Endpoints
@@ -32,6 +32,7 @@ export async function createEndpoint(
is_active?: boolean is_active?: boolean
config?: Record<string, any> config?: Record<string, any>
proxy?: ProxyConfig | null proxy?: ProxyConfig | null
format_acceptance_config?: FormatAcceptanceConfig | null
} }
): Promise<ProviderEndpoint> { ): Promise<ProviderEndpoint> {
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/endpoints`, data) const response = await client.post(`/api/admin/endpoints/providers/${providerId}/endpoints`, data)
@@ -51,6 +52,7 @@ export async function updateEndpoint(
is_active: boolean is_active: boolean
config: Record<string, any> config: Record<string, any>
proxy: ProxyConfig | null proxy: ProxyConfig | null
format_acceptance_config: FormatAcceptanceConfig | null
}> }>
): Promise<ProviderEndpoint> { ): Promise<ProviderEndpoint> {
const response = await client.put(`/api/admin/endpoints/${endpointId}`, data) const response = await client.put(`/api/admin/endpoints/${endpointId}`, data)

View File

@@ -87,6 +87,16 @@ export interface HeaderRuleRename {
export type HeaderRule = HeaderRuleSet | HeaderRuleDrop | HeaderRuleRename export type HeaderRule = HeaderRuleSet | HeaderRuleDrop | HeaderRuleRename
/**
* 格式接受策略配置
* 用于控制端点是否接受来自不同 API 格式的请求,并自动进行格式转换
*/
export interface FormatAcceptanceConfig {
enabled: boolean // 是否启用格式转换
accept_formats?: string[] // 白名单:接受哪些格式的请求
reject_formats?: string[] // 黑名单:拒绝哪些格式(优先级高于白名单)
}
export interface ProviderEndpoint { export interface ProviderEndpoint {
id: string id: string
provider_id: string provider_id: string
@@ -100,6 +110,8 @@ export interface ProviderEndpoint {
is_active: boolean is_active: boolean
config?: Record<string, any> config?: Record<string, any>
proxy?: ProxyConfig | null proxy?: ProxyConfig | null
// 格式转换配置
format_acceptance_config?: FormatAcceptanceConfig | null
total_keys: number total_keys: number
active_keys: number active_keys: number
created_at: string created_at: string

View File

@@ -8,7 +8,7 @@ const props = defineProps<CollapsibleContentProps & { class?: string }>()
<template> <template>
<CollapsibleContent <CollapsibleContent
v-bind="props" v-bind="props"
:class="cn('overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down', props.class)" :class="cn('data-[state=closed]:overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down', props.class)"
> >
<slot /> <slot />
</CollapsibleContent> </CollapsibleContent>

View File

@@ -75,6 +75,12 @@ interface Props {
modelValue?: string | number modelValue?: string | number
class?: string class?: string
autocomplete?: string autocomplete?: string
/**
* 输入框尺寸
* - 'default': 默认尺寸 (h-11, py-2)
* - 'sm': 小尺寸 (h-8, py-1)
*/
size?: 'default' | 'sm'
/** /**
* 遮蔽显示内容(用于 API Key 等敏感信息) * 遮蔽显示内容(用于 API Key 等敏感信息)
* 使用 CSS -webkit-text-security 实现,不会触发浏览器密码管理器 * 使用 CSS -webkit-text-security 实现,不会触发浏览器密码管理器
@@ -150,9 +156,16 @@ const autocompleteAttr = computed(() => {
return props.autocomplete ?? 'off' return props.autocomplete ?? 'off'
}) })
// 尺寸相关的样式
const sizeClasses = {
default: 'h-11 py-2 px-4',
sm: 'h-8 py-1 px-3'
}
const inputClass = computed(() => const inputClass = computed(() =>
cn( cn(
'flex h-11 w-full rounded-xl border border-border/60 bg-muted/50 px-4 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/60 text-foreground transition-all', 'flex w-full rounded-xl border border-border/60 bg-muted/50 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/60 text-foreground transition-all',
sizeClasses[props.size || 'default'],
props.masked && 'pr-10', props.masked && 'pr-10',
props.class props.class
) )

View File

@@ -7,33 +7,157 @@
size="2xl" size="2xl"
@update:model-value="handleDialogUpdate" @update:model-value="handleDialogUpdate"
> >
<div class="space-y-4"> <div class="flex flex-col gap-4">
<!-- 已有端点列表 --> <!-- 已有端点列表可滚动 -->
<div <div
v-if="localEndpoints.length > 0" v-if="localEndpoints.length > 0"
class="space-y-2" class="space-y-3 max-h-[50vh] overflow-y-auto"
> >
<Label class="text-muted-foreground">已配置的端点</Label> <Label class="text-muted-foreground">已配置的端点</Label>
<div class="space-y-2">
<!-- 端点卡片列表 -->
<div class="space-y-3">
<div <div
v-for="endpoint in localEndpoints" v-for="endpoint in localEndpoints"
:key="endpoint.id" :key="endpoint.id"
class="rounded-md border px-3 py-2" class="rounded-lg border bg-card"
:class="{ 'opacity-50': !endpoint.is_active }" :class="{ 'opacity-60': !endpoint.is_active }"
> >
<!-- 编辑模式 --> <!-- 卡片头部格式名称 + 状态 + 操作 -->
<template v-if="editingEndpointId === endpoint.id"> <div class="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b">
<div class="space-y-3"> <div class="flex items-center gap-3">
<div class="flex items-center gap-2"> <span class="font-medium">{{ API_FORMAT_LABELS[endpoint.api_format] || endpoint.api_format }}</span>
<span class="text-sm font-medium">{{ API_FORMAT_LABELS[endpoint.api_format] || endpoint.api_format }}</span> <Badge
<div class="flex items-center gap-1 ml-auto"> v-if="!endpoint.is_active"
variant="secondary"
class="text-xs"
>
已停用
</Badge>
</div>
<div class="flex items-center gap-1.5">
<!-- 格式转换按钮 CLI 格式才显示 -->
<Button <Button
v-if="!endpoint.api_format.endsWith('_CLI')"
variant="ghost"
size="icon"
class="h-7 w-7 mr-1"
:class="endpoint.format_acceptance_config?.enabled ? 'text-primary' : ''"
:title="endpoint.format_acceptance_config?.enabled ? '已启用格式转换(点击关闭)' : '启用格式转换'"
:disabled="togglingFormatEndpointId === endpoint.id"
@click="handleToggleFormatConversion(endpoint)"
>
<Shuffle class="w-3.5 h-3.5" />
</Button>
<!-- 启用/停用 -->
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:title="endpoint.is_active ? '停用' : '启用'"
:disabled="togglingEndpointId === endpoint.id"
@click="handleToggleEndpoint(endpoint)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<!-- 删除 -->
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive hover:text-destructive"
title="删除"
:disabled="deletingEndpointId === endpoint.id"
@click="handleDeleteEndpoint(endpoint)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
<!-- 卡片内容 -->
<div class="p-4 space-y-4">
<!-- URL 配置区 -->
<div class="flex items-end gap-3">
<div class="flex-1 min-w-0 grid grid-cols-3 gap-3">
<div class="col-span-2 space-y-1.5">
<Label class="text-xs text-muted-foreground">Base URL</Label>
<Input
:model-value="getEndpointEditState(endpoint.id)?.url ?? endpoint.base_url"
:placeholder="provider?.website || 'https://api.example.com'"
@update:model-value="(v) => updateEndpointField(endpoint.id, 'url', v)"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs text-muted-foreground">自定义路径</Label>
<Input
:model-value="getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')"
:placeholder="getDefaultPath(endpoint.api_format) || '留空使用默认'"
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
/>
</div>
</div>
<!-- 保存/撤销按钮URL/路径有修改时显示) -->
<div
v-if="hasUrlChanges(endpoint)"
class="flex items-center gap-1 shrink-0"
>
<Button
variant="ghost"
size="icon"
class="h-9 w-9"
title="保存"
:disabled="savingEndpointId === endpoint.id"
@click="saveEndpoint(endpoint)"
>
<Check class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-9 w-9"
title="撤销"
@click="resetEndpointChanges(endpoint)"
>
<RotateCcw class="w-4 h-4" />
</Button>
</div>
</div>
<!-- 请求头规则 -->
<div
v-if="hasAnyRules(endpoint)"
class="space-y-2"
>
<Collapsible v-model:open="endpointRulesExpanded[endpoint.id]">
<div class="flex items-center gap-2">
<CollapsibleTrigger as-child>
<button
type="button"
class="flex items-center gap-2 flex-1 py-1.5 px-2 -mx-2 rounded-md hover:bg-muted/50 transition-colors"
>
<ChevronRight
class="w-4 h-4 transition-transform text-muted-foreground"
:class="{ 'rotate-90': endpointRulesExpanded[endpoint.id] }"
/>
<span class="text-sm font-medium">请求头规则</span>
<Badge
v-if="getEndpointRulesCount(endpoint) > 0"
variant="secondary"
class="text-xs"
>
{{ getEndpointRulesCount(endpoint) }} 条
</Badge>
</button>
</CollapsibleTrigger>
<div class="flex items-center gap-1 shrink-0">
<Button
v-if="hasRulesChanges(endpoint)"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
title="保存" title="保存"
:disabled="savingEndpointId === endpoint.id" :disabled="savingEndpointId === endpoint.id"
@click="saveEndpointUrl(endpoint)" @click="saveEndpoint(endpoint)"
> >
<Check class="w-3.5 h-3.5" /> <Check class="w-3.5 h-3.5" />
</Button> </Button>
@@ -41,68 +165,23 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
title="取消" title="添加规则"
@click="cancelEdit" @click="handleAddEndpointRule(endpoint.id)"
> >
<X class="w-3.5 h-3.5" /> <Plus class="w-3.5 h-3.5" />
</Button> </Button>
</div> </div>
</div> </div>
<div class="flex items-end gap-3"> <CollapsibleContent class="pt-2 pl-6">
<div class="flex-1 min-w-0 space-y-1">
<Label class="text-xs text-muted-foreground">Base URL</Label>
<Input
v-model="editingUrl"
class="h-9"
:placeholder="provider?.website || 'https://api.example.com'"
@keyup.escape="cancelEdit"
/>
</div>
<div class="w-52 shrink-0 space-y-1">
<Label class="text-xs text-muted-foreground">自定义路径可选</Label>
<Input
v-model="editingPath"
class="h-9"
:placeholder="editingDefaultPath || '留空使用默认路径'"
@keyup.escape="cancelEdit"
/>
</div>
</div>
<!-- 请求头规则配置 -->
<Collapsible
v-model:open="rulesExpanded"
class="mt-1"
>
<CollapsibleTrigger as-child>
<button
type="button"
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ChevronRight
class="w-3 h-3 transition-transform"
:class="{ 'rotate-90': rulesExpanded }"
/>
<span>请求头规则</span>
<span
v-if="editingRules.length > 0"
class="text-primary"
>
({{ editingRules.length }})
</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent class="pt-2">
<div class="space-y-2"> <div class="space-y-2">
<div <div
v-for="(rule, index) in editingRules" v-for="(rule, index) in getEndpointEditRules(endpoint.id)"
:key="index" :key="index"
class="flex items-center gap-2" class="flex items-center gap-2"
> >
<!-- 操作类型选择 -->
<Select <Select
v-model:open="ruleSelectOpen[index]"
:model-value="rule.action" :model-value="rule.action"
@update:model-value="(v) => updateRuleAction(index, v as 'set' | 'drop' | 'rename')" @update:model-value="(v) => updateEndpointRuleAction(endpoint.id, index, v as 'set' | 'drop' | 'rename')"
> >
<SelectTrigger class="w-24 h-8 text-xs shrink-0"> <SelectTrigger class="w-24 h-8 text-xs shrink-0">
<SelectValue /> <SelectValue />
@@ -122,122 +201,93 @@
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<!-- set: key = value -->
<template v-if="rule.action === 'set'"> <template v-if="rule.action === 'set'">
<Input <Input
v-model="rule.key" :model-value="rule.key"
placeholder="Header 名称" placeholder="Header 名称"
:class="`w-36 h-8 text-xs ${validateRuleKey(rule.key, index) ? 'border-destructive' : ''}`" size="sm"
:title="validateRuleKey(rule.key, index) || ''" :class="`flex-1 min-w-0 text-sm ${validateRuleKeyForEndpoint(endpoint.id, rule.key, index) ? 'border-destructive' : ''}`"
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'key', v)"
/> />
<span class="text-muted-foreground text-xs">=</span> <span class="text-muted-foreground">=</span>
<Input <Input
v-model="rule.value" :model-value="rule.value"
placeholder="Header " placeholder="Header "
class="flex-1 h-8 text-xs" size="sm"
class="flex-1 min-w-0 text-sm"
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'value', v)"
/> />
</template> </template>
<!-- drop: key -->
<template v-else-if="rule.action === 'drop'"> <template v-else-if="rule.action === 'drop'">
<Input <Input
v-model="rule.key" :model-value="rule.key"
placeholder="要删除的 Header 名称" placeholder="要删除的 Header 名称"
:class="`flex-1 h-8 text-xs ${validateRuleKey(rule.key, index) ? 'border-destructive' : ''}`" size="sm"
:title="validateRuleKey(rule.key, index) || ''" :class="`flex-1 min-w-0 text-sm ${validateRuleKeyForEndpoint(endpoint.id, rule.key, index) ? 'border-destructive' : ''}`"
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'key', v)"
/> />
</template> </template>
<!-- rename: from -> to -->
<template v-else-if="rule.action === 'rename'"> <template v-else-if="rule.action === 'rename'">
<Input <Input
v-model="rule.from" :model-value="rule.from"
placeholder="原名称" placeholder="原名称"
:class="`flex-1 h-8 text-xs ${validateRenameFrom(rule.from, index) ? 'border-destructive' : ''}`" size="sm"
:title="validateRenameFrom(rule.from, index) || ''" :class="`flex-1 min-w-0 text-sm ${validateRenameFromForEndpoint(endpoint.id, rule.from, index) ? 'border-destructive' : ''}`"
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'from', v)"
/> />
<ArrowRight class="w-4 h-4 shrink-0 text-muted-foreground" /> <ArrowRight class="w-4 h-4 shrink-0 text-muted-foreground" />
<Input <Input
v-model="rule.to" :model-value="rule.to"
placeholder="新名称" placeholder="新名称"
:class="`flex-1 h-8 text-xs ${validateRenameTo(rule.to, index) ? 'border-destructive' : ''}`" size="sm"
:title="validateRenameTo(rule.to, index) || ''" :class="`flex-1 min-w-0 text-sm ${validateRenameToForEndpoint(endpoint.id, rule.to, index) ? 'border-destructive' : ''}`"
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'to', v)"
/> />
</template> </template>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8 shrink-0" class="h-8 w-8 shrink-0"
@click="removeRule(index)" @click="removeEndpointRule(endpoint.id, index)"
> >
<X class="w-3.5 h-3.5" /> <X class="w-3.5 h-3.5" />
</Button> </Button>
</div> </div>
<Button
variant="outline"
size="sm"
class="w-full h-8 text-xs"
@click="addRule"
>
<Plus class="w-3 h-3 mr-1" />
添加规则
</Button>
</div> </div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</div> </div>
</template>
<template v-else> <!-- 没有请求头规则时只显示添加按钮 -->
<div class="flex items-center gap-3"> <div
<div class="w-24 shrink-0"> v-else
<span class="text-sm font-medium">{{ API_FORMAT_LABELS[endpoint.api_format] || endpoint.api_format }}</span> class="flex items-center justify-between"
</div>
<div class="flex-1 min-w-0">
<span class="text-sm text-muted-foreground truncate block">
{{ endpoint.base_url }}{{ endpoint.custom_path ? endpoint.custom_path : '' }}
</span>
<span
v-if="getEndpointRulesCount(endpoint) > 0"
class="text-xs text-muted-foreground/70"
> >
{{ getEndpointRulesCount(endpoint) }} 条请求头规则 <span class="text-sm text-muted-foreground">请求头规则</span>
</span>
</div>
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-1 shrink-0">
<Button <Button
v-if="hasRulesChanges(endpoint)"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
title="编辑" title="保存"
@click="startEdit(endpoint)" :disabled="savingEndpointId === endpoint.id"
@click="saveEndpoint(endpoint)"
> >
<Edit class="w-3.5 h-3.5" /> <Check class="w-3.5 h-3.5" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-7 w-7" class="h-7 w-7"
:title="endpoint.is_active ? '停用' : '启用'" title="添加规则"
:disabled="togglingEndpointId === endpoint.id" @click="handleAddEndpointRule(endpoint.id)"
@click="handleToggleEndpoint(endpoint)"
> >
<Power class="w-3.5 h-3.5" /> <Plus class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive hover:text-destructive"
title="删除"
:disabled="deletingEndpointId === endpoint.id"
@click="handleDeleteEndpoint(endpoint)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button> </Button>
</div> </div>
</div> </div>
</template> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -245,17 +295,16 @@
<!-- 添加新端点 --> <!-- 添加新端点 -->
<div <div
v-if="availableFormats.length > 0" v-if="availableFormats.length > 0"
class="space-y-3 pt-3 border-t" class="rounded-lg border border-dashed p-3"
> >
<Label class="text-muted-foreground">添加新端点</Label>
<div class="flex items-end gap-3"> <div class="flex items-end gap-3">
<div class="w-36 shrink-0 space-y-1.5"> <div class="w-32 shrink-0 space-y-1">
<Label class="text-xs">API 格式</Label> <Label class="text-xs text-muted-foreground">API 格式</Label>
<Select <Select
v-model="newEndpoint.api_format" v-model="newEndpoint.api_format"
v-model:open="formatSelectOpen" v-model:open="formatSelectOpen"
> >
<SelectTrigger class="h-9"> <SelectTrigger class="h-8">
<SelectValue placeholder="选择格式" /> <SelectValue placeholder="选择格式" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -269,22 +318,30 @@
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div class="flex-1 min-w-0 space-y-1.5"> <div class="flex-1 min-w-0 space-y-1">
<Label class="text-xs">Base URL</Label> <Label class="text-xs text-muted-foreground">Base URL</Label>
<Input <Input
v-model="newEndpoint.base_url" v-model="newEndpoint.base_url"
size="sm"
:placeholder="provider?.website || 'https://api.example.com'" :placeholder="provider?.website || 'https://api.example.com'"
class="h-9"
/> />
</div> </div>
<div class="w-52 shrink-0 space-y-1.5"> <div class="w-36 shrink-0 space-y-1">
<Label class="text-xs">自定义路径(可选)</Label> <Label class="text-xs text-muted-foreground">自定义路径</Label>
<Input <Input
v-model="newEndpoint.custom_path" v-model="newEndpoint.custom_path"
:placeholder="newEndpointDefaultPath || '留空使用默认路径'" size="sm"
class="h-9" :placeholder="newEndpointDefaultPath || '留空使用默认'"
/> />
</div> </div>
<Button
size="sm"
class="shrink-0 h-8"
:disabled="!newEndpoint.api_format || (!newEndpoint.base_url?.trim() && !provider?.website?.trim()) || addingEndpoint"
@click="handleAddEndpoint"
>
{{ addingEndpoint ? '添加中...' : '添加' }}
</Button>
</div> </div>
</div> </div>
@@ -304,14 +361,6 @@
> >
关闭 关闭
</Button> </Button>
<Button
v-if="availableFormats.length > 0"
variant="outline"
:disabled="!newEndpoint.api_format || (!newEndpoint.base_url?.trim() && !provider?.website?.trim()) || addingEndpoint"
@click="handleAddEndpoint"
>
{{ addingEndpoint ? '添加中...' : '添加' }}
</Button>
</template> </template>
</Dialog> </Dialog>
@@ -336,6 +385,7 @@ import {
Button, Button,
Input, Input,
Label, Label,
Badge,
Select, Select,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
@@ -345,7 +395,7 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
CollapsibleContent, CollapsibleContent,
} from '@/components/ui' } from '@/components/ui'
import { Settings, Edit, Trash2, Check, X, Power, ChevronRight, Plus, ArrowRight } from 'lucide-vue-next' import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, ArrowRight, Shuffle, RotateCcw } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import AlertDialog from '@/components/common/AlertDialog.vue' import AlertDialog from '@/components/common/AlertDialog.vue'
@@ -369,6 +419,13 @@ interface EditableRule {
to: string // rename 用 to: string // rename 用
} }
// 端点编辑状态(仅 URL、路径、规则格式转换是直接保存的
interface EndpointEditState {
url: string
path: string
rules: EditableRule[]
}
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
provider: ProviderWithEndpointsSummary | null provider: ProviderWithEndpointsSummary | null
@@ -385,22 +442,21 @@ const { success, error: showError } = useToast()
// 状态 // 状态
const addingEndpoint = ref(false) const addingEndpoint = ref(false)
const editingEndpointId = ref<string | null>(null)
const editingUrl = ref('')
const editingPath = ref('')
const savingEndpointId = ref<string | null>(null) const savingEndpointId = ref<string | null>(null)
const deletingEndpointId = ref<string | null>(null) const deletingEndpointId = ref<string | null>(null)
const togglingEndpointId = ref<string | null>(null) const togglingEndpointId = ref<string | null>(null)
const togglingFormatEndpointId = ref<string | null>(null)
const formatSelectOpen = ref(false) const formatSelectOpen = ref(false)
// 删除确认弹窗状态 // 删除确认弹窗状态
const deleteConfirmOpen = ref(false) const deleteConfirmOpen = ref(false)
const endpointToDelete = ref<ProviderEndpoint | null>(null) const endpointToDelete = ref<ProviderEndpoint | null>(null)
// 请求头规则编辑状态 // 请求头规则折叠状态
const editingRules = ref<EditableRule[]>([]) const endpointRulesExpanded = ref<Record<string, boolean>>({})
const rulesExpanded = ref(false)
const ruleSelectOpen = ref<Record<number, boolean>>({}) // 每个规则 Select 的打开状态 // 每个端点的编辑状态(内联编辑)
const endpointEditStates = ref<Record<string, EndpointEditState>>({})
// 系统保留的 header 名称(不允许用户设置) // 系统保留的 header 名称(不允许用户设置)
const RESERVED_HEADERS = new Set([ const RESERVED_HEADERS = new Set([
@@ -447,10 +503,9 @@ function getDefaultPath(apiFormat: string): string {
return format?.default_path || '' return format?.default_path || ''
} }
// 将 API 返回的 header_rules 转换为可编辑的规则数组 // 初始化端点的编辑状态
function loadRulesFromEndpoint(endpoint: ProviderEndpoint): EditableRule[] { function initEndpointEditState(endpoint: ProviderEndpoint): EndpointEditState {
const rules: EditableRule[] = [] const rules: EditableRule[] = []
if (endpoint.header_rules && endpoint.header_rules.length > 0) { if (endpoint.header_rules && endpoint.header_rules.length > 0) {
for (const rule of endpoint.header_rules) { for (const rule of endpoint.header_rules) {
if (rule.action === 'set') { if (rule.action === 'set') {
@@ -463,11 +518,215 @@ function loadRulesFromEndpoint(endpoint: ProviderEndpoint): EditableRule[] {
} }
} }
return rules return {
url: endpoint.base_url,
path: endpoint.custom_path || '',
rules,
}
}
// 获取端点的编辑状态
function getEndpointEditState(endpointId: string): EndpointEditState | undefined {
return endpointEditStates.value[endpointId]
}
// 更新端点字段
function updateEndpointField(endpointId: string, field: 'url' | 'path', value: string) {
if (!endpointEditStates.value[endpointId]) {
const endpoint = localEndpoints.value.find(e => e.id === endpointId)
if (endpoint) {
endpointEditStates.value[endpointId] = initEndpointEditState(endpoint)
}
}
if (endpointEditStates.value[endpointId]) {
endpointEditStates.value[endpointId][field] = value
}
}
// 获取端点的编辑规则
function getEndpointEditRules(endpointId: string): EditableRule[] {
const state = endpointEditStates.value[endpointId]
if (state) {
return state.rules
}
// 从原始端点加载
const endpoint = localEndpoints.value.find(e => e.id === endpointId)
if (endpoint) {
const newState = initEndpointEditState(endpoint)
endpointEditStates.value[endpointId] = newState
return newState.rules
}
return []
}
// 添加规则(同时自动展开折叠)
function handleAddEndpointRule(endpointId: string) {
const rules = getEndpointEditRules(endpointId)
rules.push({ action: 'set', key: '', value: '', from: '', to: '' })
// 自动展开折叠
endpointRulesExpanded.value[endpointId] = true
}
// 删除规则
function removeEndpointRule(endpointId: string, index: number) {
const rules = getEndpointEditRules(endpointId)
rules.splice(index, 1)
}
// 更新规则类型
function updateEndpointRuleAction(endpointId: string, index: number, action: 'set' | 'drop' | 'rename') {
const rules = getEndpointEditRules(endpointId)
if (rules[index]) {
rules[index].action = action
rules[index].key = ''
rules[index].value = ''
rules[index].from = ''
rules[index].to = ''
}
}
// 更新规则字段
function updateEndpointRuleField(endpointId: string, index: number, field: 'key' | 'value' | 'from' | 'to', value: string) {
const rules = getEndpointEditRules(endpointId)
if (rules[index]) {
rules[index][field] = value
}
}
// 验证规则 key针对特定端点
function validateRuleKeyForEndpoint(endpointId: string, key: string, index: number): string | null {
const trimmedKey = key.trim().toLowerCase()
if (!trimmedKey) return null
if (RESERVED_HEADERS.has(trimmedKey)) {
return `"${key}" 是系统保留的请求头`
}
const rules = getEndpointEditRules(endpointId)
const duplicate = rules.findIndex(
(r, i) => i !== index && (
((r.action === 'set' || r.action === 'drop') && r.key.trim().toLowerCase() === trimmedKey) ||
(r.action === 'rename' && r.to.trim().toLowerCase() === trimmedKey)
)
)
if (duplicate >= 0) {
return '请求头名称重复'
}
return null
}
// 验证 rename from
function validateRenameFromForEndpoint(endpointId: string, from: string, index: number): string | null {
const trimmedFrom = from.trim().toLowerCase()
if (!trimmedFrom) return null
const rules = getEndpointEditRules(endpointId)
const duplicate = rules.findIndex(
(r, i) => i !== index &&
((r.action === 'set' && r.key.trim().toLowerCase() === trimmedFrom) ||
(r.action === 'drop' && r.key.trim().toLowerCase() === trimmedFrom) ||
(r.action === 'rename' && r.from.trim().toLowerCase() === trimmedFrom))
)
if (duplicate >= 0) {
return '该请求头已被其他规则处理'
}
return null
}
// 验证 rename to
function validateRenameToForEndpoint(endpointId: string, to: string, index: number): string | null {
const trimmedTo = to.trim().toLowerCase()
if (!trimmedTo) return null
if (RESERVED_HEADERS.has(trimmedTo)) {
return `"${to}" 是系统保留的请求头`
}
const rules = getEndpointEditRules(endpointId)
const duplicate = rules.findIndex(
(r, i) => i !== index &&
((r.action === 'set' && r.key.trim().toLowerCase() === trimmedTo) ||
(r.action === 'rename' && r.to.trim().toLowerCase() === trimmedTo))
)
if (duplicate >= 0) {
return '请求头名称重复'
}
return null
}
// 获取端点的请求头规则数量(有效的规则)
function getEndpointRulesCount(endpoint: ProviderEndpoint): number {
const state = endpointEditStates.value[endpoint.id]
if (state) {
return state.rules.filter(r => {
if (r.action === 'set' || r.action === 'drop') return r.key.trim()
if (r.action === 'rename') return r.from.trim() && r.to.trim()
return false
}).length
}
return endpoint.header_rules?.length || 0
}
// 检查端点是否有任何规则(包括正在编辑的空规则)
function hasAnyRules(endpoint: ProviderEndpoint): boolean {
const state = endpointEditStates.value[endpoint.id]
if (state) {
return state.rules.length > 0
}
return (endpoint.header_rules?.length || 0) > 0
}
// 检查端点 URL/路径是否有修改
function hasUrlChanges(endpoint: ProviderEndpoint): boolean {
const state = endpointEditStates.value[endpoint.id]
if (!state) return false
if (state.url !== endpoint.base_url) return true
if (state.path !== (endpoint.custom_path || '')) return true
return false
}
// 检查端点规则是否有修改
function hasRulesChanges(endpoint: ProviderEndpoint): boolean {
const state = endpointEditStates.value[endpoint.id]
if (!state) return false
const originalRules = endpoint.header_rules || []
const editedRules = state.rules.filter(r => {
if (r.action === 'set' || r.action === 'drop') return r.key.trim()
if (r.action === 'rename') return r.from.trim() && r.to.trim()
return false
})
if (editedRules.length !== originalRules.length) return true
for (let i = 0; i < editedRules.length; i++) {
const edited = editedRules[i]
const original = originalRules[i]
if (!original) return true
if (edited.action !== original.action) return true
if (edited.action === 'set' && original.action === 'set') {
if (edited.key !== original.key || edited.value !== (original.value || '')) return true
} else if (edited.action === 'drop' && original.action === 'drop') {
if (edited.key !== original.key) return true
} else if (edited.action === 'rename' && original.action === 'rename') {
if (edited.from !== original.from || edited.to !== original.to) return true
}
}
return false
}
// 检查端点是否有修改URL、路径或规则
function hasEndpointChanges(endpoint: ProviderEndpoint): boolean {
return hasUrlChanges(endpoint) || hasRulesChanges(endpoint)
}
// 重置端点修改
function resetEndpointChanges(endpoint: ProviderEndpoint) {
endpointEditStates.value[endpoint.id] = initEndpointEditState(endpoint)
} }
// 将可编辑规则数组转换为 API 需要的 HeaderRule[] // 将可编辑规则数组转换为 API 需要的 HeaderRule[]
// 返回 null 表示显式清除所有规则,返回数组表示设置规则
function rulesToHeaderRules(rules: EditableRule[]): HeaderRule[] | null { function rulesToHeaderRules(rules: EditableRule[]): HeaderRule[] | null {
const result: HeaderRule[] = [] const result: HeaderRule[] = []
@@ -481,121 +740,24 @@ function rulesToHeaderRules(rules: EditableRule[]): HeaderRule[] | null {
} }
} }
// 返回 null 而不是 undefined确保后端能正确清除规则
return result.length > 0 ? result : null return result.length > 0 ? result : null
} }
// 添加新规则 // 检查规则是否有验证错误
function addRule() { function hasValidationErrorsForEndpoint(endpointId: string): boolean {
editingRules.value.push({ action: 'set', key: '', value: '', from: '', to: '' }) const rules = getEndpointEditRules(endpointId)
} for (let i = 0; i < rules.length; i++) {
const rule = rules[i]
// 删除规则
function removeRule(index: number) {
editingRules.value.splice(index, 1)
}
// 更新规则类型时重置字段
function updateRuleAction(index: number, action: 'set' | 'drop' | 'rename') {
const rule = editingRules.value[index]
rule.action = action
// 重置字段
rule.key = ''
rule.value = ''
rule.from = ''
rule.to = ''
}
// 验证 set/drop 的 key
function validateRuleKey(key: string, index: number): string | null {
const trimmedKey = key.trim().toLowerCase()
if (!trimmedKey) return null
// set/drop 操作都不允许操作保留头
if (RESERVED_HEADERS.has(trimmedKey)) {
return `"${key}" 是系统保留的请求头`
}
// 检查重复(在所有规则中检查同类型的 key
const duplicate = editingRules.value.findIndex(
(r, i) => i !== index && (
((r.action === 'set' || r.action === 'drop') && r.key.trim().toLowerCase() === trimmedKey) ||
(r.action === 'rename' && r.to.trim().toLowerCase() === trimmedKey)
)
)
if (duplicate >= 0) {
return '请求头名称重复'
}
return null
}
// 验证 rename 的 from
function validateRenameFrom(from: string, index: number): string | null {
const trimmedFrom = from.trim().toLowerCase()
if (!trimmedFrom) return null
// 检查是否有其他规则已经修改了这个头
const duplicate = editingRules.value.findIndex(
(r, i) => i !== index &&
((r.action === 'set' && r.key.trim().toLowerCase() === trimmedFrom) ||
(r.action === 'drop' && r.key.trim().toLowerCase() === trimmedFrom) ||
(r.action === 'rename' && r.from.trim().toLowerCase() === trimmedFrom))
)
if (duplicate >= 0) {
return '该请求头已被其他规则处理'
}
return null
}
// 验证 rename 的 to
function validateRenameTo(to: string, index: number): string | null {
const trimmedTo = to.trim().toLowerCase()
if (!trimmedTo) return null
if (RESERVED_HEADERS.has(trimmedTo)) {
return `"${to}" 是系统保留的请求头`
}
// 检查重复
const duplicate = editingRules.value.findIndex(
(r, i) => i !== index &&
((r.action === 'set' && r.key.trim().toLowerCase() === trimmedTo) ||
(r.action === 'rename' && r.to.trim().toLowerCase() === trimmedTo))
)
if (duplicate >= 0) {
return '请求头名称重复'
}
return null
}
// 检查所有规则是否有效(用于保存前验证)
function hasValidationErrors(): boolean {
for (let i = 0; i < editingRules.value.length; i++) {
const rule = editingRules.value[i]
if (rule.action === 'set' || rule.action === 'drop') { if (rule.action === 'set' || rule.action === 'drop') {
if (validateRuleKey(rule.key, i)) return true if (validateRuleKeyForEndpoint(endpointId, rule.key, i)) return true
} else if (rule.action === 'rename') { } else if (rule.action === 'rename') {
if (validateRenameFrom(rule.from, i)) return true if (validateRenameFromForEndpoint(endpointId, rule.from, i)) return true
if (validateRenameTo(rule.to, i)) return true if (validateRenameToForEndpoint(endpointId, rule.to, i)) return true
} }
} }
return false return false
} }
// 获取端点的请求头规则数量(用于查看模式显示)
function getEndpointRulesCount(endpoint: ProviderEndpoint): number {
return endpoint.header_rules?.length || 0
}
// 当前编辑端点的默认路径
const editingDefaultPath = computed(() => {
const endpoint = localEndpoints.value.find(e => e.id === editingEndpointId.value)
return endpoint ? getDefaultPath(endpoint.api_format) : ''
})
// 新端点选择的格式的默认路径 // 新端点选择的格式的默认路径
const newEndpointDefaultPath = computed(() => { const newEndpointDefaultPath = computed(() => {
return getDefaultPath(newEndpoint.value.api_format) return getDefaultPath(newEndpoint.value.api_format)
@@ -619,12 +781,13 @@ onMounted(() => {
watch(() => props.modelValue, (open) => { watch(() => props.modelValue, (open) => {
if (open) { if (open) {
localEndpoints.value = [...(props.endpoints || [])] localEndpoints.value = [...(props.endpoints || [])]
// 重置编辑状态 // 清空编辑状态,重新从端点加载
editingEndpointId.value = null endpointEditStates.value = {}
editingUrl.value = '' endpointRulesExpanded.value = {}
editingPath.value = '' // 初始化每个端点的编辑状态
editingRules.value = [] for (const endpoint of localEndpoints.value) {
rulesExpanded.value = false endpointEditStates.value[endpoint.id] = initEndpointEditState(endpoint)
}
} else { } else {
// 关闭对话框时完全清空新端点表单 // 关闭对话框时完全清空新端点表单
newEndpoint.value = { api_format: '', base_url: '', custom_path: '' } newEndpoint.value = { api_format: '', base_url: '', custom_path: '' }
@@ -634,34 +797,22 @@ watch(() => props.modelValue, (open) => {
watch(() => props.endpoints, (endpoints) => { watch(() => props.endpoints, (endpoints) => {
if (props.modelValue) { if (props.modelValue) {
localEndpoints.value = [...(endpoints || [])] localEndpoints.value = [...(endpoints || [])]
// 初始化新添加端点的编辑状态
for (const endpoint of localEndpoints.value) {
if (!endpointEditStates.value[endpoint.id]) {
endpointEditStates.value[endpoint.id] = initEndpointEditState(endpoint)
}
}
} }
}, { deep: true }) }, { deep: true })
// 开始编辑
function startEdit(endpoint: ProviderEndpoint) {
editingEndpointId.value = endpoint.id
editingUrl.value = endpoint.base_url
editingPath.value = endpoint.custom_path || ''
// 加载规则数据
editingRules.value = loadRulesFromEndpoint(endpoint)
rulesExpanded.value = editingRules.value.length > 0
}
// 取消编辑
function cancelEdit() {
editingEndpointId.value = null
editingUrl.value = ''
editingPath.value = ''
editingRules.value = []
rulesExpanded.value = false
}
// 保存端点 // 保存端点
async function saveEndpointUrl(endpoint: ProviderEndpoint) { async function saveEndpoint(endpoint: ProviderEndpoint) {
if (!editingUrl.value) return const state = endpointEditStates.value[endpoint.id]
if (!state || !state.url) return
// 检查规则是否有验证错误 // 检查规则是否有验证错误
if (hasValidationErrors()) { if (hasValidationErrorsForEndpoint(endpoint.id)) {
showError('请修正请求头规则中的错误') showError('请修正请求头规则中的错误')
return return
} }
@@ -669,13 +820,12 @@ async function saveEndpointUrl(endpoint: ProviderEndpoint) {
savingEndpointId.value = endpoint.id savingEndpointId.value = endpoint.id
try { try {
await updateEndpoint(endpoint.id, { await updateEndpoint(endpoint.id, {
base_url: editingUrl.value, base_url: state.url,
custom_path: editingPath.value || null, custom_path: state.path || null,
header_rules: rulesToHeaderRules(editingRules.value), header_rules: rulesToHeaderRules(state.rules),
}) })
success('端点已更新') success('端点已更新')
emit('endpointUpdated') emit('endpointUpdated')
cancelEdit()
} catch (error: any) { } catch (error: any) {
showError(error.response?.data?.detail || '更新失败', '错误') showError(error.response?.data?.detail || '更新失败', '错误')
} finally { } finally {
@@ -683,6 +833,25 @@ async function saveEndpointUrl(endpoint: ProviderEndpoint) {
} }
} }
// 切换格式转换(直接保存)
async function handleToggleFormatConversion(endpoint: ProviderEndpoint) {
const currentEnabled = endpoint.format_acceptance_config?.enabled || false
const newEnabled = !currentEnabled
togglingFormatEndpointId.value = endpoint.id
try {
await updateEndpoint(endpoint.id, {
format_acceptance_config: newEnabled ? { enabled: true } : null,
})
success(newEnabled ? '已启用格式转换' : '已关闭格式转换')
emit('endpointUpdated')
} catch (error: any) {
showError(error.response?.data?.detail || '操作失败', '错误')
} finally {
togglingFormatEndpointId.value = null
}
}
// 添加端点 // 添加端点
async function handleAddEndpoint() { async function handleAddEndpoint() {
if (!props.provider || !newEndpoint.value.api_format) return if (!props.provider || !newEndpoint.value.api_format) return

View File

@@ -308,6 +308,7 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
is_active=True, is_active=True,
config=self.endpoint_data.config, config=self.endpoint_data.config,
proxy=self.endpoint_data.proxy.model_dump() if self.endpoint_data.proxy else None, proxy=self.endpoint_data.proxy.model_dump() if self.endpoint_data.proxy else None,
format_acceptance_config=self.endpoint_data.format_acceptance_config,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )

View File

@@ -248,6 +248,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
self, self,
source_model: str, source_model: str,
provider_id: str, provider_id: str,
api_format: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
""" """
获取模型映射后的实际模型名 获取模型映射后的实际模型名
@@ -255,6 +256,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
Args: Args:
source_model: 用户请求的模型名 source_model: 用户请求的模型名
provider_id: Provider ID provider_id: Provider ID
api_format: Provider 侧 API 格式(用于过滤映射作用域,默认使用 handler FORMAT_ID
Returns: Returns:
映射后的 provider_model_name没有映射则返回 None 映射后的 provider_model_name没有映射则返回 None
@@ -269,8 +271,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 传入 api_key.id 作为 affinity_key实现相同用户稳定选择同一映射 # 传入 api_key.id 作为 affinity_key实现相同用户稳定选择同一映射
# 传入 api_format 用于过滤适用的映射作用域 # 传入 api_format 用于过滤适用的映射作用域
affinity_key = self.api_key.id if self.api_key else None affinity_key = self.api_key.id if self.api_key else None
effective_format = api_format or self.FORMAT_ID
mapped_name = mapping.model.select_provider_model_name( mapped_name = mapping.model.select_provider_model_name(
affinity_key, api_format=self.FORMAT_ID affinity_key, api_format=effective_format
) )
logger.debug(f"[Chat] 模型映射: {source_model} -> {mapped_name}") logger.debug(f"[Chat] 模型映射: {source_model} -> {mapped_name}")
return mapped_name return mapped_name
@@ -297,6 +300,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 创建类型安全的流式上下文 # 创建类型安全的流式上下文
ctx = StreamContext(model=model, api_format=api_format) ctx = StreamContext(model=model, api_format=api_format)
ctx.request_id = self.request_id
ctx.client_api_format = api_format.value if hasattr(api_format, "value") else str(api_format)
# 创建更新状态的回调闭包(可以访问 ctx # 创建更新状态的回调闭包(可以访问 ctx
def update_streaming_status() -> None: def update_streaming_status() -> None:
@@ -430,12 +435,24 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
provider_api_format=str(endpoint.api_format) if endpoint.api_format else None, provider_api_format=str(endpoint.api_format) if endpoint.api_format else None,
) )
# ctx.api_format 是枚举,需要取 value 作为字符串
_api_format_str = (
ctx.api_format.value if hasattr(ctx.api_format, "value") else str(ctx.api_format)
)
provider_api_format = ctx.provider_api_format or _api_format_str
client_api_format = ctx.client_api_format or _api_format_str
needs_conversion = (
bool(getattr(candidate, "needs_conversion", False)) if candidate else False
)
ctx.needs_conversion = needs_conversion
# 获取模型映射(优先使用映射匹配到的模型,其次是 Provider 级别的映射) # 获取模型映射(优先使用映射匹配到的模型,其次是 Provider 级别的映射)
mapped_model = candidate.mapping_matched_model if candidate else None mapped_model = candidate.mapping_matched_model if candidate else None
if not mapped_model: if not mapped_model:
mapped_model = await self._get_mapped_model( mapped_model = await self._get_mapped_model(
source_model=ctx.model, source_model=ctx.model,
provider_id=str(provider.id), provider_id=str(provider.id),
api_format=provider_api_format,
) )
# 应用模型映射到请求体 # 应用模型映射到请求体
@@ -445,7 +462,17 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
else: else:
request_body = dict(original_request_body) request_body = dict(original_request_body)
# 准备发送给 Provider 的请求体 # 跨格式:先做请求体转换(严格模式,失败触发 failover
if needs_conversion:
from src.core.api_format import converter_registry
request_body = converter_registry.convert_request_strict(
request_body,
str(client_api_format),
str(provider_api_format),
)
else:
# 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段)
request_body = self.prepare_provider_request_body(request_body) request_body = self.prepare_provider_request_body(request_body)
# 构建请求 # 构建请求
@@ -663,6 +690,12 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
nonlocal provider_request_headers, provider_request_body, mapped_model_result nonlocal provider_request_headers, provider_request_body, mapped_model_result
provider_name = str(provider.name) 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)
)
needs_conversion = bool(getattr(candidate, "needs_conversion", False))
# 获取模型映射(优先使用映射匹配到的模型,其次是 Provider 级别的映射) # 获取模型映射(优先使用映射匹配到的模型,其次是 Provider 级别的映射)
mapped_model = candidate.mapping_matched_model if candidate else None mapped_model = candidate.mapping_matched_model if candidate else None
@@ -670,6 +703,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
mapped_model = await self._get_mapped_model( mapped_model = await self._get_mapped_model(
source_model=model, source_model=model,
provider_id=str(provider.id), provider_id=str(provider.id),
api_format=provider_api_format,
) )
# 应用模型映射 # 应用模型映射
@@ -679,7 +713,17 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
else: else:
request_body = dict(original_request_body) request_body = dict(original_request_body)
# 准备发送给 Provider 的请求体(子类可覆盖以移除不需要的字段 # 跨格式:先做请求体转换(严格模式,失败触发 failover
if needs_conversion:
from src.core.api_format import converter_registry
request_body = converter_registry.convert_request_strict(
request_body,
client_api_format,
provider_api_format,
)
else:
# 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段)
request_body = self.prepare_provider_request_body(request_body) request_body = self.prepare_provider_request_body(request_body)
# 构建请求 # 构建请求
@@ -789,9 +833,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
raw_content = repr(resp.content[:500]) if resp.content else "(empty)" raw_content = repr(resp.content[:500]) if resp.content else "(empty)"
except Exception: except Exception:
raw_content = "(unable to read)" raw_content = "(unable to read)"
logger.error( logger.error(f"[{self.request_id}] 无法解析响应 JSON: {e}, 原始内容: {raw_content}")
f"[{self.request_id}] 无法解析响应 JSON: {e}, 原始内容: {raw_content}"
)
# 判断错误类型,生成友好的客户端错误消息(不暴露提供商信息) # 判断错误类型,生成友好的客户端错误消息(不暴露提供商信息)
if raw_content == "(empty)" or not raw_content.strip(): if raw_content == "(empty)" or not raw_content.strip():
client_message = "上游服务返回了空响应" client_message = "上游服务返回了空响应"
@@ -808,7 +850,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 检查响应体中的嵌套错误HTTP 200 但响应体包含错误) # 检查响应体中的嵌套错误HTTP 200 但响应体包含错误)
if isinstance(response_json, dict): if isinstance(response_json, dict):
parser = get_parser_for_format(api_format) parser = get_parser_for_format(provider_api_format)
if parser.is_error_response(response_json): if parser.is_error_response(response_json):
parsed = parser.parse_response(response_json, 200) parsed = parser.parse_response(response_json, 200)
logger.warning( logger.warning(
@@ -825,6 +867,16 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
error_status=parsed.error_type, error_status=parsed.error_type,
) )
# 跨格式:响应转换回 client_format严格模式失败触发 failover
if needs_conversion and isinstance(response_json, dict):
from src.core.api_format import converter_registry
response_json = converter_registry.convert_response_strict(
response_json,
provider_api_format,
str(api_format),
)
return response_json if isinstance(response_json, dict) else {} return response_json if isinstance(response_json, dict) else {}
try: try:

View File

@@ -76,6 +76,7 @@ class StreamContext:
# 格式转换信息CLI handler 需要) # 格式转换信息CLI handler 需要)
client_api_format: str = "" client_api_format: str = ""
needs_conversion: bool = False # 是否需要跨格式转换(由 handler 层设置)
# Provider 响应元数据CLI handler 需要) # Provider 响应元数据CLI handler 需要)
response_metadata: Dict[str, Any] = field(default_factory=dict) response_metadata: Dict[str, Any] = field(default_factory=dict)
@@ -118,6 +119,7 @@ class StreamContext:
self.final_usage = None self.final_usage = None
self.final_response = None self.final_response = None
self.stream_conversion_state = None self.stream_conversion_state = None
self.needs_conversion = False
@property @property
def collected_text(self) -> str: def collected_text(self) -> str:

View File

@@ -328,14 +328,12 @@ class StreamProcessor:
raise raise
except (OSError, IOError) as e: except (OSError, IOError) as e:
# 网络 I/O 异常:记录警告,可能需要重试 # 网络 I/O 异常:记录警告,可能需要重试
logger.warning( logger.warning(f" [{self.request_id}] 预读流时发生网络异常: {type(e).__name__}: {e}")
f" [{self.request_id}] 预读流时发生网络异常: {type(e).__name__}: {e}"
)
except Exception as e: except Exception as e:
# 未预期的严重异常:记录错误并重新抛出,避免掩盖问题 # 未预期的严重异常:记录错误并重新抛出,避免掩盖问题
logger.error( logger.error(
f" [{self.request_id}] 预读流时发生严重异常: {type(e).__name__}: {e}", f" [{self.request_id}] 预读流时发生严重异常: {type(e).__name__}: {e}",
exc_info=True exc_info=True,
) )
raise raise
@@ -374,9 +372,26 @@ class StreamProcessor:
# 使用增量解码器处理跨 chunk 的 UTF-8 字符 # 使用增量解码器处理跨 chunk 的 UTF-8 字符
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
# 处理预读数据 # ctx.api_format 可能是 APIFormat 枚举,需要取 value
if prefetched_chunks: _api_format_str = (
for chunk in prefetched_chunks: ctx.api_format.value
if hasattr(ctx.api_format, "value")
else str(ctx.api_format or "")
)
client_format = (ctx.client_api_format or _api_format_str).upper()
provider_format = (ctx.provider_api_format or _api_format_str).upper()
# 使用 handler 层预计算的 needs_conversion由 candidate 决定)
needs_conversion = ctx.needs_conversion
# 安全检查needs_conversion 为 True 时provider_format 必须有值
if needs_conversion and not provider_format:
logger.warning(
f"[{self.request_id}] needs_conversion=True 但 provider_format 为空,回退到透传模式"
)
needs_conversion = False
def _mark_stream_started() -> None:
nonlocal start_time, streaming_started
# 记录首字时间 (TTFB) - 在 yield 之前记录 # 记录首字时间 (TTFB) - 在 yield 之前记录
if start_time is not None: if start_time is not None:
ctx.record_first_byte_time(start_time) ctx.record_first_byte_time(start_time)
@@ -386,7 +401,216 @@ class StreamProcessor:
self.on_streaming_start() self.on_streaming_start()
streaming_started = True streaming_started = True
# 把原始数据转发给客户端 def _build_stream_error_payload(message: str) -> dict:
if client_format.startswith("OPENAI"):
return {
"error": {
"message": message,
"type": "format_conversion_error",
}
}
# Claude 及其他格式使用统一的错误结构
return {
"type": "error",
"error": {
"type": "format_conversion_error",
"message": message,
},
}
# 处理预读数据
if needs_conversion:
# 延迟导入:仅在需要转换时加载转换器模块
from src.core.api_format import (
GeminiStreamConversionState,
StreamConversionState,
converter_registry,
)
# 初始化流式转换状态(首次使用时,根据 Provider 格式选择状态类)
if ctx.stream_conversion_state is None:
if provider_format == "GEMINI":
ctx.stream_conversion_state = GeminiStreamConversionState(
model=ctx.mapped_model or ctx.model or "",
message_id=ctx.response_id or ctx.request_id or "",
)
else:
ctx.stream_conversion_state = StreamConversionState(
model=ctx.mapped_model or ctx.model or "",
message_id=ctx.response_id or ctx.request_id or "",
)
skip_next_blank_line = False
empty_yield_count = 0 # 空转计数(防护异常情况)
def _emit_converted_line(normalized_line: str) -> list[bytes]:
nonlocal skip_next_blank_line
# 空行:事件分隔符(避免重复输出)
if normalized_line == "":
if skip_next_blank_line:
skip_next_blank_line = False
return []
return [b"\n"]
# 丢弃 Provider 的 event 行,避免泄漏/污染目标格式
if normalized_line.startswith("event:"):
return []
# OpenAI done 信号
if (
normalized_line.startswith("data:")
and normalized_line[5:].strip() == "[DONE]"
):
skip_next_blank_line = True
if client_format.startswith("OPENAI"):
return [b"data: [DONE]\n\n"]
return []
# 非 data 行:在跨格式场景下统一丢弃(避免泄露 Provider 格式细节)
if not normalized_line.startswith("data:"):
return []
data_content = normalized_line[5:].strip()
# Gemini 可能包含 JSON 数组包装符,直接忽略
if data_content in ("", "[", "]", ","):
return []
try:
data_obj = json.loads(data_content)
except json.JSONDecodeError:
# 跨格式转换时JSON 解析失败应跳过而不是透传(避免泄漏 Provider 格式)
logger.warning(
f"[{self.request_id}] JSON 解析失败,跳过该行: {data_content[:100]}"
)
return []
if not isinstance(data_obj, dict):
return []
try:
converted_events = converter_registry.convert_stream_chunk_strict(
data_obj,
provider_format,
client_format,
state=ctx.stream_conversion_state,
)
except Exception as conv_err:
# 首字节后无法 failover输出目标格式错误事件并终止流
# 使用 502 表示上游返回了非预期格式Bad Gateway
ctx.status_code = 502
ctx.error_message = "format_conversion_failed"
# 日志记录完整错误(内部排查),客户端只返回脱敏消息
logger.warning(f"[{self.request_id}] 流式格式转换失败: {conv_err}")
payload = _build_stream_error_payload("响应格式转换失败,请稍后重试")
error_bytes = f"data: {json.dumps(payload, ensure_ascii=False)}\n\n".encode(
"utf-8"
)
done_bytes = (
b"data: [DONE]\n\n" if client_format.startswith("OPENAI") else b""
)
return [error_bytes, done_bytes]
skip_next_blank_line = True
out: list[bytes] = []
for evt in converted_events:
out.append(
f"data: {json.dumps(evt, ensure_ascii=False)}\n\n".encode("utf-8")
)
return out
# 统一处理 prefetched + iterator
if prefetched_chunks:
for chunk in prefetched_chunks:
buffer += chunk
while b"\n" in buffer:
line_bytes, buffer = buffer.split(b"\n", 1)
try:
line = decoder.decode(line_bytes + b"\n", False)
except Exception as e:
logger.warning(
f"[{self.request_id}] UTF-8 解码失败: {e}, bytes={line_bytes[:50]!r}"
)
line = ""
if line:
self._process_line(ctx, sse_parser, line)
normalized_line = line.rstrip("\r\n") if line else ""
out_chunks = _emit_converted_line(normalized_line)
if not out_chunks:
empty_yield_count += 1
if empty_yield_count == StreamDefaults.MAX_EMPTY_YIELDS_WARNING:
logger.warning(
f"[{self.request_id}] 流式转换连续 {empty_yield_count} 次空产出"
)
else:
empty_yield_count = 0
for out in out_chunks:
if not out:
continue
_mark_stream_started()
yield out
# 转换失败:已输出 error可能还包含 done直接终止
if ctx.error_message == "format_conversion_failed":
return
async for chunk in byte_iterator:
buffer += chunk
while b"\n" in buffer:
line_bytes, buffer = buffer.split(b"\n", 1)
try:
line = decoder.decode(line_bytes + b"\n", False)
except Exception as e:
logger.warning(
f"[{self.request_id}] UTF-8 解码失败: {e}, bytes={line_bytes[:50]!r}"
)
line = ""
if line:
self._process_line(ctx, sse_parser, line)
normalized_line = line.rstrip("\r\n") if line else ""
out_chunks = _emit_converted_line(normalized_line)
if not out_chunks:
empty_yield_count += 1
if empty_yield_count == StreamDefaults.MAX_EMPTY_YIELDS_WARNING:
logger.warning(
f"[{self.request_id}] 流式转换连续 {empty_yield_count} 次空产出"
)
else:
empty_yield_count = 0
for out in out_chunks:
if not out:
continue
_mark_stream_started()
yield out
if ctx.error_message == "format_conversion_failed":
return
# 处理剩余缓冲区needs_conversion 分支内,可复用 _emit_converted_line
if buffer:
try:
line = decoder.decode(buffer, True)
except Exception as e:
logger.warning(
f"[{self.request_id}] 处理剩余缓冲区失败: {e}, bytes={buffer[:50]!r}"
)
line = ""
if line:
self._process_line(ctx, sse_parser, line)
normalized_line = line.rstrip("\r\n")
out_chunks = _emit_converted_line(normalized_line)
for out in out_chunks:
if out:
_mark_stream_started()
yield out
# 转换失败:已输出 error直接终止
if ctx.error_message == "format_conversion_failed":
return
else:
if prefetched_chunks:
for chunk in prefetched_chunks:
_mark_stream_started()
yield chunk yield chunk
buffer += chunk buffer += chunk
@@ -406,15 +630,9 @@ class StreamProcessor:
continue continue
# 处理剩余的流数据 # 处理剩余的流数据
if not needs_conversion:
async for chunk in byte_iterator: async for chunk in byte_iterator:
# 记录首字时间 (TTFB) - 在 yield 之前记录(如果预读数据为空) _mark_stream_started()
if start_time is not None:
ctx.record_first_byte_time(start_time)
start_time = None # 只记录一次
# 首次输出前触发 streaming 回调(确保 TTFB 已写入 ctx
if not streaming_started and self.on_streaming_start:
self.on_streaming_start()
streaming_started = True
# 原始数据透传 # 原始数据透传
yield chunk yield chunk
@@ -435,16 +653,15 @@ class StreamProcessor:
) )
continue continue
# 处理剩余的缓冲区数据(如果有未完成的行 # 处理剩余的缓冲区数据(仅非转换分支,转换分支已在内部处理
if buffer: if not needs_conversion and buffer:
try: try:
# 使用 final=True 处理最后的不完整字符 # 使用 final=True 处理最后的不完整字符
line = decoder.decode(buffer, True) line = decoder.decode(buffer, True)
self._process_line(ctx, sse_parser, line) self._process_line(ctx, sse_parser, line)
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"[{self.request_id}] 处理剩余缓冲区失败: {e}, " f"[{self.request_id}] 处理剩余缓冲区失败: {e}, bytes={buffer[:50]!r}"
f"bytes={buffer[:50]!r}"
) )
# 处理剩余事件 # 处理剩余事件

View File

@@ -55,6 +55,11 @@ class StreamDefaults:
# 3. 不会占用过多内存 # 3. 不会占用过多内存
MAX_PREFETCH_BYTES = 64 * 1024 # 64KB MAX_PREFETCH_BYTES = 64 * 1024 # 64KB
# 流式转换空产出告警阈值
# 连续这么多次空行/非 data 行后记录警告日志
# 50 次约等于 50 行非 data SSE 数据,足够覆盖正常事件头
MAX_EMPTY_YIELDS_WARNING = 50
class RPMDefaults: class RPMDefaults:
"""RPM每分钟请求数限制默认值 """RPM每分钟请求数限制默认值

View File

@@ -13,9 +13,12 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union import time
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, Dict, Generator, Optional, Tuple, Union
from src.core.logger import logger from src.core.logger import logger
from src.core.metrics import format_conversion_duration_seconds, format_conversion_total
from .exceptions import FormatConversionError from .exceptions import FormatConversionError
@@ -23,6 +26,35 @@ if TYPE_CHECKING:
from .state import GeminiStreamConversionState, StreamConversionState from .state import GeminiStreamConversionState, StreamConversionState
@contextmanager
def _track_conversion_metrics(
direction: str, source: str, target: str
) -> Generator[None, None, None]:
"""
跟踪转换指标的上下文管理器
Args:
direction: 转换方向request/response/stream
source: 源格式(大写)
target: 目标格式(大写)
Yields:
None - 执行转换逻辑
"""
start = time.perf_counter()
status = "success"
try:
yield
except Exception:
status = "error"
raise
finally:
format_conversion_total.labels(direction, source, target, status).inc()
format_conversion_duration_seconds.labels(direction, source, target).observe(
time.perf_counter() - start
)
class FormatConverterRegistry: class FormatConverterRegistry:
""" """
格式转换器注册表 格式转换器注册表
@@ -118,7 +150,9 @@ class FormatConverterRegistry:
logger.debug(f"[ConverterRegistry] 请求转换成功: {source_format} -> {target_format}") logger.debug(f"[ConverterRegistry] 请求转换成功: {source_format} -> {target_format}")
return converted return converted
except Exception as e: except Exception as e:
logger.error(f"[ConverterRegistry] 请求转换失败: {source_format} -> {target_format}: {e}") logger.error(
f"[ConverterRegistry] 请求转换失败: {source_format} -> {target_format}: {e}"
)
return request return request
def convert_response( def convert_response(
@@ -160,7 +194,9 @@ class FormatConverterRegistry:
logger.debug(f"[ConverterRegistry] 响应转换成功: {source_format} -> {target_format}") logger.debug(f"[ConverterRegistry] 响应转换成功: {source_format} -> {target_format}")
return converted return converted
except Exception as e: except Exception as e:
logger.error(f"[ConverterRegistry] 响应转换失败: {source_format} -> {target_format}: {e}") logger.error(
f"[ConverterRegistry] 响应转换失败: {source_format} -> {target_format}: {e}"
)
return response return response
def convert_stream_chunk( def convert_stream_chunk(
@@ -196,14 +232,16 @@ class FormatConverterRegistry:
result: list[Dict[str, Any]] = converter.convert_stream_chunk(chunk, state) result: list[Dict[str, Any]] = converter.convert_stream_chunk(chunk, state)
return result return result
except Exception as e: except Exception as e:
logger.error(f"[ConverterRegistry] 流式块转换失败: {source_format} -> {target_format}: {e}") logger.error(
f"[ConverterRegistry] 流式块转换失败: {source_format} -> {target_format}: {e}"
)
return [chunk] return [chunk]
# 降级到普通响应转换(作为单个事件返回) # 降级到普通响应转换(作为单个事件返回)
if hasattr(converter, "convert_response"): if hasattr(converter, "convert_response"):
try: try:
result = converter.convert_response(chunk) converted: Dict[str, Any] = converter.convert_response(chunk)
return [result] return [converted]
except Exception: except Exception:
return [chunk] return [chunk]
@@ -284,8 +322,11 @@ class FormatConverterRegistry:
Raises: Raises:
FormatConversionError: 转换失败时抛出 FormatConversionError: 转换失败时抛出
""" """
source_upper = source_format.upper()
target_upper = target_format.upper()
# 同格式无需转换 # 同格式无需转换
if source_format.upper() == target_format.upper(): if source_upper == target_upper:
return request return request
converter = self.get_converter(source_format, target_format) converter = self.get_converter(source_format, target_format)
@@ -293,8 +334,11 @@ class FormatConverterRegistry:
raise FormatConversionError(source_format, target_format, "未找到转换器") raise FormatConversionError(source_format, target_format, "未找到转换器")
if not hasattr(converter, "convert_request"): if not hasattr(converter, "convert_request"):
raise FormatConversionError(source_format, target_format, "转换器缺少 convert_request 方法") raise FormatConversionError(
source_format, target_format, "转换器缺少 convert_request 方法"
)
with _track_conversion_metrics("request", source_upper, target_upper):
try: try:
converted: Dict[str, Any] = converter.convert_request(request) converted: Dict[str, Any] = converter.convert_request(request)
logger.debug(f"[ConverterRegistry] 请求转换成功: {source_format} -> {target_format}") logger.debug(f"[ConverterRegistry] 请求转换成功: {source_format} -> {target_format}")
@@ -316,7 +360,10 @@ class FormatConverterRegistry:
Raises: Raises:
FormatConversionError: 转换失败时抛出 FormatConversionError: 转换失败时抛出
""" """
if source_format.upper() == target_format.upper(): source_upper = source_format.upper()
target_upper = target_format.upper()
if source_upper == target_upper:
return response return response
converter = self.get_converter(source_format, target_format) converter = self.get_converter(source_format, target_format)
@@ -324,8 +371,11 @@ class FormatConverterRegistry:
raise FormatConversionError(source_format, target_format, "未找到转换器") raise FormatConversionError(source_format, target_format, "未找到转换器")
if not hasattr(converter, "convert_response"): if not hasattr(converter, "convert_response"):
raise FormatConversionError(source_format, target_format, "转换器缺少 convert_response 方法") raise FormatConversionError(
source_format, target_format, "转换器缺少 convert_response 方法"
)
with _track_conversion_metrics("response", source_upper, target_upper):
try: try:
converted: Dict[str, Any] = converter.convert_response(response) converted: Dict[str, Any] = converter.convert_response(response)
logger.debug(f"[ConverterRegistry] 响应转换成功: {source_format} -> {target_format}") logger.debug(f"[ConverterRegistry] 响应转换成功: {source_format} -> {target_format}")
@@ -357,7 +407,10 @@ class FormatConverterRegistry:
Raises: Raises:
FormatConversionError: 转换失败时抛出 FormatConversionError: 转换失败时抛出
""" """
if source_format.upper() == target_format.upper(): source_upper = source_format.upper()
target_upper = target_format.upper()
if source_upper == target_upper:
return [chunk] return [chunk]
converter = self.get_converter(source_format, target_format) converter = self.get_converter(source_format, target_format)
@@ -369,13 +422,16 @@ class FormatConverterRegistry:
source_format, target_format, "转换器缺少 convert_stream_chunk 方法" source_format, target_format, "转换器缺少 convert_stream_chunk 方法"
) )
with _track_conversion_metrics("stream", source_upper, target_upper):
try: try:
result: list[Dict[str, Any]] = converter.convert_stream_chunk(chunk, state) result: list[Dict[str, Any]] = converter.convert_stream_chunk(chunk, state)
return result return result
except FormatConversionError: except FormatConversionError:
raise raise
except Exception as e: except Exception as e:
raise FormatConversionError(source_format, target_format, f"流式块转换失败: {e}") from e raise FormatConversionError(
source_format, target_format, f"流式块转换失败: {e}"
) from e
# 全局单例 # 全局单例
@@ -387,4 +443,3 @@ __all__ = [
"converter_registry", "converter_registry",
"FormatConversionError", "FormatConversionError",
] ]

View File

@@ -65,3 +65,18 @@ model_mapping_conflict_total = Counter(
"model_mapping_conflict_total", "model_mapping_conflict_total",
"Total number of mapping conflicts detected (same name maps to multiple GlobalModels)", "Total number of mapping conflicts detected (same name maps to multiple GlobalModels)",
) )
# ==================== API 格式转换 ====================
format_conversion_total = Counter(
"format_conversion_total",
"Total number of format conversions",
["direction", "source_format", "target_format", "status"], # status: success/error
)
format_conversion_duration_seconds = Histogram(
"format_conversion_duration_seconds",
"Duration of format conversions in seconds",
["direction", "source_format", "target_format"],
buckets=[0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0],
)

View File

@@ -278,7 +278,9 @@ class Usage(Base):
request_id = Column(String(100), unique=True, index=True, nullable=False) request_id = Column(String(100), unique=True, index=True, nullable=False)
provider_name = Column(String(100), nullable=False) # Provider 名称(非外键) provider_name = Column(String(100), nullable=False) # Provider 名称(非外键)
model = Column(String(100), nullable=False) model = Column(String(100), nullable=False)
target_model = Column(String(100), nullable=True, comment="映射后的目标模型名(若无映射则为空)") target_model = Column(
String(100), nullable=True, comment="映射后的目标模型名(若无映射则为空)"
)
# Provider 侧追踪信息(记录最终成功的 Provider/Endpoint/Key # Provider 侧追踪信息(记录最终成功的 Provider/Endpoint/Key
provider_id = Column(String(36), ForeignKey("providers.id", ondelete="SET NULL"), nullable=True) provider_id = Column(String(36), ForeignKey("providers.id", ondelete="SET NULL"), nullable=True)
@@ -465,7 +467,9 @@ class LDAPConfig(Base):
user_search_filter = Column( user_search_filter = Column(
String(500), default="(uid={username})", nullable=False String(500), default="(uid={username})", nullable=False
) # 用户搜索过滤器 ) # 用户搜索过滤器
username_attr = Column(String(50), default="uid", nullable=False) # 用户名属性 (uid/sAMAccountName) username_attr = Column(
String(50), default="uid", nullable=False
) # 用户名属性 (uid/sAMAccountName)
email_attr = Column(String(50), default="mail", nullable=False) # 邮箱属性 email_attr = Column(String(50), default="mail", nullable=False) # 邮箱属性
display_name_attr = Column(String(50), default="cn", nullable=False) # 显示名称属性 display_name_attr = Column(String(50), default="cn", nullable=False) # 显示名称属性
is_enabled = Column(Boolean, default=False, nullable=False) # 是否启用 LDAP 认证 is_enabled = Column(Boolean, default=False, nullable=False) # 是否启用 LDAP 认证
@@ -706,6 +710,14 @@ class ProviderEndpoint(Base):
# 额外配置 # 额外配置
config = Column(JSON, nullable=True) # 端点特定配置(不推荐使用,优先使用专用字段) config = Column(JSON, nullable=True) # 端点特定配置(不推荐使用,优先使用专用字段)
# 格式转换配置
format_acceptance_config = Column(
JSON,
nullable=True,
default=None,
comment="格式接受策略配置(跨格式转换开关/白黑名单等)",
)
# 代理配置 # 代理配置
proxy = Column(JSONB, nullable=True) # 代理配置: {url, username, password} proxy = Column(JSONB, nullable=True) # 代理配置: {url, username, password}
@@ -1041,8 +1053,7 @@ class Model(Base):
# 获取所有最高优先级的映射 # 获取所有最高优先级的映射
top_priority_mappings = [ top_priority_mappings = [
mapping for mapping in sorted_mappings mapping for mapping in sorted_mappings if mapping["priority"] == highest_priority
if mapping["priority"] == highest_priority
] ]
# 如果有多个相同优先级的映射,通过哈希分散选择 # 如果有多个相同优先级的映射,通过哈希分散选择
@@ -1119,9 +1130,7 @@ class ProviderAPIKey(Base):
# 示例: {"cache_1h": true, "context_1m": true} # 示例: {"cache_1h": true, "context_1m": true}
# 自适应 RPM 调整(仅当 rpm_limit = NULL 时生效) # 自适应 RPM 调整(仅当 rpm_limit = NULL 时生效)
learned_rpm_limit = Column( learned_rpm_limit = Column(Integer, nullable=True) # 学习到的 RPM 限制(自适应模式下的有效值)
Integer, nullable=True
) # 学习到的 RPM 限制(自适应模式下的有效值)
concurrent_429_count = Column(Integer, default=0, nullable=False) # 因并发导致的429次数 concurrent_429_count = Column(Integer, default=0, nullable=False) # 因并发导致的429次数
rpm_429_count = Column(Integer, default=0, nullable=False) # 因RPM导致的429次数 rpm_429_count = Column(Integer, default=0, nullable=False) # 因RPM导致的429次数
last_429_at = Column(DateTime(timezone=True), nullable=True) # 最后429时间 last_429_at = Column(DateTime(timezone=True), nullable=True) # 最后429时间
@@ -1132,9 +1141,7 @@ class ProviderAPIKey(Base):
utilization_samples = Column( utilization_samples = Column(
JSON, nullable=True JSON, nullable=True
) # 利用率采样窗口 [{"ts": timestamp, "util": 0.8}, ...] ) # 利用率采样窗口 [{"ts": timestamp, "util": 0.8}, ...]
last_probe_increase_at = Column( last_probe_increase_at = Column(DateTime(timezone=True), nullable=True) # 上次探测性扩容时间
DateTime(timezone=True), nullable=True
) # 上次探测性扩容时间
# 健康度追踪(按 API 格式存储) # 健康度追踪(按 API 格式存储)
# 结构: {"CLAUDE": {"health_score": 1.0, "consecutive_failures": 0, "last_failure_at": null, "request_results_window": []}, ...} # 结构: {"CLAUDE": {"health_score": 1.0, "consecutive_failures": 0, "last_failure_at": null, "request_results_window": []}, ...}
@@ -1568,7 +1575,9 @@ class RequestCandidate(Base):
) )
# 状态信息 # 状态信息
status = Column(String(20), nullable=False) # 'pending', 'streaming', 'success', 'failed', 'cancelled', 'skipped' status = Column(
String(20), nullable=False
) # 'pending', 'streaming', 'success', 'failed', 'cancelled', 'skipped'
skip_reason = Column(Text, nullable=True) # 跳过/失败原因 skip_reason = Column(Text, nullable=True) # 跳过/失败原因
is_cached = Column(Boolean, default=False) # 是否为缓存亲和性候选 is_cached = Column(Boolean, default=False) # 是否为缓存亲和性候选

View File

@@ -45,6 +45,12 @@ class ProviderEndpointCreate(BaseModel):
# 代理配置 # 代理配置
proxy: Optional[ProxyConfig] = Field(default=None, description="代理配置") proxy: Optional[ProxyConfig] = Field(default=None, description="代理配置")
# 格式转换配置
format_acceptance_config: Optional[Dict[str, Any]] = Field(
default=None,
description="格式接受策略配置(跨格式转换开关/白黑名单等)",
)
@field_validator("api_format") @field_validator("api_format")
@classmethod @classmethod
def validate_api_format(cls, v: str) -> str: def validate_api_format(cls, v: str) -> str:
@@ -85,6 +91,12 @@ class ProviderEndpointUpdate(BaseModel):
config: Optional[Dict[str, Any]] = Field(default=None, description="额外配置") config: Optional[Dict[str, Any]] = Field(default=None, description="额外配置")
proxy: Optional[ProxyConfig] = Field(default=None, description="代理配置") proxy: Optional[ProxyConfig] = Field(default=None, description="代理配置")
# 格式转换配置
format_acceptance_config: Optional[Dict[str, Any]] = Field(
default=None,
description="格式接受策略配置(跨格式转换开关/白黑名单等)",
)
@field_validator("base_url") @field_validator("base_url")
@classmethod @classmethod
def validate_base_url(cls, v: Optional[str]) -> Optional[str]: def validate_base_url(cls, v: Optional[str]) -> Optional[str]:
@@ -126,6 +138,12 @@ class ProviderEndpointResponse(BaseModel):
# 代理配置(响应中密码已脱敏) # 代理配置(响应中密码已脱敏)
proxy: Optional[Dict[str, Any]] = Field(default=None, description="代理配置(密码已脱敏)") proxy: Optional[Dict[str, Any]] = Field(default=None, description="代理配置(密码已脱敏)")
# 格式转换配置
format_acceptance_config: Optional[Dict[str, Any]] = Field(
default=None,
description="格式接受策略配置(跨格式转换开关/白黑名单等)",
)
# 统计(从 Keys 聚合) # 统计(从 Keys 聚合)
total_keys: int = Field(default=0, description="总 Key 数量") total_keys: int = Field(default=0, description="总 Key 数量")
active_keys: int = Field(default=0, description="活跃 Key 数量") active_keys: int = Field(default=0, description="活跃 Key 数量")

View File

@@ -35,7 +35,7 @@ import random
import re import re
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
@@ -65,6 +65,7 @@ from src.services.rate_limit.adaptive_reservation import (
get_adaptive_reservation_manager, get_adaptive_reservation_manager,
) )
from src.services.rate_limit.concurrency_manager import get_concurrency_manager from src.services.rate_limit.concurrency_manager import get_concurrency_manager
from src.services.system.config import SystemConfigService
@dataclass @dataclass
@@ -78,6 +79,8 @@ class ProviderCandidate:
is_skipped: bool = False # 是否被跳过 is_skipped: bool = False # 是否被跳过
skip_reason: Optional[str] = None # 跳过原因 skip_reason: Optional[str] = None # 跳过原因
mapping_matched_model: Optional[str] = None # 通过映射匹配到的模型名(用于实际请求) mapping_matched_model: Optional[str] = None # 通过映射匹配到的模型名(用于实际请求)
needs_conversion: bool = False # 是否需要格式转换
provider_api_format: str = "" # Provider 端点实际格式(用于健康度/熔断 bucket
@dataclass @dataclass
@@ -129,7 +132,10 @@ class CacheAwareScheduler:
} }
def __init__( def __init__(
self, redis_client=None, priority_mode: Optional[str] = None, scheduling_mode: Optional[str] = None self,
redis_client=None,
priority_mode: Optional[str] = None,
scheduling_mode: Optional[str] = None,
): ):
""" """
初始化调度器 初始化调度器
@@ -149,7 +155,9 @@ class CacheAwareScheduler:
self.scheduling_mode = self._normalize_scheduling_mode( self.scheduling_mode = self._normalize_scheduling_mode(
scheduling_mode or self.SCHEDULING_MODE_CACHE_AFFINITY scheduling_mode or self.SCHEDULING_MODE_CACHE_AFFINITY
) )
logger.debug(f"[CacheAwareScheduler] 初始化优先级模式: {self.priority_mode}, 调度模式: {self.scheduling_mode}") logger.debug(
f"[CacheAwareScheduler] 初始化优先级模式: {self.priority_mode}, 调度模式: {self.scheduling_mode}"
)
# 初始化子组件(将在第一次使用时异步初始化) # 初始化子组件(将在第一次使用时异步初始化)
self._affinity_manager: Optional[CacheAffinityManager] = None self._affinity_manager: Optional[CacheAffinityManager] = None
@@ -429,7 +437,9 @@ class CacheAwareScheduler:
import math import math
# 与 ConcurrencyManager 的 Lua 脚本保持一致:使用 floor 计算新用户可用槽位 # 与 ConcurrencyManager 的 Lua 脚本保持一致:使用 floor 计算新用户可用槽位
available_for_new = max(1, math.floor(effective_key_limit * (1 - reservation_ratio))) available_for_new = max(
1, math.floor(effective_key_limit * (1 - reservation_ratio))
)
if key_count >= available_for_new: if key_count >= available_for_new:
logger.debug( logger.debug(
f"Key {key.id[:8]}... 新用户配额已满 " f"Key {key.id[:8]}... 新用户配额已满 "
@@ -531,8 +541,7 @@ class CacheAwareScheduler:
# 合并 allowed_api_formats # 合并 allowed_api_formats
result["allowed_api_formats"] = merge_restrictions( result["allowed_api_formats"] = merge_restrictions(
user_api_key.allowed_api_formats, user_api_key.allowed_api_formats, user.allowed_api_formats if user else None
user.allowed_api_formats if user else None
) )
return result return result
@@ -578,7 +587,9 @@ class CacheAwareScheduler:
target_format = normalize_api_format(api_format) target_format = normalize_api_format(api_format)
# 0. 解析 model_name 到 GlobalModel支持直接匹配和映射名匹配使用 ModelCacheService # 0. 解析 model_name 到 GlobalModel支持直接匹配和映射名匹配使用 ModelCacheService
global_model = await ModelCacheService.resolve_global_model_by_name_or_mapping(db, model_name) global_model = await ModelCacheService.resolve_global_model_by_name_or_mapping(
db, model_name
)
if not global_model: if not global_model:
logger.warning(f"GlobalModel not found: {model_name}") logger.warning(f"GlobalModel not found: {model_name}")
@@ -592,7 +603,9 @@ class CacheAwareScheduler:
# 提取模型映射(用于 Provider Key 的 allowed_models 匹配) # 提取模型映射(用于 Provider Key 的 allowed_models 匹配)
model_mappings: List[str] = (global_model.config or {}).get("model_mappings", []) model_mappings: List[str] = (global_model.config or {}).get("model_mappings", [])
if model_mappings: if model_mappings:
logger.debug(f"[Scheduler] GlobalModel={global_model.name} 配置了映射规则: {model_mappings}") logger.debug(
f"[Scheduler] GlobalModel={global_model.name} 配置了映射规则: {model_mappings}"
)
# 获取合并后的访问限制ApiKey + User # 获取合并后的访问限制ApiKey + User
restrictions = self._get_effective_restrictions(user_api_key) restrictions = self._get_effective_restrictions(user_api_key)
@@ -654,10 +667,13 @@ class CacheAwareScheduler:
return [], global_model_id return [], global_model_id
# 2. 构建候选列表(传入 is_stream 和 capability_requirements 用于过滤) # 2. 构建候选列表(传入 is_stream 和 capability_requirements 用于过滤)
global_conversion_enabled = bool(
SystemConfigService.get_config(db, "format_conversion_enabled", False)
)
candidates = await self._build_candidates( candidates = await self._build_candidates(
db=db, db=db,
providers=providers, providers=providers,
target_format=target_format, client_format=target_format,
model_name=requested_model_name, model_name=requested_model_name,
resolved_model_name=resolved_model_name, resolved_model_name=resolved_model_name,
model_mappings=model_mappings, model_mappings=model_mappings,
@@ -665,6 +681,7 @@ class CacheAwareScheduler:
max_candidates=max_candidates, max_candidates=max_candidates,
is_stream=is_stream, is_stream=is_stream,
capability_requirements=capability_requirements, capability_requirements=capability_requirements,
global_conversion_enabled=global_conversion_enabled,
) )
# 3. 应用优先级模式排序 # 3. 应用优先级模式排序
@@ -774,15 +791,25 @@ class CacheAwareScheduler:
- provider_model_names: Provider 侧可用的模型名称集合(主名称 + 映射名称,按 api_format 过滤) - provider_model_names: Provider 侧可用的模型名称集合(主名称 + 映射名称,按 api_format 过滤)
""" """
# 使用 ModelCacheService 解析模型名称(支持映射名) # 使用 ModelCacheService 解析模型名称(支持映射名)
global_model = await ModelCacheService.resolve_global_model_by_name_or_mapping(db, model_name) global_model = await ModelCacheService.resolve_global_model_by_name_or_mapping(
db, model_name
)
if not global_model: if not global_model:
# 完全未找到匹配 # 完全未找到匹配
return False, "模型不存在或 Provider 未配置此模型", None, None return False, "模型不存在或 Provider 未配置此模型", None, None
# 找到 GlobalModel 后,检查当前 Provider 是否支持 # 找到 GlobalModel 后,检查当前 Provider 是否支持
is_supported, skip_reason, caps, provider_model_names = await self._check_model_support_for_global_model( is_supported, skip_reason, caps, provider_model_names = (
db, provider, global_model, model_name, api_format, is_stream, capability_requirements 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 return is_supported, skip_reason, caps, provider_model_names
@@ -814,6 +841,7 @@ class CacheAwareScheduler:
# 注意:从缓存重建的对象是 transient 状态,不能使用 load=False # 注意:从缓存重建的对象是 transient 状态,不能使用 load=False
# 使用 load=True默认允许 SQLAlchemy 正确处理 transient 对象 # 使用 load=True默认允许 SQLAlchemy 正确处理 transient 对象
from sqlalchemy import inspect from sqlalchemy import inspect
insp = inspect(global_model) insp = inspect(global_model)
if insp.transient or insp.detached: if insp.transient or insp.detached:
# transient/detached 对象:使用默认 merge会查询 DB 检查是否存在) # transient/detached 对象:使用默认 merge会查询 DB 检查是否存在)
@@ -940,12 +968,18 @@ class CacheAwareScheduler:
return False, f"映射规则无效: {str(e)}", None return False, f"映射规则无效: {str(e)}", None
except Exception as e: except Exception as e:
# 其他未知异常 # 其他未知异常
logger.error(f"映射匹配异常: key_id={key.id}, model={model_name}, error={e}", exc_info=True) logger.error(
f"映射匹配异常: key_id={key.id}, model={model_name}, error={e}", exc_info=True
)
# 异常时保守处理:不允许使用该 Key # 异常时保守处理:不允许使用该 Key
return False, "映射匹配失败", None return False, "映射匹配失败", None
if not is_allowed: if not is_allowed:
return False, f"模型权限不匹配(允许: {get_allowed_models_preview(key.allowed_models)})", None return (
False,
f"模型权限不匹配(允许: {get_allowed_models_preview(key.allowed_models)})",
None,
)
# Key 级别的能力匹配检查 # Key 级别的能力匹配检查
# 注意:模型级别的能力检查已在 _check_model_support 中完成 # 注意:模型级别的能力检查已在 _check_model_support 中完成
@@ -964,7 +998,7 @@ class CacheAwareScheduler:
self, self,
db: Session, db: Session,
providers: List[Provider], providers: List[Provider],
target_format: APIFormat, client_format: APIFormat,
model_name: str, model_name: str,
affinity_key: Optional[str], affinity_key: Optional[str],
resolved_model_name: Optional[str] = None, resolved_model_name: Optional[str] = None,
@@ -972,16 +1006,17 @@ class CacheAwareScheduler:
max_candidates: Optional[int] = None, max_candidates: Optional[int] = None,
is_stream: bool = False, is_stream: bool = False,
capability_requirements: Optional[Dict[str, bool]] = None, capability_requirements: Optional[Dict[str, bool]] = None,
global_conversion_enabled: bool = False,
) -> List[ProviderCandidate]: ) -> List[ProviderCandidate]:
""" """
构建候选列表 构建候选列表
Key 直属 Provider通过 api_formats 筛选符合目标格式的 Key。 Key 直属 Provider通过 api_formats 筛选符合端点格式的 Key。
Args: Args:
db: 数据库会话 db: 数据库会话
providers: Provider 列表 providers: Provider 列表
target_format: 目标 API 格式 client_format: 客户端请求的 API 格式
model_name: 模型名称(用户请求的名称,可能是映射名) model_name: 模型名称(用户请求的名称,可能是映射名)
affinity_key: 亲和性标识符通常为API Key ID affinity_key: 亲和性标识符通常为API Key ID
resolved_model_name: 解析后的 GlobalModel.name用于 Key.allowed_models 校验) resolved_model_name: 解析后的 GlobalModel.name用于 Key.allowed_models 校验)
@@ -989,89 +1024,122 @@ class CacheAwareScheduler:
max_candidates: 最大候选数 max_candidates: 最大候选数
is_stream: 是否是流式请求,如果为 True 则过滤不支持流式的 Provider is_stream: 是否是流式请求,如果为 True 则过滤不支持流式的 Provider
capability_requirements: 能力需求(可选) capability_requirements: 能力需求(可选)
global_conversion_enabled: 全局格式转换开关
Returns: Returns:
候选列表 候选列表
""" """
from src.core.api_format.conversion.compatibility import is_format_compatible
candidates: List[ProviderCandidate] = [] candidates: List[ProviderCandidate] = []
target_format_str = target_format.value client_format_str = client_format.value
for provider in providers: for provider in providers:
# 检查模型支持(同时检查流式支持和模型能力需求) # 按端点格式分别判断兼容性与模型/Key 可用性:
supports_model, skip_reason, _model_caps, provider_model_names = await self._check_model_support( # - 同格式端点优先needs_conversion=False
db, # - 跨格式端点次之needs_conversion=True
provider, model_support_cache: Dict[
model_name, str, Tuple[bool, Optional[str], Optional[List[str]], Optional[Set[str]]]
api_format=target_format_str, ] = {}
is_stream=is_stream, exact_candidates: List[ProviderCandidate] = []
capability_requirements=capability_requirements, convertible_candidates: List[ProviderCandidate] = []
)
if not supports_model: for endpoint in provider.endpoints:
logger.debug(f"Provider {provider.name} 不支持模型 {model_name}: {skip_reason}") if not endpoint.is_active:
continue continue
# 查找目标格式对应的 Endpoint获取请求配置
target_endpoint = None
for endpoint in provider.endpoints:
endpoint_format_str = ( endpoint_format_str = (
endpoint.api_format endpoint.api_format
if isinstance(endpoint.api_format, str) if isinstance(endpoint.api_format, str)
else endpoint.api_format.value else endpoint.api_format.value
) )
if endpoint.is_active and endpoint_format_str == target_format_str:
target_endpoint = endpoint
break
if not target_endpoint: is_compatible, needs_conversion, _compat_reason = is_format_compatible(
logger.debug(f"Provider {provider.name} 没有活跃的 {target_format_str} 端点") client_format_str,
endpoint_format_str,
getattr(endpoint, "format_acceptance_config", None),
is_stream,
global_conversion_enabled,
)
if not is_compatible:
continue continue
# Key 直属 Provider通过 api_formats 筛选 # 检查模型支持(按端点格式过滤 provider_model_mappings
active_keys = [ if endpoint_format_str not in model_support_cache:
key for key in provider.api_keys model_support_cache[endpoint_format_str] = await self._check_model_support(
if key.is_active and target_format_str in (key.api_formats or []) 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]
)
if not supports_model:
logger.debug(
f"Provider {provider.name} 端点 {endpoint_format_str} 不支持模型 {model_name}: {skip_reason}"
)
continue
# Key 直属 Provider通过 api_formats 按端点格式筛选
active_keys = [
key
for key in provider.api_keys
if key.is_active and endpoint_format_str in (key.api_formats or [])
]
if not active_keys: if not active_keys:
logger.debug(f"Provider {provider.name} 没有支持 {target_format_str} 的活跃 Key")
continue continue
# 检查是否所有 Key 都是 TTL=0轮换模式 # 检查是否所有 Key 都是 TTL=0轮换模式
use_random = all( use_random = all((key.cache_ttl_minutes or 0) == 0 for key in active_keys)
(key.cache_ttl_minutes or 0) == 0 for key in active_keys
) if active_keys else False
if use_random and len(active_keys) > 1: if use_random and len(active_keys) > 1:
logger.debug( logger.debug(
f" Provider {provider.name} 启用 Key 轮换模式 (TTL=0, {len(active_keys)} keys)" 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
) )
keys = self._shuffle_keys_by_internal_priority(active_keys, affinity_key, use_random)
for key in keys: for key in keys:
# Key 级别的能力检查 # Key 级别检查(健康度/熔断按 provider_format bucket
# 注意:不传入 candidate_models 限制,允许映射匹配到 Key 的 allowed_models 中的任意模型名 # 注意:不传入 candidate_models,保持原有映射匹配行为
# 这支持以下场景Key 只允许使用 gpt-5.2,而 GlobalModel 配置了映射 gpt-5.*2 is_available, key_skip_reason, mapping_matched_model = (
# 映射匹配后,实际请求会使用 gpt-5.2 作为模型名发送给 Provider self._check_key_availability(
is_available, skip_reason, mapping_matched_model = self._check_key_availability(
key, key,
target_format_str, endpoint_format_str,
model_name, model_name,
capability_requirements, capability_requirements,
resolved_model_name=resolved_model_name, resolved_model_name=resolved_model_name,
model_mappings=model_mappings, model_mappings=model_mappings,
) )
)
candidate = ProviderCandidate( candidate = ProviderCandidate(
provider=provider, provider=provider,
endpoint=target_endpoint, endpoint=endpoint,
key=key, key=key,
is_skipped=not is_available, is_skipped=not is_available,
skip_reason=skip_reason, skip_reason=key_skip_reason,
mapping_matched_model=mapping_matched_model, mapping_matched_model=mapping_matched_model,
needs_conversion=needs_conversion,
provider_api_format=str(endpoint_format_str or "").upper(),
) )
candidates.append(candidate)
if max_candidates and len(candidates) >= max_candidates: if needs_conversion:
return candidates 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 return candidates
@@ -1173,7 +1241,9 @@ class CacheAwareScheduler:
normalized = (mode or "").strip().lower() normalized = (mode or "").strip().lower()
if normalized not in self.ALLOWED_SCHEDULING_MODES: if normalized not in self.ALLOWED_SCHEDULING_MODES:
if normalized: if normalized:
logger.warning(f"[CacheAwareScheduler] 无效的调度模式 '{mode}',回退为 cache_affinity") logger.warning(
f"[CacheAwareScheduler] 无效的调度模式 '{mode}',回退为 cache_affinity"
)
return self.SCHEDULING_MODE_CACHE_AFFINITY return self.SCHEDULING_MODE_CACHE_AFFINITY
return normalized return normalized
@@ -1186,8 +1256,10 @@ class CacheAwareScheduler:
logger.debug(f"[CacheAwareScheduler] 切换调度模式为: {self.scheduling_mode}") logger.debug(f"[CacheAwareScheduler] 切换调度模式为: {self.scheduling_mode}")
def _apply_priority_mode_sort( def _apply_priority_mode_sort(
self, candidates: List[ProviderCandidate], affinity_key: Optional[str] = None, self,
api_format: Optional[str] = None candidates: List[ProviderCandidate],
affinity_key: Optional[str] = None,
api_format: Optional[str] = None,
) -> List[ProviderCandidate]: ) -> List[ProviderCandidate]:
""" """
根据优先级模式对候选列表排序(数字越小越优先) 根据优先级模式对候选列表排序(数字越小越优先)
@@ -1209,8 +1281,10 @@ class CacheAwareScheduler:
return candidates return candidates
def _sort_by_global_priority_with_hash( def _sort_by_global_priority_with_hash(
self, candidates: List[ProviderCandidate], affinity_key: Optional[str] = None, self,
api_format: Optional[str] = None candidates: List[ProviderCandidate],
affinity_key: Optional[str] = None,
api_format: Optional[str] = None,
) -> List[ProviderCandidate]: ) -> List[ProviderCandidate]:
""" """
按 global_priority_by_format 分组排序,同优先级内通过哈希分散实现负载均衡 按 global_priority_by_format 分组排序,同优先级内通过哈希分散实现负载均衡

View File

@@ -30,7 +30,6 @@ from src.services.rate_limit.adaptive_rpm import get_adaptive_rpm_manager
from src.services.rate_limit.detector import RateLimitType, detect_rate_limit_type from src.services.rate_limit.detector import RateLimitType, detect_rate_limit_type
class ErrorAction(Enum): class ErrorAction(Enum):
"""错误处理动作""" """错误处理动作"""
@@ -391,10 +390,12 @@ class ErrorClassifier:
current_usage=current_rpm, current_usage=current_rpm,
) )
logger.info(f" [{request_id}] 429错误分析: " logger.info(
f" [{request_id}] 429错误分析: "
f"类型={rate_limit_info.limit_type}, " f"类型={rate_limit_info.limit_type}, "
f"retry_after={rate_limit_info.retry_after}s, " f"retry_after={rate_limit_info.retry_after}s, "
f"当前RPM={current_rpm}") f"当前RPM={current_rpm}"
)
# 调用自适应管理器处理 # 调用自适应管理器处理
new_limit = self.adaptive_manager.handle_429_error( new_limit = self.adaptive_manager.handle_429_error(
@@ -408,7 +409,9 @@ class ErrorClassifier:
logger.warning(f" [{request_id}] 并发限制触发不调整RPM") logger.warning(f" [{request_id}] 并发限制触发不调整RPM")
return "concurrent" return "concurrent"
elif rate_limit_info.limit_type == RateLimitType.RPM: elif rate_limit_info.limit_type == RateLimitType.RPM:
logger.warning(f" [{request_id}] 自适应调整: Key {key.id[:8]}... RPM限制 -> {new_limit}") logger.warning(
f" [{request_id}] 自适应调整: Key {key.id[:8]}... RPM限制 -> {new_limit}"
)
return "rpm" return "rpm"
else: else:
return "unknown" return "unknown"
@@ -545,8 +548,10 @@ class ErrorClassifier:
except Exception: except Exception:
pass pass
logger.warning(f" [{request_id}] HTTP错误 (attempt={attempt}/{max_attempts}): " logger.warning(
f"{http_error.response.status_code if http_error.response else 'unknown'}") f" [{request_id}] HTTP错误 (attempt={attempt}/{max_attempts}): "
f"{http_error.response.status_code if http_error.response else 'unknown'}"
)
converted_error = self.convert_http_error(http_error, provider_name, error_response_text) converted_error = self.convert_http_error(http_error, provider_name, error_response_text)
@@ -557,16 +562,25 @@ class ErrorClassifier:
if error_response_text: if error_response_text:
extra_data["error_response"] = error_response_text extra_data["error_response"] = error_response_text
# 转换 api_format 为字符串 # client_format:用于缓存亲和性/缓存失效(用户视角)
api_format_str = ( client_format_str = (
normalize_api_format(api_format).value normalize_api_format(api_format).value
if isinstance(api_format, (str, APIFormat)) if isinstance(api_format, (str, APIFormat))
else str(api_format) else str(api_format)
) )
# provider_format用于健康度/熔断 bucketProvider 真实端点格式)
provider_api_format = getattr(endpoint, "api_format", None)
provider_format_str = (
provider_api_format.value
if isinstance(provider_api_format, APIFormat)
else str(provider_api_format or client_format_str)
).upper()
# 处理客户端请求错误(不应重试,不失效缓存,不记录健康失败) # 处理客户端请求错误(不应重试,不失效缓存,不记录健康失败)
if isinstance(converted_error, UpstreamClientException): if isinstance(converted_error, UpstreamClientException):
logger.warning(f" [{request_id}] 客户端请求错误,不进行重试: {converted_error.message}") logger.warning(
f" [{request_id}] 客户端请求错误,不进行重试: {converted_error.message}"
)
return extra_data return extra_data
# 处理认证错误 # 处理认证错误
@@ -574,7 +588,7 @@ class ErrorClassifier:
if endpoint and key and self.cache_scheduler is not None: if endpoint and key and self.cache_scheduler is not None:
await self.cache_scheduler.invalidate_cache( await self.cache_scheduler.invalidate_cache(
affinity_key=affinity_key, affinity_key=affinity_key,
api_format=api_format_str, api_format=client_format_str,
global_model_id=global_model_id, global_model_id=global_model_id,
endpoint_id=str(endpoint.id), endpoint_id=str(endpoint.id),
key_id=str(key.id), key_id=str(key.id),
@@ -583,7 +597,7 @@ class ErrorClassifier:
health_monitor.record_failure( health_monitor.record_failure(
db=self.db, db=self.db,
key_id=str(key.id), key_id=str(key.id),
api_format=api_format_str, api_format=provider_format_str,
error_type="ProviderAuthException", error_type="ProviderAuthException",
) )
return extra_data return extra_data
@@ -600,7 +614,7 @@ class ErrorClassifier:
if endpoint and self.cache_scheduler is not None: if endpoint and self.cache_scheduler is not None:
await self.cache_scheduler.invalidate_cache( await self.cache_scheduler.invalidate_cache(
affinity_key=affinity_key, affinity_key=affinity_key,
api_format=api_format_str, api_format=client_format_str,
global_model_id=global_model_id, global_model_id=global_model_id,
endpoint_id=str(endpoint.id), endpoint_id=str(endpoint.id),
key_id=str(key.id), key_id=str(key.id),
@@ -610,7 +624,7 @@ class ErrorClassifier:
if endpoint and key and self.cache_scheduler is not None: if endpoint and key and self.cache_scheduler is not None:
await self.cache_scheduler.invalidate_cache( await self.cache_scheduler.invalidate_cache(
affinity_key=affinity_key, affinity_key=affinity_key,
api_format=api_format_str, api_format=client_format_str,
global_model_id=global_model_id, global_model_id=global_model_id,
endpoint_id=str(endpoint.id), endpoint_id=str(endpoint.id),
key_id=str(key.id), key_id=str(key.id),
@@ -621,7 +635,7 @@ class ErrorClassifier:
health_monitor.record_failure( health_monitor.record_failure(
db=self.db, db=self.db,
key_id=str(key.id), key_id=str(key.id),
api_format=api_format_str, api_format=provider_format_str,
error_type=type(converted_error).__name__, error_type=type(converted_error).__name__,
) )
@@ -662,15 +676,24 @@ class ErrorClassifier:
""" """
provider_name = str(provider.name) provider_name = str(provider.name)
logger.warning(f" [{request_id}] 请求失败 (attempt={attempt}/{max_attempts}): " logger.warning(
f"{type(error).__name__}: {str(error)}") f" [{request_id}] 请求失败 (attempt={attempt}/{max_attempts}): "
f"{type(error).__name__}: {str(error)}"
)
# 转换 api_format 为字符串 # client_format:用于缓存亲和性/缓存失效(用户视角)
api_format_str = ( client_format_str = (
normalize_api_format(api_format).value normalize_api_format(api_format).value
if isinstance(api_format, (str, APIFormat)) if isinstance(api_format, (str, APIFormat))
else str(api_format) else str(api_format)
) )
# provider_format用于健康度/熔断 bucketProvider 真实端点格式)
provider_api_format = getattr(endpoint, "api_format", None)
provider_format_str = (
provider_api_format.value
if isinstance(provider_api_format, APIFormat)
else str(provider_api_format or client_format_str)
).upper()
# 处理限流错误 # 处理限流错误
if isinstance(error, ProviderRateLimitException) and key: if isinstance(error, ProviderRateLimitException) and key:
@@ -684,7 +707,7 @@ class ErrorClassifier:
if endpoint and self.cache_scheduler is not None: if endpoint and self.cache_scheduler is not None:
await self.cache_scheduler.invalidate_cache( await self.cache_scheduler.invalidate_cache(
affinity_key=affinity_key, affinity_key=affinity_key,
api_format=api_format_str, api_format=client_format_str,
global_model_id=global_model_id, global_model_id=global_model_id,
endpoint_id=str(endpoint.id), endpoint_id=str(endpoint.id),
key_id=str(key.id), key_id=str(key.id),
@@ -693,7 +716,7 @@ class ErrorClassifier:
# 其他错误也失效缓存 # 其他错误也失效缓存
await self.cache_scheduler.invalidate_cache( await self.cache_scheduler.invalidate_cache(
affinity_key=affinity_key, affinity_key=affinity_key,
api_format=api_format_str, api_format=client_format_str,
global_model_id=global_model_id, global_model_id=global_model_id,
endpoint_id=str(endpoint.id), endpoint_id=str(endpoint.id),
key_id=str(key.id), key_id=str(key.id),
@@ -704,6 +727,6 @@ class ErrorClassifier:
health_monitor.record_failure( health_monitor.record_failure(
db=self.db, db=self.db,
key_id=str(key.id), key_id=str(key.id),
api_format=api_format_str, api_format=provider_format_str,
error_type=type(error).__name__, error_type=type(error).__name__,
) )

View File

@@ -29,7 +29,7 @@ import httpx
from redis import Redis from redis import Redis
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.core.api_format import APIFormat from src.core.api_format import APIFormat, FormatConversionError
from src.core.error_utils import extract_error_message from src.core.error_utils import extract_error_message
from src.core.exceptions import ( from src.core.exceptions import (
ConcurrencyLimitError, ConcurrencyLimitError,
@@ -385,7 +385,9 @@ class FallbackOrchestrator:
"provider_id": str(provider.id), "provider_id": str(provider.id),
"provider_endpoint_id": str(endpoint.id), "provider_endpoint_id": str(endpoint.id),
"provider_api_key_id": str(key.id), "provider_api_key_id": str(key.id),
"api_format": api_format.value if hasattr(api_format, "value") else str(api_format), "api_format": (
api_format.value if hasattr(api_format, "value") else str(api_format)
),
} }
raise client_error raise client_error
else: else:
@@ -425,10 +427,14 @@ class FallbackOrchestrator:
# 检查是否为客户端请求错误(不应重试) # 检查是否为客户端请求错误(不应重试)
converted_error = extra_data.get("converted_error") converted_error = extra_data.get("converted_error")
# 从 extra_data 中移除 converted_error避免序列化问题 # 从 extra_data 中移除 converted_error避免序列化问题
serializable_extra_data = {k: v for k, v in extra_data.items() if k != "converted_error"} serializable_extra_data = {
k: v for k, v in extra_data.items() if k != "converted_error"
}
if isinstance(converted_error, UpstreamClientException): if isinstance(converted_error, UpstreamClientException):
logger.warning(f" [{request_id}] 客户端请求错误,停止重试: {converted_error.message}") logger.warning(
f" [{request_id}] 客户端请求错误,停止重试: {converted_error.message}"
)
RequestCandidateService.mark_candidate_failed( RequestCandidateService.mark_candidate_failed(
db=self.db, db=self.db,
candidate_id=candidate_record_id, candidate_id=candidate_record_id,
@@ -445,7 +451,9 @@ class FallbackOrchestrator:
"provider_id": str(provider.id), "provider_id": str(provider.id),
"provider_endpoint_id": str(endpoint.id), "provider_endpoint_id": str(endpoint.id),
"provider_api_key_id": str(key.id), "provider_api_key_id": str(key.id),
"api_format": api_format.value if hasattr(api_format, "value") else str(api_format), "api_format": (
api_format.value if hasattr(api_format, "value") else str(api_format)
),
} }
raise converted_error raise converted_error
@@ -487,6 +495,19 @@ class FallbackOrchestrator:
) )
return "continue" if has_retry_left else "break" return "continue" if has_retry_left else "break"
# 格式转换错误:视为候选不可用,直接切换到下一个候选(不记录健康失败)
if isinstance(cause, FormatConversionError):
logger.warning(f" [{request_id}] 格式转换失败,切换候选: {cause}")
RequestCandidateService.mark_candidate_failed(
db=self.db,
candidate_id=candidate_record_id,
error_type="FormatConversionError",
error_message=str(cause),
latency_ms=elapsed_ms,
concurrent_requests=captured_key_concurrent,
)
return "break"
# 未知错误:记录失败并抛出 # 未知错误:记录失败并抛出
RequestCandidateService.mark_candidate_failed( RequestCandidateService.mark_candidate_failed(
db=self.db, db=self.db,
@@ -552,8 +573,10 @@ class FallbackOrchestrator:
last_candidate = candidate last_candidate = candidate
if candidate.is_skipped: if candidate.is_skipped:
logger.debug(f" [{request_id}] 跳过候选: Provider={candidate.provider.name}, " logger.debug(
f"Reason={candidate.skip_reason}") f" [{request_id}] 跳过候选: Provider={candidate.provider.name}, "
f"Reason={candidate.skip_reason}"
)
continue continue
result = await self._try_candidate_with_retries( result = await self._try_candidate_with_retries(
@@ -573,7 +596,9 @@ class FallbackOrchestrator:
) )
if result["success"]: if result["success"]:
response: Tuple[Any, str, Optional[str], Optional[str], Optional[str], Optional[str]] = result["response"] response: Tuple[
Any, str, Optional[str], Optional[str], Optional[str], Optional[str]
] = result["response"]
return response return response
# 更新计数器和错误信息 # 更新计数器和错误信息
@@ -582,7 +607,9 @@ class FallbackOrchestrator:
if result.get("error"): if result.get("error"):
last_error = result["error"] last_error = result["error"]
if result.get("should_raise") and last_error is not None: if result.get("should_raise") and last_error is not None:
self._attach_metadata_to_error(last_error, last_candidate, model_name, api_format_enum) self._attach_metadata_to_error(
last_error, last_candidate, model_name, api_format_enum
)
raise last_error raise last_error
# 所有组合都已尝试完毕,全部失败 # 所有组合都已尝试完毕,全部失败
@@ -620,9 +647,13 @@ class FallbackOrchestrator:
if retry_index == 0: if retry_index == 0:
# 首次尝试该候选 # 首次尝试该候选
cache_hint = " (cached)" if candidate.is_cached else "" cache_hint = " (cached)" if candidate.is_cached else ""
logger.info(f" [{request_id[:8] if request_id else 'N/A'}] -> {provider.name}{cache_hint}") logger.info(
f" [{request_id[:8] if request_id else 'N/A'}] -> {provider.name}{cache_hint}"
)
else: else:
logger.info(f" [{request_id[:8] if request_id else 'N/A'}] -> {provider.name} (retry {retry_index})") logger.info(
f" [{request_id[:8] if request_id else 'N/A'}] -> {provider.name} (retry {retry_index})"
)
candidate_record_id = candidate_record_map[(candidate_index, retry_index)] candidate_record_id = candidate_record_map[(candidate_index, retry_index)]
@@ -706,14 +737,14 @@ class FallbackOrchestrator:
), ),
provider=getattr(existing_metadata, "provider", None) or str(candidate.provider.name), provider=getattr(existing_metadata, "provider", None) or str(candidate.provider.name),
model=getattr(existing_metadata, "model", None) or model_name, model=getattr(existing_metadata, "model", None) or model_name,
provider_id=getattr(existing_metadata, "provider_id", None) or str(candidate.provider.id), provider_id=getattr(existing_metadata, "provider_id", None)
or str(candidate.provider.id),
provider_endpoint_id=( provider_endpoint_id=(
getattr(existing_metadata, "provider_endpoint_id", None) getattr(existing_metadata, "provider_endpoint_id", None)
or str(candidate.endpoint.id) or str(candidate.endpoint.id)
), ),
provider_api_key_id=( provider_api_key_id=(
getattr(existing_metadata, "provider_api_key_id", None) getattr(existing_metadata, "provider_api_key_id", None) or str(candidate.key.id)
or str(candidate.key.id)
), ),
api_format=api_format_enum.value, api_format=api_format_enum.value,
) )
@@ -821,12 +852,16 @@ class FallbackOrchestrator:
user_id = str(user_api_key.user_id) user_id = str(user_api_key.user_id)
api_format_enum = normalize_api_format(api_format) api_format_enum = normalize_api_format(api_format)
logger.debug(f"[FallbackOrchestrator] execute_with_fallback 被调用: " logger.debug(
f"[FallbackOrchestrator] execute_with_fallback 被调用: "
f"api_format={api_format_enum.value}, model_name={model_name}, " f"api_format={api_format_enum.value}, model_name={model_name}, "
f"request_id={request_id}, is_stream={is_stream}") f"request_id={request_id}, is_stream={is_stream}"
)
# 创建 pending 状态的使用记录 # 创建 pending 状态的使用记录
self._create_pending_usage_record(request_id, user_api_key, model_name, is_stream, api_format_enum) self._create_pending_usage_record(
request_id, user_api_key, model_name, is_stream, api_format_enum
)
# 1. 收集所有候选(同时获取规范化的 global_model_id 用于缓存亲和性) # 1. 收集所有候选(同时获取规范化的 global_model_id 用于缓存亲和性)
all_candidates, global_model_id = await self._fetch_all_candidates( all_candidates, global_model_id = await self._fetch_all_candidates(

View File

@@ -12,11 +12,11 @@ from src.core.api_format import APIFormat
from src.core.exceptions import ConcurrencyLimitError from src.core.exceptions import ConcurrencyLimitError
from src.core.logger import logger from src.core.logger import logger
from src.services.health.monitor import health_monitor from src.services.health.monitor import health_monitor
from src.services.provider.format import normalize_api_format
from src.services.rate_limit.adaptive_reservation import get_adaptive_reservation_manager from src.services.rate_limit.adaptive_reservation import get_adaptive_reservation_manager
from src.services.request.candidate import RequestCandidateService from src.services.request.candidate import RequestCandidateService
@dataclass @dataclass
class ExecutionContext: class ExecutionContext:
candidate_id: str candidate_id: str
@@ -103,7 +103,9 @@ class RequestExecutor:
# 获取有效的 RPM 限制(自适应或固定) # 获取有效的 RPM 限制(自适应或固定)
if key.rpm_limit is None: if key.rpm_limit is None:
# 自适应模式:使用学习值,未学习时为 None不限制等待碰壁学习 # 自适应模式:使用学习值,未学习时为 None不限制等待碰壁学习
effective_key_limit = int(key.learned_rpm_limit) if key.learned_rpm_limit is not None else None effective_key_limit = (
int(key.learned_rpm_limit) if key.learned_rpm_limit is not None else None
)
else: else:
effective_key_limit = int(key.rpm_limit) effective_key_limit = int(key.rpm_limit)
@@ -114,9 +116,11 @@ class RequestExecutor:
) )
dynamic_reservation_ratio = reservation_result.ratio dynamic_reservation_ratio = reservation_result.ratio
logger.debug(f"[Executor] 动态预留: key={key.id[:8]}..., " logger.debug(
f"[Executor] 动态预留: key={key.id[:8]}..., "
f"ratio={dynamic_reservation_ratio:.0%}, phase={reservation_result.phase}, " f"ratio={dynamic_reservation_ratio:.0%}, phase={reservation_result.phase}, "
f"confidence={reservation_result.confidence:.0%}") f"confidence={reservation_result.confidence:.0%}"
)
async with self.concurrency_manager.rpm_guard( async with self.concurrency_manager.rpm_guard(
key_id=key.id, key_id=key.id,
@@ -140,12 +144,21 @@ class RequestExecutor:
context.elapsed_ms = int((time.time() - context.start_time) * 1000) context.elapsed_ms = int((time.time() - context.start_time) * 1000)
provider_api_format = getattr(endpoint, "api_format", None)
provider_format_str = (
provider_api_format.value
if isinstance(provider_api_format, APIFormat)
else str(provider_api_format or "")
)
client_format_str = (
api_format.value if isinstance(api_format, APIFormat) else str(api_format)
)
health_format = normalize_api_format(provider_format_str or client_format_str).value
health_monitor.record_success( health_monitor.record_success(
db=self.db, db=self.db,
key_id=key.id, key_id=key.id,
api_format=( api_format=health_format,
api_format.value if isinstance(api_format, APIFormat) else api_format
),
response_time_ms=context.elapsed_ms, response_time_ms=context.elapsed_ms,
) )
@@ -180,7 +193,9 @@ class RequestExecutor:
"is_cached_user": is_cached_user, "is_cached_user": is_cached_user,
"model_name": model_name, "model_name": model_name,
"api_format": ( "api_format": (
api_format.value if isinstance(api_format, APIFormat) else api_format api_format.value
if isinstance(api_format, APIFormat)
else api_format
), ),
}, },
) )

View File

@@ -118,6 +118,10 @@ class SystemConfigService:
"value": "cache_affinity", "value": "cache_affinity",
"description": "调度模式fixed_order(固定顺序模式,严格按优先级顺序) 或 cache_affinity(缓存亲和模式优先使用已缓存的Provider)", "description": "调度模式fixed_order(固定顺序模式,严格按优先级顺序) 或 cache_affinity(缓存亲和模式优先使用已缓存的Provider)",
}, },
"format_conversion_enabled": {
"value": False,
"description": "是否启用全局格式自动转换(需要端点配置 format_acceptance_config 才能生效)",
},
"auto_delete_expired_keys": { "auto_delete_expired_keys": {
"value": False, "value": False,
"description": "是否自动删除过期的API KeyTrue=物理删除False=仅禁用),仅管理员可配置", "description": "是否自动删除过期的API KeyTrue=物理删除False=仅禁用),仅管理员可配置",

View File

@@ -0,0 +1,103 @@
import json
from typing import Any, Dict, Optional
from unittest.mock import AsyncMock
import pytest
from src.api.handlers.base.response_parser import ParsedResponse, ResponseParser, StreamStats
from src.api.handlers.base.stream_context import StreamContext
from src.api.handlers.base.stream_processor import StreamProcessor
from src.core.api_format import register_all_converters
class DummyParser(ResponseParser):
def parse_sse_line(self, line: str, stats: StreamStats) -> Optional[Any]: # noqa: ANN401
return None
def parse_response(self, response: Dict[str, Any], status_code: int) -> ParsedResponse:
return ParsedResponse(raw_response=response, status_code=status_code)
def extract_usage_from_response(self, response: Dict[str, Any]) -> Dict[str, int]:
return {}
def extract_text_content(self, response: Dict[str, Any]) -> str:
return ""
async def _empty_async_iter():
if False: # pragma: no cover
yield b""
@pytest.mark.asyncio
async def test_create_response_stream_converts_claude_to_openai() -> None:
register_all_converters()
ctx = StreamContext(model="test-model", api_format="OPENAI")
ctx.client_api_format = "OPENAI"
ctx.provider_api_format = "CLAUDE"
ctx.needs_conversion = True
processor = StreamProcessor(request_id="test-request", default_parser=DummyParser())
response_ctx = AsyncMock()
response_ctx.__aexit__ = AsyncMock(return_value=None)
http_client = AsyncMock()
http_client.aclose = AsyncMock(return_value=None)
message_start = {
"type": "message_start",
"message": {
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "claude-test",
"content": [],
"stop_reason": None,
"stop_sequence": None,
},
}
content_delta = {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "Hi"},
}
prefetched_chunks = [
b"event: message_start\n",
f"data: {json.dumps(message_start)}\n".encode("utf-8"),
b"\n",
f"data: {json.dumps(content_delta)}\n".encode("utf-8"),
b"\n",
]
out = b"".join(
[
chunk
async for chunk in processor.create_response_stream(
ctx,
byte_iterator=_empty_async_iter(),
response_ctx=response_ctx,
http_client=http_client,
prefetched_chunks=prefetched_chunks,
)
]
)
text = out.decode("utf-8")
assert "event:" not in text
events = []
for line in text.splitlines():
if line.startswith("data: "):
events.append(json.loads(line[6:]))
assert len(events) >= 2
assert any(e.get("object") == "chat.completion.chunk" for e in events)
assert any(
e.get("choices", [{}])[0].get("delta", {}).get("content") == "Hi"
for e in events
if isinstance(e, dict)
)

View File

@@ -0,0 +1,161 @@
"""
is_format_compatible 单元测试
覆盖:
- 同格式透传
- CLI 格式禁止转换
- 全局开关/端点开关/白黑名单
- 流式转换开关
- 转换器能力校验
"""
from unittest.mock import MagicMock
from src.core.api_format.conversion.compatibility import is_format_compatible
def test_same_format_is_compatible() -> None:
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"CLAUDE",
endpoint_format_acceptance_config=None,
is_stream=False,
global_conversion_enabled=False,
registry=MagicMock(),
)
assert ok is True
assert needs_conv is False
assert reason is None
def test_cli_format_not_convertible() -> None:
ok, needs_conv, reason = is_format_compatible(
"CLAUDE_CLI",
"OPENAI",
endpoint_format_acceptance_config={"enabled": True},
is_stream=False,
global_conversion_enabled=True,
registry=MagicMock(),
)
assert ok is False
assert needs_conv is False
assert reason and "CLI" in reason
def test_global_switch_disabled_blocks_conversion() -> None:
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"OPENAI",
endpoint_format_acceptance_config={"enabled": True},
is_stream=False,
global_conversion_enabled=False,
registry=MagicMock(),
)
assert ok is False
assert needs_conv is False
assert reason and "全局" in reason
def test_endpoint_config_none_blocks_conversion() -> None:
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"OPENAI",
endpoint_format_acceptance_config=None,
is_stream=False,
global_conversion_enabled=True,
registry=MagicMock(),
)
assert ok is False
assert needs_conv is False
assert reason and "未配置" in reason
def test_endpoint_disabled_blocks_conversion() -> None:
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"OPENAI",
endpoint_format_acceptance_config={"enabled": False},
is_stream=False,
global_conversion_enabled=True,
registry=MagicMock(),
)
assert ok is False
assert needs_conv is False
assert reason and "未启用" in reason
def test_accept_formats_allows_only_whitelist() -> None:
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"OPENAI",
endpoint_format_acceptance_config={"enabled": True, "accept_formats": ["OPENAI"]},
is_stream=False,
global_conversion_enabled=True,
registry=MagicMock(),
)
assert ok is False
assert needs_conv is False
assert reason and "不接受" in reason
def test_reject_formats_blocks_blacklist() -> None:
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"OPENAI",
endpoint_format_acceptance_config={"enabled": True, "reject_formats": ["CLAUDE"]},
is_stream=False,
global_conversion_enabled=True,
registry=MagicMock(),
)
assert ok is False
assert needs_conv is False
assert reason and "拒绝" in reason
def test_stream_conversion_disabled_blocks_stream() -> None:
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"OPENAI",
endpoint_format_acceptance_config={"enabled": True, "stream_conversion": False},
is_stream=True,
global_conversion_enabled=True,
registry=MagicMock(),
)
assert ok is False
assert needs_conv is False
assert reason and "流式" in reason
def test_converter_support_required() -> None:
registry = MagicMock()
registry.can_convert_full.return_value = False
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"OPENAI",
endpoint_format_acceptance_config={"enabled": True},
is_stream=False,
global_conversion_enabled=True,
registry=registry,
)
assert ok is False
assert needs_conv is False
assert reason and "转换器" in reason
def test_conversion_allowed_when_converter_supports_full() -> None:
registry = MagicMock()
registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"OPENAI",
endpoint_format_acceptance_config={"enabled": True, "accept_formats": ["CLAUDE"]},
is_stream=False,
global_conversion_enabled=True,
registry=registry,
)
assert ok is True
assert needs_conv is True
assert reason is None

View File

@@ -403,4 +403,4 @@ class TestNonStrictConversion:
original = {"chunk": "data"} original = {"chunk": "data"}
result = registry.convert_stream_chunk(original, "A", "B") result = registry.convert_stream_chunk(original, "A", "B")
assert result == original assert result == [original]

View File

@@ -0,0 +1,125 @@
import pytest
from unittest.mock import AsyncMock, MagicMock
from src.core.api_format import APIFormat, register_all_converters
from src.services.cache.aware_scheduler import CacheAwareScheduler
def _mock_key(key_id: str, api_formats: list[str]) -> MagicMock:
key = MagicMock()
key.id = key_id
key.is_active = True
key.api_formats = api_formats
key.cache_ttl_minutes = 1
key.internal_priority = 1
return key
def _mock_endpoint(api_format: str, config: dict | None = None) -> MagicMock:
endpoint = MagicMock()
endpoint.id = f"ep_{api_format.lower()}"
endpoint.is_active = True
endpoint.api_format = api_format
endpoint.format_acceptance_config = config
return endpoint
@pytest.mark.asyncio
async def test_build_candidates_blocks_cross_format_when_global_switch_off() -> None:
register_all_converters()
scheduler = CacheAwareScheduler()
scheduler._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[attr-defined]
scheduler._check_key_availability = MagicMock(return_value=(True, None, None)) # type: ignore[attr-defined]
provider = MagicMock()
provider.name = "p1"
provider.endpoints = [
_mock_endpoint(
"OPENAI",
{"enabled": True, "accept_formats": ["CLAUDE"], "stream_conversion": True},
)
]
provider.api_keys = [_mock_key("k1", ["OPENAI"])]
candidates = await scheduler._build_candidates(
db=MagicMock(),
providers=[provider],
client_format=APIFormat.CLAUDE,
model_name="dummy-model",
affinity_key=None,
global_conversion_enabled=False,
)
assert candidates == []
@pytest.mark.asyncio
async def test_build_candidates_includes_cross_format_when_enabled() -> None:
register_all_converters()
scheduler = CacheAwareScheduler()
scheduler._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[attr-defined]
scheduler._check_key_availability = MagicMock(return_value=(True, None, None)) # type: ignore[attr-defined]
provider = MagicMock()
provider.name = "p1"
provider.endpoints = [
_mock_endpoint(
"OPENAI",
{"enabled": True, "accept_formats": ["CLAUDE"], "stream_conversion": True},
)
]
provider.api_keys = [_mock_key("k1", ["OPENAI"])]
candidates = await scheduler._build_candidates(
db=MagicMock(),
providers=[provider],
client_format=APIFormat.CLAUDE,
model_name="dummy-model",
affinity_key=None,
global_conversion_enabled=True,
)
assert len(candidates) == 1
assert candidates[0].needs_conversion is True
assert candidates[0].provider_api_format == "OPENAI"
@pytest.mark.asyncio
async def test_exact_matches_rank_before_convertible() -> None:
register_all_converters()
scheduler = CacheAwareScheduler()
scheduler._check_model_support = AsyncMock(return_value=(True, None, None, {"m"})) # type: ignore[attr-defined]
scheduler._check_key_availability = MagicMock(return_value=(True, None, None)) # type: ignore[attr-defined]
provider = MagicMock()
provider.name = "p1"
# 故意把 OPENAI 放在 endpoints[0],验证排序仍然是 CLAUDEexact在前
provider.endpoints = [
_mock_endpoint(
"OPENAI",
{"enabled": True, "accept_formats": ["CLAUDE"], "stream_conversion": True},
),
_mock_endpoint("CLAUDE", None),
]
provider.api_keys = [
_mock_key("k_openai", ["OPENAI"]),
_mock_key("k_claude", ["CLAUDE"]),
]
candidates = await scheduler._build_candidates(
db=MagicMock(),
providers=[provider],
client_format=APIFormat.CLAUDE,
model_name="dummy-model",
affinity_key=None,
global_conversion_enabled=True,
)
assert len(candidates) == 2
assert candidates[0].needs_conversion is False
assert candidates[0].provider_api_format == "CLAUDE"
assert candidates[1].needs_conversion is True
assert candidates[1].provider_api_format == "OPENAI"

View File

@@ -0,0 +1,110 @@
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.core.api_format import APIFormat
from src.services.orchestration.error_classifier import ErrorClassifier
from src.services.request.executor import RequestExecutor
@asynccontextmanager
async def _noop_async_cm():
yield
@pytest.mark.asyncio
async def test_executor_records_health_by_provider_format() -> None:
db = MagicMock()
concurrency_manager = MagicMock()
concurrency_manager.get_current_concurrency = AsyncMock(return_value=(0, 0))
concurrency_manager.get_key_rpm_count = AsyncMock(return_value=1)
concurrency_manager.rpm_guard = MagicMock(return_value=_noop_async_cm())
adaptive_manager = MagicMock()
provider = MagicMock()
provider.id = "p1"
provider.name = "p1"
endpoint = MagicMock()
endpoint.id = "e1"
endpoint.api_format = "OPENAI"
key = MagicMock()
key.id = "k1"
key.api_key = "encrypted"
key.rpm_limit = 10
key.learned_rpm_limit = None
key.cache_ttl_minutes = 0
candidate = MagicMock()
candidate.provider = provider
candidate.endpoint = endpoint
candidate.key = key
candidate.is_cached = False
async def request_func(_provider, _endpoint, _key, _candidate): # noqa: ANN001
return {"ok": True}
with patch("src.services.request.executor.RequestCandidateService.mark_candidate_started"), patch(
"src.services.request.executor.RequestCandidateService.mark_candidate_success"
), patch("src.services.request.executor.get_adaptive_reservation_manager") as mock_res_mgr, patch(
"src.services.request.executor.health_monitor.record_success"
) as record_success:
mock_res_mgr.return_value.calculate_reservation.return_value = MagicMock(
ratio=0.0, phase="stable", confidence=1.0
)
executor = RequestExecutor(db=db, concurrency_manager=concurrency_manager, adaptive_manager=adaptive_manager)
await executor.execute(
candidate=candidate,
candidate_id="c1",
candidate_index=0,
user_api_key=MagicMock(user_id="u1", id="ak1"),
request_func=request_func,
request_id="r1",
api_format=APIFormat.CLAUDE, # client_format
model_name="m",
is_stream=False,
)
record_success.assert_called()
assert record_success.call_args.kwargs["api_format"] == "OPENAI"
@pytest.mark.asyncio
async def test_error_classifier_records_failure_by_provider_format() -> None:
db = MagicMock()
classifier = ErrorClassifier(db=db, cache_scheduler=None, adaptive_manager=MagicMock())
provider = MagicMock()
provider.name = "p1"
endpoint = MagicMock()
endpoint.id = "e1"
endpoint.api_format = "OPENAI"
key = MagicMock()
key.id = "k1"
with patch("src.services.orchestration.error_classifier.health_monitor.record_failure") as record_failure:
await classifier.handle_retriable_error(
error=RuntimeError("boom"),
provider=provider,
endpoint=endpoint,
key=key,
affinity_key="aff",
api_format=APIFormat.CLAUDE, # client_format
global_model_id="gm1",
captured_key_concurrent=None,
elapsed_ms=None,
request_id="r1",
attempt=1,
max_attempts=2,
)
record_failure.assert_called()
assert record_failure.call_args.kwargs["api_format"] == "OPENAI"