mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(test,export,headers): 模型测试复用统一运行时、实时进度展示、导出增强与请求头大小写保留
- 模型测试 failover 从手动 FailoverEngine 改为 TaskService.execute_sync_candidates 统一运行时 - 前端新增实时 trace 轮询进度展示(候选状态、测试账号、进度条) - 用户导出/导入支持明文 Key 优先(版本升至 1.2),新增 email_verified 字段 - SENSITIVE_CREDENTIAL_FIELDS 统一到 provider_ops/types.py,补充 refresh_token - 请求头大小写保留机制(resolve_header_name_case + HeaderBuilder.add 语义修改) - Codex envelope 移除合成头部,保留客户端原始请求头 - endpoint_checker 支持自定义超时透传 - 新增 x-forwarded-scheme 到上游丢弃头部列表
This commit is contained in:
@@ -34,6 +34,12 @@ export interface OAuthProviderExport {
|
|||||||
is_enabled?: boolean
|
is_enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SystemConfigExport {
|
||||||
|
key: string
|
||||||
|
value: unknown
|
||||||
|
description?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
// 配置导出数据结构
|
// 配置导出数据结构
|
||||||
export interface ConfigExportData {
|
export interface ConfigExportData {
|
||||||
version: string
|
version: string
|
||||||
@@ -42,6 +48,7 @@ export interface ConfigExportData {
|
|||||||
providers: ProviderExport[]
|
providers: ProviderExport[]
|
||||||
ldap_config?: LDAPConfigExport | null
|
ldap_config?: LDAPConfigExport | null
|
||||||
oauth_providers?: OAuthProviderExport[]
|
oauth_providers?: OAuthProviderExport[]
|
||||||
|
system_configs?: SystemConfigExport[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户导出数据结构
|
// 用户导出数据结构
|
||||||
@@ -54,6 +61,7 @@ export interface UsersExportData {
|
|||||||
|
|
||||||
export interface UserExport {
|
export interface UserExport {
|
||||||
email: string
|
email: string
|
||||||
|
email_verified?: boolean
|
||||||
username: string
|
username: string
|
||||||
password_hash: string
|
password_hash: string
|
||||||
role: string
|
role: string
|
||||||
@@ -69,6 +77,7 @@ export interface UserExport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UserApiKeyExport {
|
export interface UserApiKeyExport {
|
||||||
|
key?: string | null
|
||||||
key_hash: string
|
key_hash: string
|
||||||
key_encrypted?: string | null
|
key_encrypted?: string | null
|
||||||
name?: string | null
|
name?: string | null
|
||||||
@@ -105,14 +114,18 @@ export interface ProviderExport {
|
|||||||
name: string
|
name: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
website?: string | null
|
website?: string | null
|
||||||
|
provider_type?: string
|
||||||
billing_type?: string | null
|
billing_type?: string | null
|
||||||
monthly_quota_usd?: number | null
|
monthly_quota_usd?: number | null
|
||||||
quota_reset_day?: number
|
quota_reset_day?: number
|
||||||
rpm_limit?: number | null
|
|
||||||
provider_priority?: number
|
provider_priority?: number
|
||||||
|
keep_priority_on_conversion?: boolean
|
||||||
|
enable_format_conversion?: boolean
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
concurrent_limit?: number | null
|
concurrent_limit?: number | null
|
||||||
max_retries?: number | null
|
max_retries?: number | null
|
||||||
|
stream_first_byte_timeout?: number | null
|
||||||
|
request_timeout?: number | null
|
||||||
proxy?: Record<string, unknown>
|
proxy?: Record<string, unknown>
|
||||||
config?: Record<string, unknown>
|
config?: Record<string, unknown>
|
||||||
endpoints: EndpointExport[]
|
endpoints: EndpointExport[]
|
||||||
@@ -123,19 +136,24 @@ export interface ProviderExport {
|
|||||||
export interface EndpointExport {
|
export interface EndpointExport {
|
||||||
api_format: string
|
api_format: string
|
||||||
base_url: string
|
base_url: string
|
||||||
headers?: Record<string, unknown>
|
header_rules?: Record<string, unknown>[] | null
|
||||||
|
body_rules?: Record<string, unknown>[] | null
|
||||||
max_retries?: number
|
max_retries?: number
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
custom_path?: string | null
|
custom_path?: string | null
|
||||||
config?: Record<string, unknown>
|
config?: Record<string, unknown>
|
||||||
|
format_acceptance_config?: Record<string, unknown> | null
|
||||||
proxy?: Record<string, unknown>
|
proxy?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderKeyExport {
|
export interface ProviderKeyExport {
|
||||||
api_key: string
|
api_key: string
|
||||||
|
auth_type?: string
|
||||||
|
auth_config?: string | Record<string, unknown> | null
|
||||||
name?: string | null
|
name?: string | null
|
||||||
note?: string | null
|
note?: string | null
|
||||||
api_formats: string[]
|
api_formats: string[]
|
||||||
|
supported_endpoints?: string[]
|
||||||
rate_multipliers?: Record<string, number> | null
|
rate_multipliers?: Record<string, number> | null
|
||||||
internal_priority?: number
|
internal_priority?: number
|
||||||
global_priority_by_format?: Record<string, number> | null
|
global_priority_by_format?: Record<string, number> | null
|
||||||
@@ -144,7 +162,13 @@ export interface ProviderKeyExport {
|
|||||||
capabilities?: Record<string, boolean>
|
capabilities?: Record<string, boolean>
|
||||||
cache_ttl_minutes?: number
|
cache_ttl_minutes?: number
|
||||||
max_probe_interval_minutes?: number
|
max_probe_interval_minutes?: number
|
||||||
|
auto_fetch_models?: boolean
|
||||||
|
locked_models?: string[] | null
|
||||||
|
model_include_patterns?: string[] | null
|
||||||
|
model_exclude_patterns?: string[] | null
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
|
proxy?: Record<string, unknown> | null
|
||||||
|
fingerprint?: Record<string, unknown> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelExport {
|
export interface ModelExport {
|
||||||
|
|||||||
@@ -131,7 +131,9 @@ export interface TestModelResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function testModel(data: TestModelRequest): Promise<TestModelResponse> {
|
export async function testModel(data: TestModelRequest): Promise<TestModelResponse> {
|
||||||
const response = await client.post('/api/admin/provider-query/test-model', data)
|
const response = await client.post('/api/admin/provider-query/test-model', data, {
|
||||||
|
timeout: 10 * 60 * 1000,
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,10 +147,12 @@ export interface TestModelFailoverRequest {
|
|||||||
api_format?: string
|
api_format?: string
|
||||||
endpoint_id?: string
|
endpoint_id?: string
|
||||||
message?: string
|
message?: string
|
||||||
|
request_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TestAttemptDetail {
|
export interface TestAttemptDetail {
|
||||||
candidate_index: number
|
candidate_index: number
|
||||||
|
retry_index?: number
|
||||||
endpoint_api_format: string
|
endpoint_api_format: string
|
||||||
endpoint_base_url: string
|
endpoint_base_url: string
|
||||||
key_name: string | null
|
key_name: string | null
|
||||||
@@ -174,7 +178,9 @@ export interface TestModelFailoverResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function testModelFailover(data: TestModelFailoverRequest): Promise<TestModelFailoverResponse> {
|
export async function testModelFailover(data: TestModelFailoverRequest): Promise<TestModelFailoverResponse> {
|
||||||
const response = await client.post('/api/admin/provider-query/test-model-failover', data)
|
const response = await client.post('/api/admin/provider-query/test-model-failover', data, {
|
||||||
|
timeout: 10 * 60 * 1000,
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface CandidateRecord {
|
|||||||
endpoint_name?: string // 端点显示名称(api_format)
|
endpoint_name?: string // 端点显示名称(api_format)
|
||||||
key_id?: string
|
key_id?: string
|
||||||
key_name?: string // 密钥名称
|
key_name?: string // 密钥名称
|
||||||
|
key_account_label?: string // 更适合展示的测试账号标签(优先 OAuth 邮箱)
|
||||||
key_preview?: string // 密钥脱敏预览(如 sk-***abc),OAuth 类型不返回
|
key_preview?: string // 密钥脱敏预览(如 sk-***abc),OAuth 类型不返回
|
||||||
key_auth_type?: string // 密钥认证类型(api_key, service_account, oauth 等)
|
key_auth_type?: string // 密钥认证类型(api_key, service_account, oauth 等)
|
||||||
key_oauth_plan_type?: string // OAuth 账号套餐类型(free/plus/team/enterprise)
|
key_oauth_plan_type?: string // OAuth 账号套餐类型(free/plus/team/enterprise)
|
||||||
|
|||||||
@@ -41,22 +41,138 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="testing"
|
v-else-if="testing"
|
||||||
class="flex flex-col items-center justify-center gap-3 py-10 text-center"
|
class="space-y-4 py-6"
|
||||||
>
|
>
|
||||||
<Loader2 class="w-8 h-8 animate-spin text-primary" />
|
<div class="flex flex-col items-center justify-center gap-3 text-center">
|
||||||
<div class="space-y-1">
|
<Loader2 class="w-8 h-8 animate-spin text-primary" />
|
||||||
<p class="text-sm font-medium">
|
<div class="space-y-1">
|
||||||
正在测试模型
|
<p class="text-sm font-medium">
|
||||||
</p>
|
正在测试模型
|
||||||
<p class="text-xs text-muted-foreground">
|
</p>
|
||||||
{{ selectingModelName || '-' }}
|
<p class="text-xs text-muted-foreground">
|
||||||
</p>
|
{{ selectingModelName || '-' }}
|
||||||
<p
|
</p>
|
||||||
v-if="selectedEndpoint"
|
<p
|
||||||
class="text-xs text-muted-foreground"
|
v-if="selectedEndpoint"
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
端点:{{ formatApiFormat(selectedEndpoint.api_format) }} · {{ selectedEndpoint.base_url }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>实时进度</span>
|
||||||
|
<span>{{ liveTraceSummary.completed }}/{{ liveTraceSummary.total || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
class="h-full bg-primary transition-all duration-300"
|
||||||
|
:style="{ width: `${liveProgressPercent}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
class="text-[10px] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
待执行 {{ liveTraceSummary.available }}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="text-[10px] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
进行中 {{ liveTraceSummary.pending }}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="success"
|
||||||
|
class="text-[10px] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
成功 {{ liveTraceSummary.success }}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
class="text-[10px] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
失败 {{ liveTraceSummary.failed }}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
class="text-[10px] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
跳过 {{ liveTraceSummary.skipped }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div class="rounded-md border border-border/60 bg-background/80 p-3 space-y-1">
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
测试账号
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-medium break-all">
|
||||||
|
{{ liveAccountTitle }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground break-all">
|
||||||
|
{{ liveAccountMeta }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md border border-border/60 bg-background/80 p-3 space-y-1">
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
实时状态
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
{{ liveStatusTitle }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground break-all">
|
||||||
|
{{ liveStatusDetail }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="requestId"
|
||||||
|
class="text-[11px] text-muted-foreground break-all"
|
||||||
>
|
>
|
||||||
端点:{{ formatApiFormat(selectedEndpoint.api_format) }} · {{ selectedEndpoint.base_url }}
|
请求 ID:<code class="bg-muted px-1 py-0.5 rounded">{{ requestId }}</code>
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="liveRecentCandidates.length > 0"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<div class="text-xs font-medium text-muted-foreground">
|
||||||
|
最近状态
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="candidate in liveRecentCandidates"
|
||||||
|
:key="`${candidate.id}-${candidate.status}`"
|
||||||
|
class="flex items-start justify-between gap-3 rounded-md border border-border/50 bg-background/70 px-3 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 space-y-1">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<span class="text-muted-foreground shrink-0">{{ formatTraceCandidateIndex(candidate) }}</span>
|
||||||
|
<Badge
|
||||||
|
:variant="statusVariant(candidate.status)"
|
||||||
|
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||||
|
>
|
||||||
|
{{ candidate.status_code || statusLabel(candidate.status) }}
|
||||||
|
</Badge>
|
||||||
|
<span class="truncate font-medium">{{ formatTraceCandidateAccount(candidate) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground break-all">
|
||||||
|
{{ traceCandidateDetail(candidate) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0 text-muted-foreground tabular-nums">
|
||||||
|
{{ candidate.latency_ms != null ? `${candidate.latency_ms}ms` : '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,7 +234,7 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-1.5 min-w-0">
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
<span class="text-muted-foreground shrink-0">#{{ attempt.candidate_index }}</span>
|
<span class="text-muted-foreground shrink-0">{{ formatAttemptIndex(attempt) }}</span>
|
||||||
<Badge
|
<Badge
|
||||||
:variant="statusVariant(attempt.status)"
|
:variant="statusVariant(attempt.status)"
|
||||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||||
@@ -209,7 +325,7 @@
|
|||||||
:class="attemptRowClass(attempt.status)"
|
:class="attemptRowClass(attempt.status)"
|
||||||
>
|
>
|
||||||
<td class="pl-3 pr-1 py-2 text-muted-foreground">
|
<td class="pl-3 pr-1 py-2 text-muted-foreground">
|
||||||
{{ attempt.candidate_index }}
|
{{ formatAttemptIndex(attempt) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2">
|
<td class="px-3 py-2">
|
||||||
<div
|
<div
|
||||||
@@ -292,6 +408,7 @@ import { Dialog, Badge } from '@/components/ui'
|
|||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||||
import type { TestModelFailoverResponse, TestAttemptDetail } from '@/api/endpoints/providers'
|
import type { TestModelFailoverResponse, TestAttemptDetail } from '@/api/endpoints/providers'
|
||||||
|
import type { CandidateRecord, RequestTrace } from '@/api/requestTrace'
|
||||||
|
|
||||||
type TestEndpointOption = {
|
type TestEndpointOption = {
|
||||||
id: string
|
id: string
|
||||||
@@ -308,6 +425,8 @@ const props = defineProps<{
|
|||||||
endpoints?: TestEndpointOption[]
|
endpoints?: TestEndpointOption[]
|
||||||
selectedEndpoint?: TestEndpointOption | null
|
selectedEndpoint?: TestEndpointOption | null
|
||||||
testing?: boolean
|
testing?: boolean
|
||||||
|
trace?: RequestTrace | null
|
||||||
|
requestId?: string | null
|
||||||
showEndpointSelector?: boolean
|
showEndpointSelector?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -318,6 +437,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const endpoints = computed(() => props.endpoints ?? [])
|
const endpoints = computed(() => props.endpoints ?? [])
|
||||||
|
const traceCandidates = computed(() => props.trace?.candidates ?? [])
|
||||||
const showSelection = computed(() => props.open && !!props.showEndpointSelector && !props.testing && !props.result)
|
const showSelection = computed(() => props.open && !!props.showEndpointSelector && !props.testing && !props.result)
|
||||||
const showResult = computed(() => !!props.result)
|
const showResult = computed(() => !!props.result)
|
||||||
const canReselect = computed(() => !!props.showEndpointSelector && endpoints.value.length > 1)
|
const canReselect = computed(() => !!props.showEndpointSelector && endpoints.value.length > 1)
|
||||||
@@ -354,6 +474,82 @@ const hasEffectiveModel = computed(() => {
|
|||||||
return props.result.attempts.some(a => a.effective_model && a.effective_model !== props.result?.model)
|
return props.result.attempts.some(a => a.effective_model && a.effective_model !== props.result?.model)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const liveTraceSummary = computed(() => {
|
||||||
|
const summary = {
|
||||||
|
total: traceCandidates.value.length,
|
||||||
|
available: 0,
|
||||||
|
pending: 0,
|
||||||
|
success: 0,
|
||||||
|
failed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
completed: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of traceCandidates.value) {
|
||||||
|
if (candidate.status === 'available' || candidate.status === 'unused') summary.available += 1
|
||||||
|
if (candidate.status === 'pending' || candidate.status === 'streaming') summary.pending += 1
|
||||||
|
if (candidate.status === 'success') summary.success += 1
|
||||||
|
if (candidate.status === 'failed' || candidate.status === 'cancelled' || candidate.status === 'stream_interrupted') summary.failed += 1
|
||||||
|
if (candidate.status === 'skipped') summary.skipped += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.completed = summary.success + summary.failed + summary.skipped
|
||||||
|
return summary
|
||||||
|
})
|
||||||
|
|
||||||
|
const liveProgressPercent = computed(() => {
|
||||||
|
if (liveTraceSummary.value.total <= 0) return 6
|
||||||
|
const raw = Math.round((liveTraceSummary.value.completed / liveTraceSummary.value.total) * 100)
|
||||||
|
return Math.min(100, Math.max(raw, liveTraceSummary.value.pending > 0 ? 12 : 6))
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeTraceCandidate = computed(() => {
|
||||||
|
const preferredStatuses = ['pending', 'streaming', 'failed', 'success', 'skipped', 'cancelled']
|
||||||
|
for (let index = traceCandidates.value.length - 1; index >= 0; index -= 1) {
|
||||||
|
const candidate = traceCandidates.value[index]
|
||||||
|
if (preferredStatuses.includes(candidate.status)) return candidate
|
||||||
|
}
|
||||||
|
return traceCandidates.value[0] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const liveAccountTitle = computed(() => {
|
||||||
|
const candidate = activeTraceCandidate.value
|
||||||
|
if (!candidate) return '等待分配测试账号'
|
||||||
|
return candidate.key_account_label || candidate.key_name || candidate.key_preview || '等待分配测试账号'
|
||||||
|
})
|
||||||
|
|
||||||
|
const liveAccountMeta = computed(() => {
|
||||||
|
const candidate = activeTraceCandidate.value
|
||||||
|
if (!candidate) return '候选创建后会显示测试账号和认证方式'
|
||||||
|
const parts: string[] = []
|
||||||
|
if (candidate.key_auth_type) parts.push(formatAuthType(candidate.key_auth_type))
|
||||||
|
if (candidate.key_oauth_plan_type) parts.push(candidate.key_oauth_plan_type)
|
||||||
|
if (candidate.key_preview && candidate.key_preview !== candidate.key_account_label) parts.push(candidate.key_preview)
|
||||||
|
return parts.join(' · ') || '正在等待候选进入执行阶段'
|
||||||
|
})
|
||||||
|
|
||||||
|
const liveStatusTitle = computed(() => {
|
||||||
|
const candidate = activeTraceCandidate.value
|
||||||
|
if (!candidate) return '正在创建测试请求'
|
||||||
|
if (candidate.status === 'pending' || candidate.status === 'streaming') {
|
||||||
|
return `正在测试 ${formatTraceCandidateIndex(candidate)}`
|
||||||
|
}
|
||||||
|
return statusLabel(candidate.status)
|
||||||
|
})
|
||||||
|
|
||||||
|
const liveStatusDetail = computed(() => {
|
||||||
|
const candidate = activeTraceCandidate.value
|
||||||
|
if (!candidate) return '等待后端写入候选状态'
|
||||||
|
return traceCandidateDetail(candidate)
|
||||||
|
})
|
||||||
|
|
||||||
|
const liveRecentCandidates = computed(() => {
|
||||||
|
return traceCandidates.value
|
||||||
|
.filter(candidate => !['available', 'unused'].includes(candidate.status))
|
||||||
|
.slice(-4)
|
||||||
|
.reverse()
|
||||||
|
})
|
||||||
|
|
||||||
function statusVariant(status: string) {
|
function statusVariant(status: string) {
|
||||||
if (status === 'success') return 'success' as const
|
if (status === 'success') return 'success' as const
|
||||||
if (status === 'failed') return 'destructive' as const
|
if (status === 'failed') return 'destructive' as const
|
||||||
@@ -364,6 +560,11 @@ function statusLabel(status: string) {
|
|||||||
if (status === 'success') return '成功'
|
if (status === 'success') return '成功'
|
||||||
if (status === 'failed') return '失败'
|
if (status === 'failed') return '失败'
|
||||||
if (status === 'skipped') return '跳过'
|
if (status === 'skipped') return '跳过'
|
||||||
|
if (status === 'pending') return '等待中'
|
||||||
|
if (status === 'streaming') return '测试中'
|
||||||
|
if (status === 'cancelled') return '已取消'
|
||||||
|
if (status === 'stream_interrupted') return '流中断'
|
||||||
|
if (status === 'available') return '待执行'
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,6 +580,37 @@ function maskKey(key: string): string {
|
|||||||
return `${key.slice(0, 4)}...${key.slice(-4)}`
|
return `${key.slice(0, 4)}...${key.slice(-4)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAuthType(authType: string): string {
|
||||||
|
const lowered = authType.toLowerCase()
|
||||||
|
if (lowered === 'api_key') return 'API Key'
|
||||||
|
if (lowered === 'service_account') return 'Service Account'
|
||||||
|
if (lowered === 'oauth') return 'OAuth'
|
||||||
|
if (lowered === 'codex') return 'Codex OAuth'
|
||||||
|
if (lowered === 'antigravity') return 'Antigravity OAuth'
|
||||||
|
if (lowered === 'kiro') return 'Kiro OAuth'
|
||||||
|
return authType
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAttemptIndex(attempt: TestAttemptDetail): string {
|
||||||
|
const retryIndex = attempt.retry_index ?? 0
|
||||||
|
return retryIndex > 0 ? `#${attempt.candidate_index}.${retryIndex}` : `#${attempt.candidate_index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTraceCandidateIndex(candidate: CandidateRecord): string {
|
||||||
|
return candidate.retry_index > 0 ? `#${candidate.candidate_index}.${candidate.retry_index}` : `#${candidate.candidate_index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTraceCandidateAccount(candidate: CandidateRecord): string {
|
||||||
|
return candidate.key_account_label || candidate.key_name || candidate.key_preview || '待分配账号'
|
||||||
|
}
|
||||||
|
|
||||||
|
function traceCandidateDetail(candidate: CandidateRecord): string {
|
||||||
|
if (candidate.skip_reason) return candidate.skip_reason
|
||||||
|
if (candidate.error_message) return candidate.error_message
|
||||||
|
if (candidate.endpoint_name) return `端点:${formatApiFormat(candidate.endpoint_name)}`
|
||||||
|
return '等待响应中…'
|
||||||
|
}
|
||||||
|
|
||||||
function attemptDetail(attempt: TestAttemptDetail): string {
|
function attemptDetail(attempt: TestAttemptDetail): string {
|
||||||
if (attempt.skip_reason) return attempt.skip_reason
|
if (attempt.skip_reason) return attempt.skip_reason
|
||||||
if (attempt.error_message) return attempt.error_message
|
if (attempt.error_message) return attempt.error_message
|
||||||
|
|||||||
@@ -219,6 +219,8 @@
|
|||||||
:endpoints="activeEndpoints"
|
:endpoints="activeEndpoints"
|
||||||
:selected-endpoint="selectedTestEndpoint"
|
:selected-endpoint="selectedTestEndpoint"
|
||||||
:testing="!!pendingTestModel && testingModelId === pendingTestModel.id"
|
:testing="!!pendingTestModel && testingModelId === pendingTestModel.id"
|
||||||
|
:trace="testTrace"
|
||||||
|
:request-id="currentTestRequestId"
|
||||||
:show-endpoint-selector="activeEndpoints.length > 1"
|
:show-endpoint-selector="activeEndpoints.length > 1"
|
||||||
@close="handleTestDialogClose"
|
@close="handleTestDialogClose"
|
||||||
@back="handleTestDialogBack"
|
@back="handleTestDialogBack"
|
||||||
@@ -227,7 +229,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onBeforeUnmount } from 'vue'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
import { useSmartPagination } from '@/composables/useSmartPagination'
|
import { useSmartPagination } from '@/composables/useSmartPagination'
|
||||||
import { Box, Edit, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
|
import { Box, Edit, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
@@ -242,6 +245,7 @@ import {
|
|||||||
type TestModelFailoverResponse,
|
type TestModelFailoverResponse,
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
import { updateModel } from '@/api/endpoints/models'
|
import { updateModel } from '@/api/endpoints/models'
|
||||||
|
import { requestTraceApi, type RequestTrace } from '@/api/requestTrace'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||||
@@ -272,6 +276,10 @@ const testResultMode = ref<'global' | 'direct'>('global')
|
|||||||
const testDialogOpen = ref(false)
|
const testDialogOpen = ref(false)
|
||||||
const pendingTestModel = ref<Model | null>(null)
|
const pendingTestModel = ref<Model | null>(null)
|
||||||
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
|
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
|
||||||
|
const currentTestRequestId = ref<string | null>(null)
|
||||||
|
const testTrace = ref<RequestTrace | null>(null)
|
||||||
|
let tracePollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let tracePollToken = 0
|
||||||
// 使用 props 传入的数据,或使用本地数据
|
// 使用 props 传入的数据,或使用本地数据
|
||||||
const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active))
|
const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active))
|
||||||
// 使用 props 传入的数据,或使用本地数据
|
// 使用 props 传入的数据,或使用本地数据
|
||||||
@@ -304,6 +312,47 @@ function refresh() {
|
|||||||
emit('refresh')
|
emit('refresh')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildTestRequestId(): string {
|
||||||
|
const randomUUID = globalThis.crypto?.randomUUID?.bind(globalThis.crypto)
|
||||||
|
if (randomUUID) {
|
||||||
|
return `provider-test-${randomUUID().replace(/-/g, '').slice(0, 20)}`
|
||||||
|
}
|
||||||
|
return `provider-test-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollTestTrace(requestId: string, token: number) {
|
||||||
|
try {
|
||||||
|
const trace = await requestTraceApi.getRequestTrace(requestId, { attemptedOnly: false })
|
||||||
|
if (tracePollToken !== token || currentTestRequestId.value !== requestId) return
|
||||||
|
testTrace.value = trace
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (isAxiosError(err) && err.response?.status === 404) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTestTracePolling(options: { clearState?: boolean } = {}) {
|
||||||
|
tracePollToken += 1
|
||||||
|
if (tracePollTimer) {
|
||||||
|
clearInterval(tracePollTimer)
|
||||||
|
tracePollTimer = null
|
||||||
|
}
|
||||||
|
if (options.clearState !== false) {
|
||||||
|
currentTestRequestId.value = null
|
||||||
|
testTrace.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTestTracePolling(requestId: string) {
|
||||||
|
stopTestTracePolling()
|
||||||
|
currentTestRequestId.value = requestId
|
||||||
|
testTrace.value = null
|
||||||
|
const token = ++tracePollToken
|
||||||
|
void pollTestTrace(requestId, token)
|
||||||
|
tracePollTimer = setInterval(() => {
|
||||||
|
void pollTestTrace(requestId, token)
|
||||||
|
}, 800)
|
||||||
|
}
|
||||||
|
|
||||||
// 格式化价格显示
|
// 格式化价格显示
|
||||||
function formatPrice(price: number | null | undefined): string {
|
function formatPrice(price: number | null | undefined): string {
|
||||||
if (price === null || price === undefined) return '-'
|
if (price === null || price === undefined) return '-'
|
||||||
@@ -437,6 +486,7 @@ async function toggleModelActive(model: Model) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetTestDialogState() {
|
function resetTestDialogState() {
|
||||||
|
stopTestTracePolling()
|
||||||
testDialogOpen.value = false
|
testDialogOpen.value = false
|
||||||
pendingTestModel.value = null
|
pendingTestModel.value = null
|
||||||
selectedTestEndpoint.value = null
|
selectedTestEndpoint.value = null
|
||||||
@@ -466,6 +516,8 @@ async function runModelTest(model: Model, endpoint?: ProviderEndpoint) {
|
|||||||
testingModelId.value = model.id
|
testingModelId.value = model.id
|
||||||
testDialogOpen.value = true
|
testDialogOpen.value = true
|
||||||
selectedTestEndpoint.value = endpoint ?? null
|
selectedTestEndpoint.value = endpoint ?? null
|
||||||
|
const requestId = buildTestRequestId()
|
||||||
|
startTestTracePolling(requestId)
|
||||||
try {
|
try {
|
||||||
const modelName = model.global_model_name || model.provider_model_name
|
const modelName = model.global_model_name || model.provider_model_name
|
||||||
|
|
||||||
@@ -476,6 +528,7 @@ async function runModelTest(model: Model, endpoint?: ProviderEndpoint) {
|
|||||||
api_format: endpoint?.api_format,
|
api_format: endpoint?.api_format,
|
||||||
endpoint_id: endpoint?.id,
|
endpoint_id: endpoint?.id,
|
||||||
message: 'hello',
|
message: 'hello',
|
||||||
|
request_id: requestId,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -489,9 +542,11 @@ async function runModelTest(model: Model, endpoint?: ProviderEndpoint) {
|
|||||||
resetTestDialogState()
|
resetTestDialogState()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
stopTestTracePolling({ clearState: false })
|
||||||
testResultMode.value = 'global'
|
testResultMode.value = 'global'
|
||||||
testResult.value = result
|
testResult.value = result
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
stopTestTracePolling()
|
||||||
showError(`模型测试失败: ${parseApiError(err, '测试请求失败')}`)
|
showError(`模型测试失败: ${parseApiError(err, '测试请求失败')}`)
|
||||||
if (activeEndpoints.value.length <= 1) {
|
if (activeEndpoints.value.length <= 1) {
|
||||||
resetTestDialogState()
|
resetTestDialogState()
|
||||||
@@ -526,4 +581,8 @@ async function testModelConnection(model: Model) {
|
|||||||
defineExpose({
|
defineExpose({
|
||||||
reload: refresh
|
reload: refresh
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopTestTracePolling()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-1.5 min-w-0">
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
<span class="text-muted-foreground shrink-0">#{{ attempt.candidate_index }}</span>
|
<span class="text-muted-foreground shrink-0">{{ formatAttemptIndex(attempt) }}</span>
|
||||||
<Badge
|
<Badge
|
||||||
:variant="statusVariant(attempt.status)"
|
:variant="statusVariant(attempt.status)"
|
||||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
:class="attemptRowClass(attempt.status)"
|
:class="attemptRowClass(attempt.status)"
|
||||||
>
|
>
|
||||||
<td class="pl-3 pr-1 py-2 text-muted-foreground">
|
<td class="pl-3 pr-1 py-2 text-muted-foreground">
|
||||||
{{ attempt.candidate_index }}
|
{{ formatAttemptIndex(attempt) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2">
|
<td class="px-3 py-2">
|
||||||
<div
|
<div
|
||||||
@@ -267,6 +267,11 @@ function statusLabel(status: string) {
|
|||||||
if (status === 'success') return '成功'
|
if (status === 'success') return '成功'
|
||||||
if (status === 'failed') return '失败'
|
if (status === 'failed') return '失败'
|
||||||
if (status === 'skipped') return '跳过'
|
if (status === 'skipped') return '跳过'
|
||||||
|
if (status === 'pending') return '等待中'
|
||||||
|
if (status === 'streaming') return '测试中'
|
||||||
|
if (status === 'cancelled') return '已取消'
|
||||||
|
if (status === 'stream_interrupted') return '流中断'
|
||||||
|
if (status === 'available') return '待执行'
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +287,11 @@ function maskKey(key: string): string {
|
|||||||
return `${key.slice(0, 4)}...${key.slice(-4)}`
|
return `${key.slice(0, 4)}...${key.slice(-4)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAttemptIndex(attempt: TestAttemptDetail): string {
|
||||||
|
const retryIndex = attempt.retry_index ?? 0
|
||||||
|
return retryIndex > 0 ? `#${attempt.candidate_index}.${retryIndex}` : `#${attempt.candidate_index}`
|
||||||
|
}
|
||||||
|
|
||||||
function attemptDetail(attempt: TestAttemptDetail): string {
|
function attemptDetail(attempt: TestAttemptDetail): string {
|
||||||
if (attempt.skip_reason) return attempt.skip_reason
|
if (attempt.skip_reason) return attempt.skip_reason
|
||||||
if (attempt.error_message) return attempt.error_message
|
if (attempt.error_message) return attempt.error_message
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class CandidateResponse(BaseModel):
|
|||||||
endpoint_name: str | None = None # 端点显示名称(api_format)
|
endpoint_name: str | None = None # 端点显示名称(api_format)
|
||||||
key_id: str | None = None
|
key_id: str | None = None
|
||||||
key_name: str | None = None # 密钥名称
|
key_name: str | None = None # 密钥名称
|
||||||
|
key_account_label: str | None = None # 更适合展示的测试账号标签(优先 OAuth 邮箱)
|
||||||
key_preview: str | None = None # 密钥脱敏预览(如 sk-***abc),OAuth 类型不返回
|
key_preview: str | None = None # 密钥脱敏预览(如 sk-***abc),OAuth 类型不返回
|
||||||
key_auth_type: str | None = None # 密钥认证类型(api_key, service_account, oauth)
|
key_auth_type: str | None = None # 密钥认证类型(api_key, service_account, oauth)
|
||||||
key_oauth_plan_type: str | None = None # OAuth 账号套餐类型(free/plus/team/enterprise)
|
key_oauth_plan_type: str | None = None # OAuth 账号套餐类型(free/plus/team/enterprise)
|
||||||
@@ -257,6 +258,7 @@ class AdminGetRequestTraceAdapter(AdminApiAdapter):
|
|||||||
key_ids = {c.key_id for c in candidates if c.key_id}
|
key_ids = {c.key_id for c in candidates if c.key_id}
|
||||||
key_map: dict[str, str] = {}
|
key_map: dict[str, str] = {}
|
||||||
key_preview_map: dict[str, str] = {}
|
key_preview_map: dict[str, str] = {}
|
||||||
|
key_account_label_map: dict[str, str | None] = {}
|
||||||
key_capabilities_map: dict[str, dict | None] = {}
|
key_capabilities_map: dict[str, dict | None] = {}
|
||||||
key_auth_type_map: dict[str, str] = {}
|
key_auth_type_map: dict[str, str] = {}
|
||||||
key_oauth_plan_map: dict[str, str | None] = {}
|
key_oauth_plan_map: dict[str, str | None] = {}
|
||||||
@@ -268,6 +270,7 @@ class AdminGetRequestTraceAdapter(AdminApiAdapter):
|
|||||||
keys = db.query(ProviderAPIKey).filter(ProviderAPIKey.id.in_(key_ids)).all()
|
keys = db.query(ProviderAPIKey).filter(ProviderAPIKey.id.in_(key_ids)).all()
|
||||||
for k in keys:
|
for k in keys:
|
||||||
key_map[k.id] = k.name
|
key_map[k.id] = k.name
|
||||||
|
key_account_label_map[k.id] = k.name
|
||||||
key_capabilities_map[k.id] = k.capabilities
|
key_capabilities_map[k.id] = k.capabilities
|
||||||
|
|
||||||
is_oauth = k.auth_type == "oauth"
|
is_oauth = k.auth_type == "oauth"
|
||||||
@@ -286,6 +289,9 @@ class AdminGetRequestTraceAdapter(AdminApiAdapter):
|
|||||||
try:
|
try:
|
||||||
decrypted_config = crypto_service.decrypt(k.auth_config)
|
decrypted_config = crypto_service.decrypt(k.auth_config)
|
||||||
auth_config = json.loads(decrypted_config)
|
auth_config = json.loads(decrypted_config)
|
||||||
|
email = auth_config.get("email")
|
||||||
|
if isinstance(email, str) and email.strip():
|
||||||
|
key_account_label_map[k.id] = email.strip()
|
||||||
oauth_plan_type = auth_config.get("plan_type")
|
oauth_plan_type = auth_config.get("plan_type")
|
||||||
if not oauth_plan_type:
|
if not oauth_plan_type:
|
||||||
ag_tier = auth_config.get("tier")
|
ag_tier = auth_config.get("tier")
|
||||||
@@ -348,6 +354,9 @@ class AdminGetRequestTraceAdapter(AdminApiAdapter):
|
|||||||
endpoint_map.get(candidate.endpoint_id) if candidate.endpoint_id else None
|
endpoint_map.get(candidate.endpoint_id) if candidate.endpoint_id else None
|
||||||
)
|
)
|
||||||
key_name = key_map.get(candidate.key_id) if candidate.key_id else None
|
key_name = key_map.get(candidate.key_id) if candidate.key_id else None
|
||||||
|
key_account_label = (
|
||||||
|
key_account_label_map.get(candidate.key_id) if candidate.key_id else None
|
||||||
|
)
|
||||||
key_preview = key_preview_map.get(candidate.key_id) if candidate.key_id else None
|
key_preview = key_preview_map.get(candidate.key_id) if candidate.key_id else None
|
||||||
key_auth_type = key_auth_type_map.get(candidate.key_id) if candidate.key_id else None
|
key_auth_type = key_auth_type_map.get(candidate.key_id) if candidate.key_id else None
|
||||||
key_oauth_plan_type = (
|
key_oauth_plan_type = (
|
||||||
@@ -370,6 +379,7 @@ class AdminGetRequestTraceAdapter(AdminApiAdapter):
|
|||||||
endpoint_name=endpoint_name,
|
endpoint_name=endpoint_name,
|
||||||
key_id=candidate.key_id,
|
key_id=candidate.key_id,
|
||||||
key_name=key_name,
|
key_name=key_name,
|
||||||
|
key_account_label=key_account_label,
|
||||||
key_preview=key_preview,
|
key_preview=key_preview,
|
||||||
key_auth_type=key_auth_type,
|
key_auth_type=key_auth_type,
|
||||||
key_oauth_plan_type=key_oauth_plan_type,
|
key_oauth_plan_type=key_oauth_plan_type,
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import time
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
@@ -207,12 +208,14 @@ class TestModelFailoverRequest(BaseModel):
|
|||||||
api_format: str | None = None # 指定 API 格式(endpoint signature)
|
api_format: str | None = None # 指定 API 格式(endpoint signature)
|
||||||
endpoint_id: str | None = None # 指定仅使用该端点测试
|
endpoint_id: str | None = None # 指定仅使用该端点测试
|
||||||
message: str | None = "Hello"
|
message: str | None = "Hello"
|
||||||
|
request_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TestAttemptDetail(BaseModel):
|
class TestAttemptDetail(BaseModel):
|
||||||
"""单次测试尝试的详情"""
|
"""单次测试尝试的详情"""
|
||||||
|
|
||||||
candidate_index: int
|
candidate_index: int
|
||||||
|
retry_index: int = 0
|
||||||
endpoint_api_format: str
|
endpoint_api_format: str
|
||||||
endpoint_base_url: str
|
endpoint_base_url: str
|
||||||
key_name: str | None = None
|
key_name: str | None = None
|
||||||
@@ -1180,6 +1183,264 @@ def _filter_test_candidates_by_endpoint(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_jsonish(value: Any) -> Any:
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||||||
|
return value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_test_effective_model(
|
||||||
|
*,
|
||||||
|
provider: Provider,
|
||||||
|
candidate: Any,
|
||||||
|
request: TestModelFailoverRequest,
|
||||||
|
gm_obj: Any,
|
||||||
|
key: Any | None = None,
|
||||||
|
) -> str:
|
||||||
|
effective_model = request.model_name
|
||||||
|
if request.mode != "global":
|
||||||
|
return effective_model
|
||||||
|
|
||||||
|
current_key = key or getattr(candidate, "key", None)
|
||||||
|
pool_mapping = (
|
||||||
|
getattr(current_key, "_pool_mapping_matched_model", None) if current_key else None
|
||||||
|
)
|
||||||
|
mapping_matched_model = pool_mapping or getattr(candidate, "mapping_matched_model", None)
|
||||||
|
if mapping_matched_model:
|
||||||
|
return str(mapping_matched_model)
|
||||||
|
|
||||||
|
if not gm_obj:
|
||||||
|
return effective_model
|
||||||
|
|
||||||
|
gm_id_str = str(gm_obj.id)
|
||||||
|
endpoint = getattr(candidate, "endpoint", None)
|
||||||
|
ep_format = str(getattr(endpoint, "api_format", "") or "")
|
||||||
|
for model in provider.models or []:
|
||||||
|
if not getattr(model, "is_active", False):
|
||||||
|
continue
|
||||||
|
if str(getattr(model, "global_model_id", "") or "") != gm_id_str:
|
||||||
|
continue
|
||||||
|
selected = model.select_provider_model_name(affinity_key=None, api_format=ep_format)
|
||||||
|
if selected:
|
||||||
|
return str(selected)
|
||||||
|
return effective_model
|
||||||
|
|
||||||
|
|
||||||
|
def _build_test_candidate_meta(
|
||||||
|
*,
|
||||||
|
candidates: list[ProviderCandidate],
|
||||||
|
provider: Provider,
|
||||||
|
request: TestModelFailoverRequest,
|
||||||
|
gm_obj: Any,
|
||||||
|
) -> tuple[dict[tuple[int, str], dict[str, Any]], dict[int, dict[str, Any]]]:
|
||||||
|
from src.services.scheduling.schemas import PoolCandidate
|
||||||
|
|
||||||
|
by_pair: dict[tuple[int, str], dict[str, Any]] = {}
|
||||||
|
by_candidate: dict[int, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
for candidate_index, candidate in enumerate(candidates):
|
||||||
|
endpoint = candidate.endpoint
|
||||||
|
base_meta = {
|
||||||
|
"endpoint_api_format": str(getattr(endpoint, "api_format", "") or ""),
|
||||||
|
"endpoint_base_url": str(getattr(endpoint, "base_url", "") or "")[:80],
|
||||||
|
"effective_model": _resolve_test_effective_model(
|
||||||
|
provider=provider,
|
||||||
|
candidate=candidate,
|
||||||
|
request=request,
|
||||||
|
gm_obj=gm_obj,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
by_candidate[candidate_index] = base_meta
|
||||||
|
|
||||||
|
key = getattr(candidate, "key", None)
|
||||||
|
if key is not None and getattr(key, "id", None):
|
||||||
|
by_pair[(candidate_index, str(key.id))] = dict(base_meta)
|
||||||
|
|
||||||
|
if isinstance(candidate, PoolCandidate):
|
||||||
|
for pool_key in candidate.pool_keys or []:
|
||||||
|
if not getattr(pool_key, "id", None):
|
||||||
|
continue
|
||||||
|
by_pair[(candidate_index, str(pool_key.id))] = {
|
||||||
|
"endpoint_api_format": base_meta["endpoint_api_format"],
|
||||||
|
"endpoint_base_url": base_meta["endpoint_base_url"],
|
||||||
|
"effective_model": _resolve_test_effective_model(
|
||||||
|
provider=provider,
|
||||||
|
candidate=candidate,
|
||||||
|
request=request,
|
||||||
|
gm_obj=gm_obj,
|
||||||
|
key=pool_key,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return by_pair, by_candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_mark_test_oauth_key_invalid(
|
||||||
|
*,
|
||||||
|
db: Session,
|
||||||
|
key: Any,
|
||||||
|
auth_type: str,
|
||||||
|
error_payload: Any,
|
||||||
|
) -> None:
|
||||||
|
if auth_type != "oauth" or not isinstance(error_payload, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
error_obj = error_payload.get("error")
|
||||||
|
if not isinstance(error_obj, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
error_message = str(error_obj.get("message", "") or "")
|
||||||
|
if error_obj.get("code") != 403:
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
"verify" not in error_message.lower()
|
||||||
|
and "permission" not in str(error_obj.get("status", "") or "").lower()
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from src.services.provider.oauth_token import OAUTH_ACCOUNT_BLOCK_PREFIX
|
||||||
|
|
||||||
|
key.oauth_invalid_at = datetime.now(timezone.utc)
|
||||||
|
key.oauth_invalid_reason = f"{OAUTH_ACCOUNT_BLOCK_PREFIX}Google 要求验证账号"
|
||||||
|
key.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_test_response_or_raise(
|
||||||
|
*,
|
||||||
|
response: dict[str, Any],
|
||||||
|
endpoint: Any,
|
||||||
|
provider_name: str,
|
||||||
|
auth_type: str,
|
||||||
|
api_key: Any,
|
||||||
|
db: Session,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
status_code = int(response.get("status_code", 0) or 0)
|
||||||
|
response_payload = response.get("response", {})
|
||||||
|
parsed_payload = _parse_jsonish(response_payload)
|
||||||
|
if isinstance(parsed_payload, dict) and "response_body" in parsed_payload:
|
||||||
|
parsed_payload = _parse_jsonish(parsed_payload.get("response_body"))
|
||||||
|
|
||||||
|
if isinstance(parsed_payload, dict) and "error" in parsed_payload:
|
||||||
|
_maybe_mark_test_oauth_key_invalid(
|
||||||
|
db=db,
|
||||||
|
key=api_key,
|
||||||
|
auth_type=auth_type,
|
||||||
|
error_payload=parsed_payload,
|
||||||
|
)
|
||||||
|
error_obj = parsed_payload["error"]
|
||||||
|
error_code = error_obj.get("code") if isinstance(error_obj, dict) else status_code or 500
|
||||||
|
error_message = (
|
||||||
|
error_obj.get("message") if isinstance(error_obj, dict) else str(error_obj or "")
|
||||||
|
)
|
||||||
|
error_status = error_obj.get("status") if isinstance(error_obj, dict) else None
|
||||||
|
from src.core.exceptions import EmbeddedErrorException
|
||||||
|
|
||||||
|
raise EmbeddedErrorException(
|
||||||
|
provider_name=provider_name,
|
||||||
|
error_code=int(error_code) if error_code else None,
|
||||||
|
error_message=str(error_message or ""),
|
||||||
|
error_status=str(error_status) if error_status else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if status_code == 200 and not response.get("error"):
|
||||||
|
return parsed_payload if isinstance(parsed_payload, dict) else response_payload
|
||||||
|
|
||||||
|
error_meta = response_payload if isinstance(response_payload, dict) else {}
|
||||||
|
error_type = str(error_meta.get("error_type", "") or "")
|
||||||
|
error_message = str(response.get("error", "") or "")
|
||||||
|
if not error_message and isinstance(parsed_payload, dict):
|
||||||
|
embedded_error = parsed_payload.get("error")
|
||||||
|
if isinstance(embedded_error, dict):
|
||||||
|
error_message = str(embedded_error.get("message", "") or "")
|
||||||
|
elif embedded_error:
|
||||||
|
error_message = str(embedded_error)
|
||||||
|
if not error_message and isinstance(parsed_payload, str):
|
||||||
|
error_message = parsed_payload
|
||||||
|
if not error_message and status_code:
|
||||||
|
error_message = f"HTTP {status_code}"
|
||||||
|
|
||||||
|
request_obj = httpx.Request("POST", str(getattr(endpoint, "base_url", "") or ""))
|
||||||
|
if status_code > 0:
|
||||||
|
body_text = error_message[:4000] if error_message else ""
|
||||||
|
synthetic_response = httpx.Response(
|
||||||
|
status_code=status_code,
|
||||||
|
request=request_obj,
|
||||||
|
text=body_text,
|
||||||
|
headers=response.get("headers", {}),
|
||||||
|
)
|
||||||
|
http_error = httpx.HTTPStatusError(
|
||||||
|
message=body_text or f"HTTP {status_code}",
|
||||||
|
request=request_obj,
|
||||||
|
response=synthetic_response,
|
||||||
|
)
|
||||||
|
http_error.upstream_response = body_text # type: ignore[attr-defined]
|
||||||
|
raise http_error
|
||||||
|
|
||||||
|
if error_type == "timeout":
|
||||||
|
raise httpx.TimeoutException(error_message or "Request timeout")
|
||||||
|
|
||||||
|
if error_type in {"network_error", "connection_failed"}:
|
||||||
|
raise httpx.ConnectError(error_message or "Connection failed", request=request_obj)
|
||||||
|
|
||||||
|
from src.core.exceptions import ProviderNotAvailableException
|
||||||
|
|
||||||
|
raise ProviderNotAvailableException(
|
||||||
|
error_message or "服务暂时不可用,请稍后重试",
|
||||||
|
provider_name=provider_name,
|
||||||
|
upstream_response=error_message or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_test_attempts_from_candidate_keys(
|
||||||
|
*,
|
||||||
|
candidate_keys: list[Any],
|
||||||
|
candidate_meta_by_pair: dict[tuple[int, str], dict[str, Any]],
|
||||||
|
candidate_meta_by_index: dict[int, dict[str, Any]],
|
||||||
|
) -> list[TestAttemptDetail]:
|
||||||
|
attempts: list[TestAttemptDetail] = []
|
||||||
|
|
||||||
|
for candidate_key in candidate_keys:
|
||||||
|
status = str(getattr(candidate_key, "status", "") or "").strip().lower()
|
||||||
|
if status in {"", "available", "unused"}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidate_index = int(getattr(candidate_key, "candidate_index", 0) or 0)
|
||||||
|
retry_index = int(getattr(candidate_key, "retry_index", 0) or 0)
|
||||||
|
key_id = str(getattr(candidate_key, "key_id", "") or "")
|
||||||
|
meta = candidate_meta_by_pair.get((candidate_index, key_id)) or candidate_meta_by_index.get(
|
||||||
|
candidate_index, {}
|
||||||
|
)
|
||||||
|
|
||||||
|
attempts.append(
|
||||||
|
TestAttemptDetail(
|
||||||
|
candidate_index=candidate_index,
|
||||||
|
retry_index=retry_index,
|
||||||
|
endpoint_api_format=str(meta.get("endpoint_api_format", "") or ""),
|
||||||
|
endpoint_base_url=str(meta.get("endpoint_base_url", "") or ""),
|
||||||
|
key_name=getattr(candidate_key, "key_name", None),
|
||||||
|
key_id=key_id,
|
||||||
|
auth_type=str(getattr(candidate_key, "auth_type", "") or ""),
|
||||||
|
effective_model=(
|
||||||
|
str(meta.get("effective_model")) if meta.get("effective_model") else None
|
||||||
|
),
|
||||||
|
status=status,
|
||||||
|
skip_reason=getattr(candidate_key, "skip_reason", None),
|
||||||
|
error_message=getattr(candidate_key, "error_message", None),
|
||||||
|
status_code=getattr(candidate_key, "status_code", None),
|
||||||
|
latency_ms=getattr(candidate_key, "latency_ms", None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
attempts.sort(key=lambda attempt: (attempt.candidate_index, attempt.retry_index))
|
||||||
|
return attempts
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test-model-failover")
|
@router.post("/test-model-failover")
|
||||||
async def test_model_failover(
|
async def test_model_failover(
|
||||||
request: TestModelFailoverRequest,
|
request: TestModelFailoverRequest,
|
||||||
@@ -1193,11 +1454,13 @@ async def test_model_failover(
|
|||||||
- global: 模拟外部请求,用全局模型名走候选解析(限定当前 Provider)
|
- global: 模拟外部请求,用全局模型名走候选解析(限定当前 Provider)
|
||||||
- direct: 直接测试 provider_model_name,在当前 Provider 内多 Key 故障转移
|
- direct: 直接测试 provider_model_name,在当前 Provider 内多 Key 故障转移
|
||||||
"""
|
"""
|
||||||
from src.services.candidate.failover import FailoverEngine
|
from src.core.exceptions import ProviderNotAvailableException
|
||||||
from src.services.candidate.policy import RetryMode, RetryPolicy, SkipPolicy
|
from src.services.candidate.recorder import CandidateRecorder
|
||||||
from src.services.task.protocol import AttemptKind, AttemptResult
|
from src.services.scheduling.candidate_builder import CandidateBuilder
|
||||||
|
from src.services.scheduling.candidate_sorter import CandidateSorter
|
||||||
|
from src.services.scheduling.scheduling_config import SchedulingConfig
|
||||||
|
from src.services.task import TaskService
|
||||||
|
|
||||||
# 1. 加载 Provider
|
|
||||||
provider = (
|
provider = (
|
||||||
db.query(Provider)
|
db.query(Provider)
|
||||||
.options(
|
.options(
|
||||||
@@ -1214,9 +1477,8 @@ async def test_model_failover(
|
|||||||
if request.mode not in ("global", "direct"):
|
if request.mode not in ("global", "direct"):
|
||||||
raise HTTPException(status_code=400, detail="mode must be 'global' or 'direct'")
|
raise HTTPException(status_code=400, detail="mode must be 'global' or 'direct'")
|
||||||
|
|
||||||
# 2. 构建候选列表
|
candidates: list[ProviderCandidate] = []
|
||||||
candidates = []
|
gm_obj = None
|
||||||
gm_obj = None # GlobalModel 对象,global 模式下用于 fallback 映射
|
|
||||||
endpoint_by_id = {
|
endpoint_by_id = {
|
||||||
str(getattr(ep, "id", "") or ""): ep
|
str(getattr(ep, "id", "") or ""): ep
|
||||||
for ep in (provider.endpoints or [])
|
for ep in (provider.endpoints or [])
|
||||||
@@ -1231,21 +1493,14 @@ async def test_model_failover(
|
|||||||
if request.api_format and ep_format != request.api_format:
|
if request.api_format and ep_format != request.api_format:
|
||||||
raise HTTPException(status_code=400, detail="endpoint_id does not match api_format")
|
raise HTTPException(status_code=400, detail="endpoint_id does not match api_format")
|
||||||
|
|
||||||
|
client_format = request.api_format
|
||||||
if request.mode == "global":
|
if request.mode == "global":
|
||||||
# 模拟外部请求:走 CandidateBuilder 候选解析
|
|
||||||
from src.services.scheduling.candidate_builder import CandidateBuilder
|
|
||||||
from src.services.scheduling.candidate_sorter import CandidateSorter
|
|
||||||
from src.services.scheduling.scheduling_config import SchedulingConfig
|
|
||||||
|
|
||||||
sorter = CandidateSorter(SchedulingConfig())
|
sorter = CandidateSorter(SchedulingConfig())
|
||||||
builder = CandidateBuilder(sorter)
|
builder = CandidateBuilder(sorter)
|
||||||
|
|
||||||
# 确定 client_format
|
|
||||||
client_format = request.api_format
|
|
||||||
if not client_format and requested_endpoint is not None:
|
if not client_format and requested_endpoint is not None:
|
||||||
client_format = str(getattr(requested_endpoint, "api_format", "") or "")
|
client_format = str(getattr(requested_endpoint, "api_format", "") or "")
|
||||||
if not client_format:
|
if not client_format:
|
||||||
# 取第一个活跃端点的格式
|
|
||||||
for ep in provider.endpoints or []:
|
for ep in provider.endpoints or []:
|
||||||
if getattr(ep, "is_active", False):
|
if getattr(ep, "is_active", False):
|
||||||
client_format = str(getattr(ep, "api_format", "") or "")
|
client_format = str(getattr(ep, "api_format", "") or "")
|
||||||
@@ -1256,11 +1511,9 @@ async def test_model_failover(
|
|||||||
status_code=400, detail="No active endpoint found to determine API format"
|
status_code=400, detail="No active endpoint found to determine API format"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 从 GlobalModel 提取 model_mappings(正则映射规则,用于 Key.allowed_models 匹配)
|
|
||||||
from src.services.cache.model_cache import ModelCacheService
|
from src.services.cache.model_cache import ModelCacheService
|
||||||
|
|
||||||
model_mappings: list[str] = []
|
model_mappings: list[str] = []
|
||||||
gm_obj = None
|
|
||||||
try:
|
try:
|
||||||
gm_obj = await ModelCacheService.get_global_model_by_name(db, request.model_name)
|
gm_obj = await ModelCacheService.get_global_model_by_name(db, request.model_name)
|
||||||
if gm_obj and isinstance(gm_obj.config, dict):
|
if gm_obj and isinstance(gm_obj.config, dict):
|
||||||
@@ -1285,7 +1538,10 @@ async def test_model_failover(
|
|||||||
candidates = []
|
candidates = []
|
||||||
candidates = _filter_test_candidates_by_endpoint(candidates, request.endpoint_id)
|
candidates = _filter_test_candidates_by_endpoint(candidates, request.endpoint_id)
|
||||||
else:
|
else:
|
||||||
# 直接测试:简单匹配 Endpoint + Key
|
if not client_format and requested_endpoint is not None:
|
||||||
|
client_format = str(getattr(requested_endpoint, "api_format", "") or "")
|
||||||
|
if not client_format and provider.endpoints:
|
||||||
|
client_format = str(getattr(provider.endpoints[0], "api_format", "") or "")
|
||||||
candidates = _build_direct_test_candidates(
|
candidates = _build_direct_test_candidates(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
api_format=request.api_format,
|
api_format=request.api_format,
|
||||||
@@ -1303,266 +1559,165 @@ async def test_model_failover(
|
|||||||
error="No available candidates found for this model",
|
error="No available candidates found for this model",
|
||||||
).model_dump()
|
).model_dump()
|
||||||
|
|
||||||
# 3. 定义 attempt_func
|
request_payload = {
|
||||||
attempts: list[TestAttemptDetail] = []
|
"model": request.model_name,
|
||||||
p_type = str(getattr(provider, "provider_type", "") or "").lower()
|
"messages": [{"role": "user", "content": request.message or "Hello"}],
|
||||||
|
"max_tokens": 30,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"stream": True,
|
||||||
|
}
|
||||||
|
request_id = str(request.request_id or f"provider-test-{uuid4().hex[:12]}")
|
||||||
|
request_timeout = float(getattr(provider, "request_timeout", 0) or TimeoutDefaults.HTTP_REQUEST)
|
||||||
|
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
|
||||||
|
|
||||||
async def _attempt_func(candidate: Any) -> AttemptResult:
|
async def _request_func(provider_obj: Any, endpoint: Any, key: Any, candidate: Any) -> Any:
|
||||||
start_time = time.monotonic()
|
effective_proxy = resolve_effective_proxy(
|
||||||
endpoint = candidate.endpoint
|
getattr(provider_obj, "proxy", None), getattr(key, "proxy", None)
|
||||||
key = candidate.key
|
)
|
||||||
candidate_idx = getattr(candidate, "_utf_candidate_index", 0)
|
try:
|
||||||
|
api_key_value, auth_config = await _resolve_key_auth(
|
||||||
|
key,
|
||||||
|
provider_obj,
|
||||||
|
provider_proxy_config=effective_proxy,
|
||||||
|
)
|
||||||
|
except _KeyAuthError as e:
|
||||||
|
raise RuntimeError(e.message) from e
|
||||||
|
|
||||||
auth_type = str(getattr(key, "auth_type", "api_key") or "api_key").lower()
|
auth_type = str(getattr(key, "auth_type", "api_key") or "api_key").lower()
|
||||||
extra_headers: dict[str, str] = {}
|
extra_headers = get_extra_headers_from_endpoint(endpoint) or {}
|
||||||
oauth_meta: dict = {}
|
if auth_type == "oauth":
|
||||||
effective_model = request.model_name
|
account_id = (auth_config or {}).get("account_id")
|
||||||
attempt_recorded = False
|
if account_id:
|
||||||
|
extra_headers["chatgpt-account-id"] = str(account_id)
|
||||||
|
|
||||||
try:
|
effective_model = _resolve_test_effective_model(
|
||||||
# 解析 Key(复用统一的认证解析逻辑)
|
provider=provider,
|
||||||
effective_proxy = resolve_effective_proxy(
|
candidate=candidate,
|
||||||
getattr(provider, "proxy", None), getattr(key, "proxy", None)
|
request=request,
|
||||||
)
|
gm_obj=gm_obj,
|
||||||
try:
|
key=key,
|
||||||
api_key_value, auth_config = await _resolve_key_auth(
|
)
|
||||||
key, provider, provider_proxy_config=effective_proxy
|
adapter_class = get_adapter_for_format(endpoint.api_format)
|
||||||
)
|
if not adapter_class:
|
||||||
except _KeyAuthError as e:
|
raise ValueError(f"Unknown API format: {endpoint.api_format}")
|
||||||
raise Exception(e.message) from e
|
|
||||||
oauth_meta = auth_config or {}
|
|
||||||
|
|
||||||
# OAuth 额外头
|
response = await adapter_class.check_endpoint(
|
||||||
if auth_type == "oauth":
|
None,
|
||||||
account_id = oauth_meta.get("account_id")
|
endpoint.base_url,
|
||||||
if account_id:
|
api_key_value,
|
||||||
extra_headers["chatgpt-account-id"] = str(account_id)
|
{
|
||||||
|
**request_payload,
|
||||||
ep_extra = get_extra_headers_from_endpoint(endpoint) or {}
|
|
||||||
extra_headers.update(ep_extra)
|
|
||||||
|
|
||||||
# 确定实际模型名
|
|
||||||
effective_model = request.model_name
|
|
||||||
if request.mode == "global":
|
|
||||||
if candidate.mapping_matched_model:
|
|
||||||
effective_model = candidate.mapping_matched_model
|
|
||||||
elif gm_obj:
|
|
||||||
# Fallback: 从 Provider.Model.provider_model_mappings 获取映射
|
|
||||||
# 与正常请求流程中 _get_mapped_model() 的逻辑一致
|
|
||||||
gm_id_str = str(gm_obj.id)
|
|
||||||
for m in provider.models or []:
|
|
||||||
if not getattr(m, "is_active", False):
|
|
||||||
continue
|
|
||||||
if str(getattr(m, "global_model_id", "")) != gm_id_str:
|
|
||||||
continue
|
|
||||||
ep_format = str(getattr(endpoint, "api_format", "") or "")
|
|
||||||
effective_model = m.select_provider_model_name(
|
|
||||||
affinity_key=None, api_format=ep_format
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"[test-failover] Fallback mapping: {} -> {} "
|
|
||||||
"(provider_model_name={}, has_provider_model_mappings={})",
|
|
||||||
request.model_name,
|
|
||||||
effective_model,
|
|
||||||
m.provider_model_name,
|
|
||||||
bool(m.provider_model_mappings),
|
|
||||||
)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
"[test-failover] No matching Model found for gm_id={} in provider={}",
|
|
||||||
gm_id_str,
|
|
||||||
provider.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 获取 adapter
|
|
||||||
adapter_class = get_adapter_for_format(endpoint.api_format)
|
|
||||||
if not adapter_class:
|
|
||||||
raise Exception(f"Unknown API format: {endpoint.api_format}")
|
|
||||||
|
|
||||||
# 构建测试请求
|
|
||||||
check_request = {
|
|
||||||
"model": effective_model,
|
"model": effective_model,
|
||||||
"messages": [{"role": "user", "content": request.message or "Hello"}],
|
},
|
||||||
"max_tokens": 30,
|
extra_headers if extra_headers else None,
|
||||||
"temperature": 0.7,
|
body_rules=getattr(endpoint, "body_rules", None),
|
||||||
"stream": True,
|
header_rules=getattr(endpoint, "header_rules", None),
|
||||||
}
|
db=db,
|
||||||
|
user=current_user,
|
||||||
body_rules = getattr(endpoint, "body_rules", None)
|
provider_name=provider_obj.name,
|
||||||
header_rules = getattr(endpoint, "header_rules", None)
|
provider_id=str(provider_obj.id),
|
||||||
|
api_key_id=str(key.id),
|
||||||
# 执行检查
|
model_name=effective_model,
|
||||||
response = await adapter_class.check_endpoint(
|
auth_type=auth_type,
|
||||||
None,
|
provider_type=provider_type if provider_type else None,
|
||||||
endpoint.base_url,
|
decrypted_auth_config=auth_config if auth_config else None,
|
||||||
api_key_value,
|
provider_endpoint=endpoint,
|
||||||
check_request,
|
provider_api_key=key,
|
||||||
extra_headers if extra_headers else None,
|
proxy_config=effective_proxy,
|
||||||
body_rules=body_rules,
|
timeout_seconds=request_timeout,
|
||||||
header_rules=header_rules,
|
)
|
||||||
db=db,
|
return _extract_test_response_or_raise(
|
||||||
user=current_user,
|
response=response,
|
||||||
provider_name=provider.name,
|
endpoint=endpoint,
|
||||||
provider_id=str(provider.id),
|
provider_name=str(provider_obj.name),
|
||||||
api_key_id=str(key.id),
|
auth_type=auth_type,
|
||||||
model_name=effective_model,
|
api_key=key,
|
||||||
auth_type=auth_type,
|
db=db,
|
||||||
provider_type=p_type if p_type else None,
|
|
||||||
decrypted_auth_config=oauth_meta if oauth_meta else None,
|
|
||||||
provider_endpoint=endpoint,
|
|
||||||
provider_api_key=key,
|
|
||||||
proxy_config=effective_proxy,
|
|
||||||
)
|
|
||||||
|
|
||||||
latency_ms = int((time.monotonic() - start_time) * 1000)
|
|
||||||
status_code = response.get("status_code", 0)
|
|
||||||
|
|
||||||
# 检查响应是否有错误
|
|
||||||
has_error = bool(response.get("error")) or status_code != 200
|
|
||||||
if not has_error:
|
|
||||||
resp_data = response.get("response", {})
|
|
||||||
resp_body = resp_data.get("response_body", {})
|
|
||||||
if isinstance(resp_body, str):
|
|
||||||
try:
|
|
||||||
parsed = json.loads(resp_body)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
parsed = resp_body
|
|
||||||
else:
|
|
||||||
parsed = resp_body
|
|
||||||
if isinstance(parsed, dict) and "error" in parsed:
|
|
||||||
has_error = True
|
|
||||||
|
|
||||||
if has_error:
|
|
||||||
error_msg = str(response.get("error", ""))[:300]
|
|
||||||
if not error_msg and status_code != 200:
|
|
||||||
error_msg = f"HTTP {status_code}"
|
|
||||||
if not error_msg and isinstance(parsed, dict) and "error" in parsed:
|
|
||||||
err_val = parsed["error"]
|
|
||||||
error_msg = str(
|
|
||||||
err_val.get("message", err_val) if isinstance(err_val, dict) else err_val
|
|
||||||
)[:300]
|
|
||||||
attempts.append(
|
|
||||||
TestAttemptDetail(
|
|
||||||
candidate_index=candidate_idx,
|
|
||||||
endpoint_api_format=str(endpoint.api_format),
|
|
||||||
endpoint_base_url=str(endpoint.base_url)[:80],
|
|
||||||
key_name=getattr(key, "name", None),
|
|
||||||
key_id=str(key.id),
|
|
||||||
auth_type=auth_type,
|
|
||||||
effective_model=effective_model,
|
|
||||||
status="failed",
|
|
||||||
error_message=error_msg,
|
|
||||||
status_code=status_code,
|
|
||||||
latency_ms=latency_ms,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
attempt_recorded = True
|
|
||||||
raise Exception(f"Upstream error: status={status_code}, error={error_msg}")
|
|
||||||
|
|
||||||
# 成功
|
|
||||||
attempts.append(
|
|
||||||
TestAttemptDetail(
|
|
||||||
candidate_index=candidate_idx,
|
|
||||||
endpoint_api_format=str(endpoint.api_format),
|
|
||||||
endpoint_base_url=str(endpoint.base_url)[:80],
|
|
||||||
key_name=getattr(key, "name", None),
|
|
||||||
key_id=str(key.id),
|
|
||||||
auth_type=auth_type,
|
|
||||||
effective_model=effective_model,
|
|
||||||
status="success",
|
|
||||||
status_code=status_code,
|
|
||||||
latency_ms=latency_ms,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return AttemptResult(
|
|
||||||
kind=AttemptKind.SYNC_RESPONSE,
|
|
||||||
http_status=status_code,
|
|
||||||
http_headers={},
|
|
||||||
response_body=response.get("response", response),
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
latency_ms = int((time.monotonic() - start_time) * 1000)
|
|
||||||
# has_error 路径已记录带 status_code 的详细 attempt,此处仅补录早期异常
|
|
||||||
if not attempt_recorded:
|
|
||||||
attempts.append(
|
|
||||||
TestAttemptDetail(
|
|
||||||
candidate_index=candidate_idx,
|
|
||||||
endpoint_api_format=str(endpoint.api_format),
|
|
||||||
endpoint_base_url=str(endpoint.base_url)[:80],
|
|
||||||
key_name=getattr(key, "name", None),
|
|
||||||
key_id=str(key.id),
|
|
||||||
auth_type=auth_type,
|
|
||||||
effective_model=effective_model,
|
|
||||||
status="failed",
|
|
||||||
error_message=str(exc)[:300],
|
|
||||||
latency_ms=latency_ms,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
# 4. 预设 candidate index(FailoverEngine 也会 setattr,此处兜底防止 setattr 失败)
|
|
||||||
for i, cand in enumerate(candidates):
|
|
||||||
cand._utf_candidate_index = i # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
# 5. 执行故障转移
|
|
||||||
try:
|
|
||||||
engine = FailoverEngine(db)
|
|
||||||
result = await engine.execute(
|
|
||||||
candidates=candidates,
|
|
||||||
attempt_func=_attempt_func,
|
|
||||||
retry_policy=RetryPolicy(mode=RetryMode.DISABLED),
|
|
||||||
skip_policy=SkipPolicy(),
|
|
||||||
request_id=None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 补充 skipped 候选到 attempts
|
candidate_recorder = CandidateRecorder(db)
|
||||||
for i, cand in enumerate(candidates):
|
task_service = TaskService(db)
|
||||||
if cand.is_skipped and not any(a.candidate_index == i for a in attempts):
|
exec_result = None
|
||||||
attempts.append(
|
run_error: Exception | None = None
|
||||||
TestAttemptDetail(
|
|
||||||
candidate_index=i,
|
|
||||||
endpoint_api_format=str(cand.endpoint.api_format),
|
|
||||||
endpoint_base_url=str(cand.endpoint.base_url)[:80],
|
|
||||||
key_name=getattr(cand.key, "name", None),
|
|
||||||
key_id=str(cand.key.id),
|
|
||||||
auth_type=str(getattr(cand.key, "auth_type", "") or ""),
|
|
||||||
status="skipped",
|
|
||||||
skip_reason=cand.skip_reason,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
attempts.sort(key=lambda a: a.candidate_index)
|
try:
|
||||||
|
exec_result = await task_service.execute_sync_candidates(
|
||||||
|
api_format=client_format or "openai:chat",
|
||||||
|
model_name=request.model_name,
|
||||||
|
candidates=candidates,
|
||||||
|
request_func=_request_func,
|
||||||
|
request_id=request_id,
|
||||||
|
current_user=current_user,
|
||||||
|
user_api_key=None,
|
||||||
|
is_stream=False,
|
||||||
|
capability_requirements=None,
|
||||||
|
request_body_ref={"body": dict(request_payload)},
|
||||||
|
request_headers=None,
|
||||||
|
request_body=dict(request_payload),
|
||||||
|
affinity_key=f"provider-test:{provider.id}",
|
||||||
|
create_pending_usage=False,
|
||||||
|
enable_cache_affinity=False,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
run_error = exc
|
||||||
|
logger.error("[test-model-failover] Error: {}", exc)
|
||||||
|
|
||||||
# 提取成功时的数据
|
try:
|
||||||
data = None
|
candidate_keys = candidate_recorder.get_candidate_keys(request_id)
|
||||||
if result.success and result.attempt_result:
|
except Exception:
|
||||||
data = {
|
candidate_keys = list(exec_result.candidate_keys) if exec_result else []
|
||||||
|
|
||||||
|
candidate_meta_by_pair, candidate_meta_by_index = _build_test_candidate_meta(
|
||||||
|
candidates=candidates,
|
||||||
|
provider=provider,
|
||||||
|
request=request,
|
||||||
|
gm_obj=gm_obj,
|
||||||
|
)
|
||||||
|
attempts = _build_test_attempts_from_candidate_keys(
|
||||||
|
candidate_keys=candidate_keys,
|
||||||
|
candidate_meta_by_pair=candidate_meta_by_pair,
|
||||||
|
candidate_meta_by_index=candidate_meta_by_index,
|
||||||
|
)
|
||||||
|
total_attempts = sum(1 for attempt in attempts if attempt.status != "skipped")
|
||||||
|
|
||||||
|
if exec_result and exec_result.success:
|
||||||
|
return TestModelFailoverResponse(
|
||||||
|
success=True,
|
||||||
|
model=request.model_name,
|
||||||
|
provider={"id": str(provider.id), "name": provider.name},
|
||||||
|
attempts=attempts,
|
||||||
|
total_candidates=len(candidates),
|
||||||
|
total_attempts=exec_result.attempt_count,
|
||||||
|
data={
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"response": result.attempt_result.response_body,
|
"response": exec_result.response,
|
||||||
}
|
},
|
||||||
|
error=None,
|
||||||
return TestModelFailoverResponse(
|
|
||||||
success=result.success,
|
|
||||||
model=request.model_name,
|
|
||||||
provider={"id": str(provider.id), "name": provider.name},
|
|
||||||
attempts=attempts,
|
|
||||||
total_candidates=len(candidates),
|
|
||||||
total_attempts=result.attempt_count,
|
|
||||||
data=data,
|
|
||||||
error=result.error_message if not result.success else None,
|
|
||||||
).model_dump()
|
).model_dump()
|
||||||
|
|
||||||
except Exception as e:
|
error_message = None
|
||||||
logger.error("[test-model-failover] Error: {}", e)
|
if run_error is not None:
|
||||||
return TestModelFailoverResponse(
|
if isinstance(run_error, ProviderNotAvailableException) and getattr(
|
||||||
success=False,
|
run_error, "upstream_response", None
|
||||||
model=request.model_name,
|
):
|
||||||
provider={"id": str(provider.id), "name": provider.name},
|
error_message = str(run_error.upstream_response)[:500]
|
||||||
attempts=attempts,
|
if not error_message:
|
||||||
total_candidates=len(candidates),
|
error_message = str(run_error)
|
||||||
total_attempts=0,
|
if not error_message:
|
||||||
error=str(e)[:500],
|
failed_attempt = next(
|
||||||
).model_dump()
|
(attempt for attempt in reversed(attempts) if attempt.error_message),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
error_message = (
|
||||||
|
failed_attempt.error_message if failed_attempt else "服务暂时不可用,请稍后重试"
|
||||||
|
)
|
||||||
|
|
||||||
|
return TestModelFailoverResponse(
|
||||||
|
success=False,
|
||||||
|
model=request.model_name,
|
||||||
|
provider={"id": str(provider.id), "name": provider.name},
|
||||||
|
attempts=attempts,
|
||||||
|
total_candidates=len(candidates),
|
||||||
|
total_attempts=total_attempts,
|
||||||
|
error=str(error_message)[:500],
|
||||||
|
).model_dump()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from src.database import get_db
|
|||||||
from src.models.api import SystemSettingsRequest, SystemSettingsResponse
|
from src.models.api import SystemSettingsRequest, SystemSettingsResponse
|
||||||
from src.models.database import ApiKey, Provider, Usage, User
|
from src.models.database import ApiKey, Provider, Usage, User
|
||||||
from src.services.email.email_template import EmailTemplate
|
from src.services.email.email_template import EmailTemplate
|
||||||
|
from src.services.provider_ops.types import SENSITIVE_CREDENTIAL_FIELDS
|
||||||
from src.services.system.config import SystemConfigService
|
from src.services.system.config import SystemConfigService
|
||||||
from src.utils.cache_decorator import cache_result
|
from src.utils.cache_decorator import cache_result
|
||||||
|
|
||||||
@@ -899,16 +900,7 @@ class AdminExportConfigAdapter(AdminApiAdapter):
|
|||||||
"""导出提供商和模型配置"""
|
"""导出提供商和模型配置"""
|
||||||
|
|
||||||
# Provider Ops 中需要解密的敏感字段
|
# Provider Ops 中需要解密的敏感字段
|
||||||
SENSITIVE_CREDENTIALS = {
|
SENSITIVE_CREDENTIALS = SENSITIVE_CREDENTIAL_FIELDS
|
||||||
"api_key",
|
|
||||||
"password",
|
|
||||||
"session_token",
|
|
||||||
"session_cookie",
|
|
||||||
"token_cookie",
|
|
||||||
"auth_cookie",
|
|
||||||
"cookie_string",
|
|
||||||
"cookie",
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_api_formats(raw_formats: Any) -> list[str]:
|
def _normalize_api_formats(raw_formats: Any) -> list[str]:
|
||||||
@@ -1180,16 +1172,7 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
|||||||
"""导入提供商和模型配置"""
|
"""导入提供商和模型配置"""
|
||||||
|
|
||||||
# Provider Ops 中需要加密的敏感字段
|
# Provider Ops 中需要加密的敏感字段
|
||||||
SENSITIVE_CREDENTIALS = {
|
SENSITIVE_CREDENTIALS = SENSITIVE_CREDENTIAL_FIELDS
|
||||||
"api_key",
|
|
||||||
"password",
|
|
||||||
"session_token",
|
|
||||||
"session_cookie",
|
|
||||||
"token_cookie",
|
|
||||||
"auth_cookie",
|
|
||||||
"cookie_string",
|
|
||||||
"cookie",
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_import_key_api_formats(
|
def _extract_import_key_api_formats(
|
||||||
@@ -1962,8 +1945,45 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
|
|
||||||
class AdminExportUsersAdapter(AdminApiAdapter):
|
class AdminExportUsersAdapter(AdminApiAdapter):
|
||||||
|
@staticmethod
|
||||||
|
def _serialize_api_key(key: ApiKey, include_is_standalone: bool = False) -> dict[str, Any]:
|
||||||
|
"""序列化用户 API Key 为导出格式。"""
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"key_hash": key.key_hash,
|
||||||
|
"name": key.name,
|
||||||
|
"balance_used_usd": key.balance_used_usd,
|
||||||
|
"current_balance_usd": key.current_balance_usd,
|
||||||
|
"allowed_providers": key.allowed_providers,
|
||||||
|
"allowed_api_formats": key.allowed_api_formats,
|
||||||
|
"allowed_models": key.allowed_models,
|
||||||
|
"rate_limit": key.rate_limit,
|
||||||
|
"concurrent_limit": key.concurrent_limit,
|
||||||
|
"force_capabilities": key.force_capabilities,
|
||||||
|
"is_active": key.is_active,
|
||||||
|
"expires_at": key.expires_at.isoformat() if key.expires_at else None,
|
||||||
|
"auto_delete_on_expiry": key.auto_delete_on_expiry,
|
||||||
|
"total_requests": key.total_requests,
|
||||||
|
"total_cost_usd": key.total_cost_usd,
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.key_encrypted:
|
||||||
|
try:
|
||||||
|
data["key"] = crypto_service.decrypt(key.key_encrypted, silent=True)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"[USERS_EXPORT] API Key 解密失败,回退为 legacy 密文字段: key_id={}", key.id
|
||||||
|
)
|
||||||
|
data["key_encrypted"] = key.key_encrypted
|
||||||
|
|
||||||
|
if include_is_standalone:
|
||||||
|
data["is_standalone"] = key.is_standalone
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
"""导出用户数据(保留加密数据,排除管理员)"""
|
"""导出用户数据(优先导出解密后的完整 Key,排除管理员)"""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from src.core.enums import UserRole
|
from src.core.enums import UserRole
|
||||||
@@ -1971,30 +1991,6 @@ class AdminExportUsersAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
db = context.db
|
db = context.db
|
||||||
|
|
||||||
def _serialize_api_key(key: ApiKey, include_is_standalone: bool = False) -> dict:
|
|
||||||
"""序列化 API Key 为导出格式"""
|
|
||||||
data = {
|
|
||||||
"key_hash": key.key_hash,
|
|
||||||
"key_encrypted": key.key_encrypted,
|
|
||||||
"name": key.name,
|
|
||||||
"balance_used_usd": key.balance_used_usd,
|
|
||||||
"current_balance_usd": key.current_balance_usd,
|
|
||||||
"allowed_providers": key.allowed_providers,
|
|
||||||
"allowed_api_formats": key.allowed_api_formats,
|
|
||||||
"allowed_models": key.allowed_models,
|
|
||||||
"rate_limit": key.rate_limit,
|
|
||||||
"concurrent_limit": key.concurrent_limit,
|
|
||||||
"force_capabilities": key.force_capabilities,
|
|
||||||
"is_active": key.is_active,
|
|
||||||
"expires_at": key.expires_at.isoformat() if key.expires_at else None,
|
|
||||||
"auto_delete_on_expiry": key.auto_delete_on_expiry,
|
|
||||||
"total_requests": key.total_requests,
|
|
||||||
"total_cost_usd": key.total_cost_usd,
|
|
||||||
}
|
|
||||||
if include_is_standalone:
|
|
||||||
data["is_standalone"] = key.is_standalone
|
|
||||||
return data
|
|
||||||
|
|
||||||
# 导出 Users(排除管理员)
|
# 导出 Users(排除管理员)
|
||||||
users = db.query(User).filter(User.is_deleted.is_(False), User.role != UserRole.ADMIN).all()
|
users = db.query(User).filter(User.is_deleted.is_(False), User.role != UserRole.ADMIN).all()
|
||||||
users_data = []
|
users_data = []
|
||||||
@@ -2006,12 +2002,13 @@ class AdminExportUsersAdapter(AdminApiAdapter):
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
api_keys_data = [
|
api_keys_data = [
|
||||||
_serialize_api_key(key, include_is_standalone=True) for key in api_keys
|
self._serialize_api_key(key, include_is_standalone=True) for key in api_keys
|
||||||
]
|
]
|
||||||
|
|
||||||
users_data.append(
|
users_data.append(
|
||||||
{
|
{
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
|
"email_verified": user.email_verified,
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
"password_hash": user.password_hash,
|
"password_hash": user.password_hash,
|
||||||
"role": user.role.value if user.role else "user",
|
"role": user.role.value if user.role else "user",
|
||||||
@@ -2029,10 +2026,10 @@ class AdminExportUsersAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
# 导出独立余额 Keys(管理员创建的,不属于普通用户)
|
# 导出独立余额 Keys(管理员创建的,不属于普通用户)
|
||||||
standalone_keys = db.query(ApiKey).filter(ApiKey.is_standalone.is_(True)).all()
|
standalone_keys = db.query(ApiKey).filter(ApiKey.is_standalone.is_(True)).all()
|
||||||
standalone_keys_data = [_serialize_api_key(key) for key in standalone_keys]
|
standalone_keys_data = [self._serialize_api_key(key) for key in standalone_keys]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"version": "1.1",
|
"version": "1.2",
|
||||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"users": users_data,
|
"users": users_data,
|
||||||
"standalone_keys": standalone_keys_data,
|
"standalone_keys": standalone_keys_data,
|
||||||
@@ -2040,6 +2037,22 @@ class AdminExportUsersAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
|
|
||||||
class AdminImportUsersAdapter(AdminApiAdapter):
|
class AdminImportUsersAdapter(AdminApiAdapter):
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_api_key_material(key_data: dict[str, Any]) -> tuple[str | None, str | None]:
|
||||||
|
"""解析用户 API Key 导入材料,优先使用明文 key。"""
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
|
from src.models.database import ApiKey
|
||||||
|
|
||||||
|
plaintext_key = key_data.get("key")
|
||||||
|
if isinstance(plaintext_key, str):
|
||||||
|
normalized = plaintext_key.strip()
|
||||||
|
if normalized:
|
||||||
|
return ApiKey.hash_key(normalized), crypto_service.encrypt(normalized)
|
||||||
|
|
||||||
|
key_hash = str(key_data.get("key_hash") or "").strip() or None
|
||||||
|
key_encrypted = key_data.get("key_encrypted")
|
||||||
|
return key_hash, key_encrypted
|
||||||
|
|
||||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
"""导入用户数据"""
|
"""导入用户数据"""
|
||||||
import uuid
|
import uuid
|
||||||
@@ -2079,7 +2092,7 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
|||||||
(None, "skipped"): key 已存在,跳过
|
(None, "skipped"): key 已存在,跳过
|
||||||
(None, "invalid"): 数据无效,跳过
|
(None, "invalid"): 数据无效,跳过
|
||||||
"""
|
"""
|
||||||
key_hash = key_data.get("key_hash", "").strip()
|
key_hash, key_encrypted = self._resolve_api_key_material(key_data)
|
||||||
if not key_hash:
|
if not key_hash:
|
||||||
return None, "invalid"
|
return None, "invalid"
|
||||||
|
|
||||||
@@ -2103,7 +2116,7 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
|||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
user_id=owner_id,
|
user_id=owner_id,
|
||||||
key_hash=key_hash,
|
key_hash=key_hash,
|
||||||
key_encrypted=key_data.get("key_encrypted"),
|
key_encrypted=key_encrypted,
|
||||||
name=key_data.get("name"),
|
name=key_data.get("name"),
|
||||||
is_standalone=is_standalone or key_data.get("is_standalone", False),
|
is_standalone=is_standalone or key_data.get("is_standalone", False),
|
||||||
balance_used_usd=key_data.get("balance_used_usd", 0.0),
|
balance_used_usd=key_data.get("balance_used_usd", 0.0),
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ class SyncRequestContext:
|
|||||||
mapped_model_result: str | None = None
|
mapped_model_result: str | None = None
|
||||||
sync_proxy_info: dict[str, Any] | None = None
|
sync_proxy_info: dict[str, Any] | None = None
|
||||||
provider_response_json: dict[str, Any] | None = None # 格式转换前的提供商原始响应
|
provider_response_json: dict[str, Any] | None = None # 格式转换前的提供商原始响应
|
||||||
|
pool_summary: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ChatSyncExecutor:
|
class ChatSyncExecutor:
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ async def run_endpoint_check(
|
|||||||
user: Any | None = None, # User对象
|
user: Any | None = None, # User对象
|
||||||
proxy_config: dict[str, Any] | None = None, # 原始代理配置(支持 tunnel 模式)
|
proxy_config: dict[str, Any] | None = None, # 原始代理配置(支持 tunnel 模式)
|
||||||
is_stream: bool | None = None, # 显式流式标记(优先于 body/url 推断)
|
is_stream: bool | None = None, # 显式流式标记(优先于 body/url 推断)
|
||||||
|
timeout: float | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
执行端点检查(重构版本,使用新的架构):
|
执行端点检查(重构版本,使用新的架构):
|
||||||
@@ -97,6 +98,7 @@ async def run_endpoint_check(
|
|||||||
request_id=str(uuid.uuid4())[:8],
|
request_id=str(uuid.uuid4())[:8],
|
||||||
proxy_config=proxy_config,
|
proxy_config=proxy_config,
|
||||||
is_stream=is_stream,
|
is_stream=is_stream,
|
||||||
|
timeout=float(timeout) if timeout is not None else 30.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 使用协调器执行检查
|
# 使用协调器执行检查
|
||||||
@@ -595,6 +597,7 @@ class HttpRequestExecutor:
|
|||||||
"""执行HTTP请求(支持流式和非流式响应)"""
|
"""执行HTTP请求(支持流式和非流式响应)"""
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
request_id = request.request_id or str(uuid.uuid4())[:8]
|
request_id = request.request_id or str(uuid.uuid4())[:8]
|
||||||
|
effective_timeout = float(request.timeout if request.timeout is not None else self.timeout)
|
||||||
|
|
||||||
# 检查是否是流式请求(优先显式参数,其次 body,最后 URL 推断)
|
# 检查是否是流式请求(优先显式参数,其次 body,最后 URL 推断)
|
||||||
if request.is_stream is not None:
|
if request.is_stream is not None:
|
||||||
@@ -618,7 +621,7 @@ class HttpRequestExecutor:
|
|||||||
|
|
||||||
# 统一通过 build_proxy_client_kwargs 构建(支持 tunnel 模式 + 普通代理 + 系统默认回退)
|
# 统一通过 build_proxy_client_kwargs 构建(支持 tunnel 模式 + 普通代理 + 系统默认回退)
|
||||||
client_kwargs = build_proxy_client_kwargs(
|
client_kwargs = build_proxy_client_kwargs(
|
||||||
proxy_config=request.proxy_config, timeout=self.timeout
|
proxy_config=request.proxy_config, timeout=effective_timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
async with httpx.AsyncClient(**client_kwargs) as client:
|
async with httpx.AsyncClient(**client_kwargs) as client:
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from src.core.api_format import (
|
|||||||
get_adapter_protected_keys_for_endpoint,
|
get_adapter_protected_keys_for_endpoint,
|
||||||
get_auth_handler,
|
get_auth_handler,
|
||||||
get_default_auth_method_for_endpoint,
|
get_default_auth_method_for_endpoint,
|
||||||
|
resolve_header_name_case,
|
||||||
)
|
)
|
||||||
from src.core.exceptions import (
|
from src.core.exceptions import (
|
||||||
ProviderAuthException,
|
ProviderAuthException,
|
||||||
@@ -343,6 +344,7 @@ class HandlerAdapterBase(ApiAdapter):
|
|||||||
provider_api_key: Any | None = None,
|
provider_api_key: Any | None = None,
|
||||||
# 代理配置
|
# 代理配置
|
||||||
proxy_config: dict[str, Any] | None = None,
|
proxy_config: dict[str, Any] | None = None,
|
||||||
|
timeout_seconds: float | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
测试模型连接性(非流式)
|
测试模型连接性(非流式)
|
||||||
@@ -458,7 +460,8 @@ class HandlerAdapterBase(ApiAdapter):
|
|||||||
default_auth_header, _ = get_auth_config_for_endpoint(cls.FORMAT_ID)
|
default_auth_header, _ = get_auth_config_for_endpoint(cls.FORMAT_ID)
|
||||||
if default_auth_header.lower() != "authorization":
|
if default_auth_header.lower() != "authorization":
|
||||||
headers.pop(default_auth_header, None)
|
headers.pop(default_auth_header, None)
|
||||||
headers["Authorization"] = f"Bearer {api_key}"
|
auth_header_name = resolve_header_name_case(extra_headers, "Authorization")
|
||||||
|
headers[auth_header_name] = f"Bearer {api_key}"
|
||||||
|
|
||||||
# ---- Body ----
|
# ---- Body ----
|
||||||
body = cls.build_request_body(request_data, base_url=base_url, provider_type=provider_type)
|
body = cls.build_request_body(request_data, base_url=base_url, provider_type=provider_type)
|
||||||
@@ -527,6 +530,7 @@ class HandlerAdapterBase(ApiAdapter):
|
|||||||
api_key_id=api_key_id,
|
api_key_id=api_key_id,
|
||||||
model_name=effective_model_name,
|
model_name=effective_model_name,
|
||||||
proxy_config=proxy_config,
|
proxy_config=proxy_config,
|
||||||
|
timeout=timeout_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from src.core.api_format import (
|
|||||||
HeaderBuilder,
|
HeaderBuilder,
|
||||||
get_auth_config_for_endpoint,
|
get_auth_config_for_endpoint,
|
||||||
make_signature_key,
|
make_signature_key,
|
||||||
|
resolve_header_name_case,
|
||||||
)
|
)
|
||||||
from src.core.crypto import crypto_service
|
from src.core.crypto import crypto_service
|
||||||
from src.models.endpoint_models import _CONDITION_OPS, _TYPE_IS_VALUES, parse_re_flags
|
from src.models.endpoint_models import _CONDITION_OPS, _TYPE_IS_VALUES, parse_re_flags
|
||||||
@@ -1250,7 +1251,7 @@ class PassthroughRequestBuilder(RequestBuilder):
|
|||||||
builder.add_many(effective_extra_headers)
|
builder.add_many(effective_extra_headers)
|
||||||
|
|
||||||
# 5. 设置认证头(最高优先级,上游始终使用 header 认证)
|
# 5. 设置认证头(最高优先级,上游始终使用 header 认证)
|
||||||
builder.add(auth_header, auth_value)
|
builder.add(resolve_header_name_case(original_headers, auth_header), auth_value)
|
||||||
|
|
||||||
# 6. 确保有 Content-Type
|
# 6. 确保有 Content-Type
|
||||||
headers = builder.build()
|
headers = builder.build()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from fastapi.responses import JSONResponse
|
|||||||
|
|
||||||
from src.api.handlers.base.chat_adapter_base import ChatAdapterBase, register_adapter
|
from src.api.handlers.base.chat_adapter_base import ChatAdapterBase, register_adapter
|
||||||
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
|
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
|
||||||
from src.core.api_format import ApiFamily, get_auth_handler
|
from src.core.api_format import ApiFamily, get_auth_handler, resolve_header_name_case
|
||||||
from src.core.api_format.enums import AuthMethod
|
from src.core.api_format.enums import AuthMethod
|
||||||
from src.core.api_format.headers import BROWSER_FINGERPRINT_HEADERS
|
from src.core.api_format.headers import BROWSER_FINGERPRINT_HEADERS
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
@@ -302,6 +302,7 @@ class GeminiChatAdapter(ChatAdapterBase):
|
|||||||
provider_api_key: Any | None = None,
|
provider_api_key: Any | None = None,
|
||||||
# 代理配置
|
# 代理配置
|
||||||
proxy_config: dict[str, Any] | None = None,
|
proxy_config: dict[str, Any] | None = None,
|
||||||
|
timeout_seconds: float | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""测试 Gemini API 模型连接性(非流式)"""
|
"""测试 Gemini API 模型连接性(非流式)"""
|
||||||
from src.api.handlers.base.endpoint_checker import run_endpoint_check
|
from src.api.handlers.base.endpoint_checker import run_endpoint_check
|
||||||
@@ -382,7 +383,8 @@ class GeminiChatAdapter(ChatAdapterBase):
|
|||||||
default_auth_header, _ = get_auth_config_for_endpoint(cls.FORMAT_ID)
|
default_auth_header, _ = get_auth_config_for_endpoint(cls.FORMAT_ID)
|
||||||
if default_auth_header.lower() != "authorization":
|
if default_auth_header.lower() != "authorization":
|
||||||
headers.pop(default_auth_header, None)
|
headers.pop(default_auth_header, None)
|
||||||
headers["Authorization"] = f"Bearer {api_key}"
|
auth_header_name = resolve_header_name_case(extra_headers, "Authorization")
|
||||||
|
headers[auth_header_name] = f"Bearer {api_key}"
|
||||||
|
|
||||||
body = cls.build_request_body(request_data)
|
body = cls.build_request_body(request_data)
|
||||||
|
|
||||||
@@ -432,6 +434,7 @@ class GeminiChatAdapter(ChatAdapterBase):
|
|||||||
api_key_id=api_key_id,
|
api_key_id=api_key_id,
|
||||||
model_name=effective_model_name,
|
model_name=effective_model_name,
|
||||||
proxy_config=proxy_config,
|
proxy_config=proxy_config,
|
||||||
|
timeout=timeout_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from src.core.api_format.headers import (
|
|||||||
merge_headers_with_protection,
|
merge_headers_with_protection,
|
||||||
normalize_headers,
|
normalize_headers,
|
||||||
redact_headers_for_log,
|
redact_headers_for_log,
|
||||||
|
resolve_header_name_case,
|
||||||
)
|
)
|
||||||
from src.core.api_format.metadata import (
|
from src.core.api_format.metadata import (
|
||||||
ENDPOINT_DEFINITIONS,
|
ENDPOINT_DEFINITIONS,
|
||||||
@@ -122,6 +123,7 @@ __all__ = [
|
|||||||
"merge_headers_with_protection",
|
"merge_headers_with_protection",
|
||||||
"filter_response_headers",
|
"filter_response_headers",
|
||||||
"redact_headers_for_log",
|
"redact_headers_for_log",
|
||||||
|
"resolve_header_name_case",
|
||||||
"build_adapter_base_headers_for_endpoint",
|
"build_adapter_base_headers_for_endpoint",
|
||||||
"build_adapter_headers_for_endpoint",
|
"build_adapter_headers_for_endpoint",
|
||||||
"get_adapter_protected_keys_for_endpoint",
|
"get_adapter_protected_keys_for_endpoint",
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ UPSTREAM_DROP_HEADERS: frozenset[str] = frozenset(
|
|||||||
"x-real-proto",
|
"x-real-proto",
|
||||||
"x-forwarded-for",
|
"x-forwarded-for",
|
||||||
"x-forwarded-proto",
|
"x-forwarded-proto",
|
||||||
|
"x-forwarded-scheme",
|
||||||
"x-forwarded-host",
|
"x-forwarded-host",
|
||||||
"x-forwarded-port",
|
"x-forwarded-port",
|
||||||
}
|
}
|
||||||
@@ -189,6 +190,19 @@ def extract_client_api_key_for_endpoint(
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_header_name_case(
|
||||||
|
headers: dict[str, str] | None,
|
||||||
|
preferred_key: str,
|
||||||
|
) -> str:
|
||||||
|
"""Preserve original header casing when replacing an existing header."""
|
||||||
|
if headers:
|
||||||
|
preferred_lower = preferred_key.lower()
|
||||||
|
for key in headers.keys():
|
||||||
|
if str(key).lower() == preferred_lower:
|
||||||
|
return str(key)
|
||||||
|
return preferred_key
|
||||||
|
|
||||||
|
|
||||||
def extract_client_api_key_for_endpoint_with_query(
|
def extract_client_api_key_for_endpoint_with_query(
|
||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
query_params: dict[str, str] | None,
|
query_params: dict[str, str] | None,
|
||||||
@@ -279,8 +293,11 @@ class HeaderBuilder:
|
|||||||
self._headers: dict[str, tuple[str, str]] = {}
|
self._headers: dict[str, tuple[str, str]] = {}
|
||||||
|
|
||||||
def add(self, key: str, value: str) -> HeaderBuilder:
|
def add(self, key: str, value: str) -> HeaderBuilder:
|
||||||
"""添加单个头部(会覆盖同名头部)"""
|
"""添加单个头部(会覆盖同名头部,但保留已存在 key 的原始大小写)"""
|
||||||
self._headers[key.lower()] = (key, value)
|
key_lower = key.lower()
|
||||||
|
existing = self._headers.get(key_lower)
|
||||||
|
stored_key = existing[0] if existing else key
|
||||||
|
self._headers[key_lower] = (stored_key, value)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def add_many(self, headers: dict[str, str]) -> HeaderBuilder:
|
def add_many(self, headers: dict[str, str]) -> HeaderBuilder:
|
||||||
@@ -454,7 +471,7 @@ def build_upstream_headers_for_endpoint(
|
|||||||
if extra_headers:
|
if extra_headers:
|
||||||
builder.add_many(extra_headers)
|
builder.add_many(extra_headers)
|
||||||
|
|
||||||
builder.add(auth_header, auth_value)
|
builder.add(resolve_header_name_case(original_headers, auth_header), auth_value)
|
||||||
|
|
||||||
result = builder.build()
|
result = builder.build()
|
||||||
if not any(k.lower() == "content-type" for k in result):
|
if not any(k.lower() == "content-type" for k in result):
|
||||||
|
|||||||
@@ -193,8 +193,8 @@ class CandidateResolver:
|
|||||||
self,
|
self,
|
||||||
all_candidates: list[ProviderCandidate],
|
all_candidates: list[ProviderCandidate],
|
||||||
request_id: str | None,
|
request_id: str | None,
|
||||||
user_id: str,
|
user_id: str | None,
|
||||||
user_api_key: ApiKey,
|
user_api_key: ApiKey | None,
|
||||||
required_capabilities: dict[str, bool] | None = None,
|
required_capabilities: dict[str, bool] | None = None,
|
||||||
*,
|
*,
|
||||||
expand_retries: bool = True,
|
expand_retries: bool = True,
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ class RequestDispatcher:
|
|||||||
candidate_index: int,
|
candidate_index: int,
|
||||||
retry_index: int,
|
retry_index: int,
|
||||||
candidate_record_id: str,
|
candidate_record_id: str,
|
||||||
user_api_key: ApiKey,
|
user_api_key: ApiKey | None,
|
||||||
|
user_id: str | None,
|
||||||
request_func: Callable[..., Any],
|
request_func: Callable[..., Any],
|
||||||
request_id: str | None,
|
request_id: str | None,
|
||||||
api_format: str,
|
api_format: str,
|
||||||
@@ -112,6 +113,7 @@ class RequestDispatcher:
|
|||||||
candidate_id=candidate_record_id,
|
candidate_id=candidate_record_id,
|
||||||
candidate_index=candidate_index,
|
candidate_index=candidate_index,
|
||||||
user_api_key=user_api_key,
|
user_api_key=user_api_key,
|
||||||
|
user_id=user_id,
|
||||||
request_func=request_func,
|
request_func=request_func,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
api_format=api_format,
|
api_format=api_format,
|
||||||
|
|||||||
@@ -14,10 +14,8 @@ to extra_headers().
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from src.config.settings import config
|
|
||||||
from src.services.provider.adapters.codex.context import (
|
from src.services.provider.adapters.codex.context import (
|
||||||
CodexRequestContext,
|
CodexRequestContext,
|
||||||
get_codex_request_context,
|
get_codex_request_context,
|
||||||
@@ -30,36 +28,12 @@ class CodexOAuthEnvelope:
|
|||||||
"""Provider envelope hooks for Codex OAuth upstream."""
|
"""Provider envelope hooks for Codex OAuth upstream."""
|
||||||
|
|
||||||
name = "codex:oauth"
|
name = "codex:oauth"
|
||||||
_CODEX_VERSION = "0.101.0"
|
|
||||||
_CODEX_ORIGINATOR = "codex_cli_rs"
|
|
||||||
|
|
||||||
def extra_headers(self) -> dict[str, str] | None:
|
def extra_headers(self) -> dict[str, str] | None:
|
||||||
# Keep these headers provider-scoped to avoid leaking to other upstreams.
|
# Codex desktop clients already send the protocol-specific headers they need.
|
||||||
headers: dict[str, str] = {
|
# Preserve the original request headers as much as possible and avoid injecting
|
||||||
# Codex upstream is strict about Content-Type; variants like
|
# synthetic CLI identity headers here.
|
||||||
# "application/json; charset=utf-8" are rejected.
|
return None
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Version": self._CODEX_VERSION,
|
|
||||||
"Session_id": str(uuid.uuid4()),
|
|
||||||
"Connection": "Keep-Alive",
|
|
||||||
"Originator": self._CODEX_ORIGINATOR,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Compact endpoint is non-stream; normal responses endpoint expects SSE.
|
|
||||||
ctx = get_codex_request_context()
|
|
||||||
is_compact = bool(ctx.is_compact) if ctx else False
|
|
||||||
headers["Accept"] = "application/json" if is_compact else "text/event-stream"
|
|
||||||
|
|
||||||
ua = str(getattr(config, "internal_user_agent_openai_cli", "") or "").strip()
|
|
||||||
if ua:
|
|
||||||
headers["User-Agent"] = ua
|
|
||||||
|
|
||||||
# Add chatgpt-account-id from context (set by wrap_request).
|
|
||||||
# Context is NOT cleared here — build_codex_url reads is_compact from it later.
|
|
||||||
if ctx and ctx.account_id:
|
|
||||||
headers["Chatgpt-Account-Id"] = ctx.account_id
|
|
||||||
|
|
||||||
return headers
|
|
||||||
|
|
||||||
def wrap_request(
|
def wrap_request(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from src.models.database import Provider
|
|||||||
from src.services.provider_ops.architectures import ProviderConnector
|
from src.services.provider_ops.architectures import ProviderConnector
|
||||||
from src.services.provider_ops.registry import get_registry
|
from src.services.provider_ops.registry import get_registry
|
||||||
from src.services.provider_ops.types import (
|
from src.services.provider_ops.types import (
|
||||||
|
SENSITIVE_CREDENTIAL_FIELDS,
|
||||||
ActionResult,
|
ActionResult,
|
||||||
ActionStatus,
|
ActionStatus,
|
||||||
BalanceInfo,
|
BalanceInfo,
|
||||||
@@ -94,17 +95,7 @@ class ProviderOpsService:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# 凭据中需要加密的字段
|
# 凭据中需要加密的字段
|
||||||
SENSITIVE_FIELDS = {
|
SENSITIVE_FIELDS = SENSITIVE_CREDENTIAL_FIELDS
|
||||||
"api_key",
|
|
||||||
"password",
|
|
||||||
"refresh_token",
|
|
||||||
"session_token",
|
|
||||||
"session_cookie",
|
|
||||||
"token_cookie",
|
|
||||||
"auth_cookie",
|
|
||||||
"cookie_string",
|
|
||||||
"cookie",
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ from datetime import datetime, timezone
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
SENSITIVE_CREDENTIAL_FIELDS = frozenset(
|
||||||
|
{
|
||||||
|
"api_key",
|
||||||
|
"password",
|
||||||
|
"refresh_token",
|
||||||
|
"session_token",
|
||||||
|
"session_cookie",
|
||||||
|
"token_cookie",
|
||||||
|
"auth_cookie",
|
||||||
|
"cookie_string",
|
||||||
|
"cookie",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ConnectorAuthType(str, Enum):
|
class ConnectorAuthType(str, Enum):
|
||||||
"""连接器认证类型"""
|
"""连接器认证类型"""
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ class RequestExecutor:
|
|||||||
candidate: Any,
|
candidate: Any,
|
||||||
candidate_id: str,
|
candidate_id: str,
|
||||||
candidate_index: int,
|
candidate_index: int,
|
||||||
user_api_key: Any,
|
user_api_key: Any | None,
|
||||||
|
user_id: str | None = None,
|
||||||
request_func: Callable[..., Any],
|
request_func: Callable[..., Any],
|
||||||
request_id: str | None,
|
request_id: str | None,
|
||||||
api_format: str,
|
api_format: str,
|
||||||
@@ -93,8 +94,8 @@ class RequestExecutor:
|
|||||||
provider_id=provider.id,
|
provider_id=provider.id,
|
||||||
endpoint_id=endpoint.id,
|
endpoint_id=endpoint.id,
|
||||||
key_id=key.id,
|
key_id=key.id,
|
||||||
user_id=user_api_key.user_id,
|
user_id=user_id if user_id is not None else getattr(user_api_key, "user_id", None),
|
||||||
api_key_id=user_api_key.id,
|
api_key_id=getattr(user_api_key, "id", None),
|
||||||
is_cached_user=is_cached_user,
|
is_cached_user=is_cached_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,329 @@ class TaskService:
|
|||||||
request_body=request_body,
|
request_body=request_body,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def execute_sync_candidates(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
api_format: str,
|
||||||
|
model_name: str,
|
||||||
|
candidates: list[Any],
|
||||||
|
request_func: Callable[..., Any],
|
||||||
|
request_id: str | None = None,
|
||||||
|
current_user: User | None = None,
|
||||||
|
user_api_key: ApiKey | None = None,
|
||||||
|
is_stream: bool = False,
|
||||||
|
capability_requirements: dict[str, bool] | None = None,
|
||||||
|
request_body_ref: dict[str, Any] | None = None,
|
||||||
|
request_headers: dict[str, Any] | None = None,
|
||||||
|
request_body: dict[str, Any] | None = None,
|
||||||
|
affinity_key: str | None = None,
|
||||||
|
create_pending_usage: bool = False,
|
||||||
|
enable_cache_affinity: bool = False,
|
||||||
|
) -> ExecutionResult:
|
||||||
|
"""Execute a pre-built candidate set through the unified SYNC runtime."""
|
||||||
|
from src.services.rate_limit.adaptive_rpm import get_adaptive_rpm_manager
|
||||||
|
from src.services.rate_limit.concurrency_manager import get_concurrency_manager
|
||||||
|
from src.services.request.executor import RequestExecutor
|
||||||
|
|
||||||
|
if not request_id:
|
||||||
|
request_id = str(uuid4())
|
||||||
|
|
||||||
|
api_format_norm = normalize_endpoint_signature(api_format)
|
||||||
|
|
||||||
|
priority_mode = SystemConfigService.get_config(
|
||||||
|
self.db,
|
||||||
|
"provider_priority_mode",
|
||||||
|
CacheAwareScheduler.PRIORITY_MODE_PROVIDER,
|
||||||
|
)
|
||||||
|
scheduling_mode = SystemConfigService.get_config(
|
||||||
|
self.db,
|
||||||
|
"scheduling_mode",
|
||||||
|
CacheAwareScheduler.SCHEDULING_MODE_CACHE_AFFINITY,
|
||||||
|
)
|
||||||
|
cache_scheduler = await get_cache_aware_scheduler(
|
||||||
|
self.redis,
|
||||||
|
priority_mode=priority_mode,
|
||||||
|
scheduling_mode=scheduling_mode,
|
||||||
|
)
|
||||||
|
await cache_scheduler._ensure_initialized()
|
||||||
|
|
||||||
|
concurrency_manager = await get_concurrency_manager()
|
||||||
|
adaptive_manager = get_adaptive_rpm_manager()
|
||||||
|
request_executor = RequestExecutor(
|
||||||
|
db=self.db,
|
||||||
|
concurrency_manager=concurrency_manager,
|
||||||
|
adaptive_manager=adaptive_manager,
|
||||||
|
)
|
||||||
|
candidate_resolver = CandidateResolver(
|
||||||
|
db=self.db,
|
||||||
|
cache_scheduler=cache_scheduler,
|
||||||
|
)
|
||||||
|
error_classifier = ErrorClassifier(
|
||||||
|
db=self.db,
|
||||||
|
cache_scheduler=cache_scheduler,
|
||||||
|
adaptive_manager=adaptive_manager,
|
||||||
|
)
|
||||||
|
request_dispatcher = RequestDispatcher(
|
||||||
|
db=self.db,
|
||||||
|
request_executor=request_executor,
|
||||||
|
cache_scheduler=cache_scheduler if enable_cache_affinity else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_user = current_user
|
||||||
|
if resolved_user is None and user_api_key is not None:
|
||||||
|
try:
|
||||||
|
resolved_user = user_api_key.user if hasattr(user_api_key, "user") else None
|
||||||
|
except Exception:
|
||||||
|
resolved_user = None
|
||||||
|
if resolved_user is None and getattr(user_api_key, "user_id", None):
|
||||||
|
resolved_user = self.db.query(User).filter(User.id == user_api_key.user_id).first()
|
||||||
|
|
||||||
|
user_id: str | None = None
|
||||||
|
if resolved_user is not None and getattr(resolved_user, "id", None):
|
||||||
|
user_id = str(resolved_user.id)
|
||||||
|
elif user_api_key is not None and getattr(user_api_key, "user_id", None):
|
||||||
|
user_id = str(user_api_key.user_id)
|
||||||
|
|
||||||
|
resolved_affinity_key = affinity_key
|
||||||
|
if not resolved_affinity_key:
|
||||||
|
api_key_id = getattr(user_api_key, "id", None) if user_api_key is not None else None
|
||||||
|
resolved_affinity_key = str(api_key_id) if api_key_id else f"internal-test:{request_id}"
|
||||||
|
|
||||||
|
if create_pending_usage:
|
||||||
|
try:
|
||||||
|
UsageService.create_pending_usage(
|
||||||
|
db=self.db,
|
||||||
|
request_id=request_id,
|
||||||
|
user=resolved_user,
|
||||||
|
api_key=user_api_key,
|
||||||
|
model=model_name,
|
||||||
|
is_stream=is_stream,
|
||||||
|
api_format=api_format_norm,
|
||||||
|
request_headers=request_headers,
|
||||||
|
request_body=request_body,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("创建 pending 使用记录失败: {}", str(exc))
|
||||||
|
|
||||||
|
all_candidates = list(candidates)
|
||||||
|
all_candidates, pool_traces = await self._apply_pool_reorder(
|
||||||
|
all_candidates, request_body=request_body
|
||||||
|
)
|
||||||
|
|
||||||
|
candidate_record_map = candidate_resolver.create_candidate_records(
|
||||||
|
all_candidates=all_candidates,
|
||||||
|
request_id=request_id,
|
||||||
|
user_id=user_id,
|
||||||
|
user_api_key=user_api_key,
|
||||||
|
required_capabilities=capability_requirements,
|
||||||
|
)
|
||||||
|
|
||||||
|
max_attempts = candidate_resolver.count_total_attempts(all_candidates)
|
||||||
|
last_error: Exception | None = None
|
||||||
|
last_candidate: Any | None = all_candidates[-1] if all_candidates else None
|
||||||
|
|
||||||
|
async def _attempt(candidate: Any) -> AttemptResult:
|
||||||
|
nonlocal last_candidate
|
||||||
|
last_candidate = candidate
|
||||||
|
|
||||||
|
candidate_index = int(getattr(candidate, "_utf_candidate_index", -1))
|
||||||
|
retry_index = int(getattr(candidate, "_utf_retry_index", 0))
|
||||||
|
candidate_record_id = str(getattr(candidate, "_utf_candidate_record_id", "") or "")
|
||||||
|
attempt_counter = int(getattr(candidate, "_utf_attempt_count", 0))
|
||||||
|
max_attempts_local = int(getattr(candidate, "_utf_max_attempts", max_attempts))
|
||||||
|
|
||||||
|
if not candidate_record_id:
|
||||||
|
from src.services.scheduling.schemas import PoolCandidate
|
||||||
|
|
||||||
|
pool_extra = (
|
||||||
|
getattr(candidate.key, "_pool_extra_data", None)
|
||||||
|
if isinstance(getattr(candidate.key, "_pool_extra_data", None), dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
extra_data: dict[str, Any] = {
|
||||||
|
"needs_conversion": bool(getattr(candidate, "needs_conversion", False)),
|
||||||
|
"provider_api_format": getattr(candidate, "provider_api_format", None) or None,
|
||||||
|
"mapping_matched_model": getattr(candidate, "mapping_matched_model", None)
|
||||||
|
or None,
|
||||||
|
**pool_extra,
|
||||||
|
}
|
||||||
|
if isinstance(candidate, PoolCandidate):
|
||||||
|
extra_data["pool_group_id"] = str(candidate.provider.id)
|
||||||
|
extra_data["pool_key_index"] = int(
|
||||||
|
getattr(candidate, "_pool_key_index", 0) or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
candidate_record = RequestCandidateService.create_candidate(
|
||||||
|
db=self.db,
|
||||||
|
request_id=request_id,
|
||||||
|
candidate_index=candidate_index,
|
||||||
|
retry_index=retry_index,
|
||||||
|
user_id=user_id,
|
||||||
|
api_key_id=(getattr(user_api_key, "id", None) if user_api_key else None),
|
||||||
|
provider_id=str(candidate.provider.id),
|
||||||
|
endpoint_id=str(candidate.endpoint.id),
|
||||||
|
key_id=str(candidate.key.id),
|
||||||
|
status="available",
|
||||||
|
is_cached=bool(getattr(candidate, "is_cached", False)),
|
||||||
|
extra_data=extra_data,
|
||||||
|
)
|
||||||
|
self.db.flush()
|
||||||
|
candidate_record_id = str(candidate_record.id)
|
||||||
|
candidate_record_map[(candidate_index, retry_index)] = candidate_record_id
|
||||||
|
setattr(candidate, "_utf_candidate_record_id", candidate_record_id)
|
||||||
|
|
||||||
|
(
|
||||||
|
response,
|
||||||
|
_provider_name,
|
||||||
|
attempt_id,
|
||||||
|
_provider_id,
|
||||||
|
_endpoint_id,
|
||||||
|
_key_id,
|
||||||
|
_first_byte_time_ms,
|
||||||
|
) = await request_dispatcher.dispatch(
|
||||||
|
candidate=candidate,
|
||||||
|
candidate_index=candidate_index,
|
||||||
|
retry_index=retry_index,
|
||||||
|
candidate_record_id=candidate_record_id,
|
||||||
|
user_api_key=user_api_key,
|
||||||
|
user_id=user_id,
|
||||||
|
request_func=request_func,
|
||||||
|
request_id=request_id,
|
||||||
|
api_format=api_format_norm,
|
||||||
|
model_name=model_name,
|
||||||
|
affinity_key=resolved_affinity_key,
|
||||||
|
global_model_id=model_name,
|
||||||
|
attempt_counter=attempt_counter,
|
||||||
|
max_attempts=max_attempts_local,
|
||||||
|
is_stream=is_stream,
|
||||||
|
)
|
||||||
|
_ = (attempt_id, _provider_name, _provider_id, _endpoint_id, _key_id)
|
||||||
|
|
||||||
|
await self._pool_on_success(
|
||||||
|
candidate,
|
||||||
|
request_body,
|
||||||
|
ttfb_ms=_first_byte_time_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_stream:
|
||||||
|
return AttemptResult(
|
||||||
|
kind=AttemptKind.STREAM,
|
||||||
|
http_status=200,
|
||||||
|
http_headers={},
|
||||||
|
stream_iterator=response,
|
||||||
|
)
|
||||||
|
return AttemptResult(
|
||||||
|
kind=AttemptKind.SYNC_RESPONSE,
|
||||||
|
http_status=200,
|
||||||
|
http_headers={},
|
||||||
|
response_body=response,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_exec_err(
|
||||||
|
*,
|
||||||
|
exec_err: Any,
|
||||||
|
candidate: Any,
|
||||||
|
candidate_index: int,
|
||||||
|
retry_index: int,
|
||||||
|
max_retries_for_candidate: int,
|
||||||
|
record_id: str | None,
|
||||||
|
attempt_count: int,
|
||||||
|
max_attempts: int | None,
|
||||||
|
) -> tuple[Any, int | None]:
|
||||||
|
nonlocal last_error, last_candidate
|
||||||
|
last_candidate = candidate
|
||||||
|
last_error = getattr(exec_err, "cause", None)
|
||||||
|
|
||||||
|
candidate_record_id = str(record_id or "") or str(
|
||||||
|
candidate_record_map.get((candidate_index, 0), "")
|
||||||
|
)
|
||||||
|
|
||||||
|
action = await self._handle_candidate_error(
|
||||||
|
exec_err=exec_err,
|
||||||
|
candidate=candidate,
|
||||||
|
candidate_record_id=candidate_record_id,
|
||||||
|
retry_index=retry_index,
|
||||||
|
max_retries_for_candidate=max_retries_for_candidate,
|
||||||
|
affinity_key=resolved_affinity_key,
|
||||||
|
api_format=api_format_norm,
|
||||||
|
global_model_id=model_name,
|
||||||
|
request_id=request_id,
|
||||||
|
attempt=attempt_count,
|
||||||
|
max_attempts=int(max_attempts or 0),
|
||||||
|
request_body_ref=request_body_ref,
|
||||||
|
error_classifier=error_classifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
if action == "continue":
|
||||||
|
new_max = None
|
||||||
|
if request_body_ref and request_body_ref.get("_rectified_this_turn", False):
|
||||||
|
request_body_ref["_rectified_this_turn"] = False
|
||||||
|
new_max = max(max_retries_for_candidate, retry_index + 2)
|
||||||
|
return ("retry", new_max)
|
||||||
|
|
||||||
|
if action == "break":
|
||||||
|
return ("continue", None)
|
||||||
|
|
||||||
|
if action == "raise":
|
||||||
|
if last_error is not None:
|
||||||
|
self._attach_metadata_to_error(
|
||||||
|
last_error, last_candidate, model_name, api_format_norm
|
||||||
|
)
|
||||||
|
raise last_error
|
||||||
|
raise
|
||||||
|
|
||||||
|
return ("continue", None)
|
||||||
|
|
||||||
|
engine = FailoverEngine(
|
||||||
|
self.db,
|
||||||
|
error_classifier=error_classifier,
|
||||||
|
recorder=self._recorder,
|
||||||
|
)
|
||||||
|
result = await engine.execute(
|
||||||
|
candidates=all_candidates,
|
||||||
|
attempt_func=_attempt,
|
||||||
|
retry_policy=RetryPolicy.for_sync_task(),
|
||||||
|
skip_policy=SkipPolicy(),
|
||||||
|
request_id=request_id,
|
||||||
|
user_id=user_id,
|
||||||
|
api_key_id=(
|
||||||
|
str(user_api_key.id) if user_api_key and getattr(user_api_key, "id", None) else None
|
||||||
|
),
|
||||||
|
candidate_record_map=candidate_record_map,
|
||||||
|
max_attempts=max_attempts,
|
||||||
|
execution_error_handler=_handle_exec_err,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
if pool_traces and result.key_id:
|
||||||
|
try:
|
||||||
|
attempted_key_ids: set[str] = set()
|
||||||
|
for ck in result.candidate_keys or []:
|
||||||
|
status = str(getattr(ck, "status", "") or "").strip().lower()
|
||||||
|
if status in {"", "available", "pending", "skipped", "unused"}:
|
||||||
|
continue
|
||||||
|
kid = getattr(ck, "key_id", None)
|
||||||
|
if isinstance(kid, str) and kid:
|
||||||
|
attempted_key_ids.add(kid)
|
||||||
|
if not attempted_key_ids:
|
||||||
|
attempted_key_ids.add(str(result.key_id))
|
||||||
|
|
||||||
|
for pt in pool_traces:
|
||||||
|
summary = pt.build_summary(
|
||||||
|
result.key_id,
|
||||||
|
attempted_key_ids=attempted_key_ids,
|
||||||
|
)
|
||||||
|
if summary:
|
||||||
|
result.pool_summary = summary
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
self._raise_all_failed_exception(
|
||||||
|
request_id, max_attempts, last_candidate, model_name, api_format_norm, last_error
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_session_uuid(
|
def _extract_session_uuid(
|
||||||
provider_type: str, request_body: dict[str, Any] | None
|
provider_type: str, request_body: dict[str, Any] | None
|
||||||
@@ -587,6 +910,7 @@ class TaskService:
|
|||||||
retry_index=retry_index,
|
retry_index=retry_index,
|
||||||
candidate_record_id=candidate_record_id,
|
candidate_record_id=candidate_record_id,
|
||||||
user_api_key=user_api_key,
|
user_api_key=user_api_key,
|
||||||
|
user_id=user_id,
|
||||||
request_func=request_func,
|
request_func=request_func,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
api_format=api_format_norm,
|
api_format=api_format_norm,
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from src.api.handlers.base.request_builder import PassthroughRequestBuilder
|
||||||
from src.services.provider.adapters.codex.request_patching import (
|
from src.services.provider.adapters.codex.request_patching import (
|
||||||
maybe_patch_request_for_codex,
|
maybe_patch_request_for_codex,
|
||||||
patch_openai_cli_request_for_codex,
|
patch_openai_cli_request_for_codex,
|
||||||
@@ -110,42 +113,49 @@ def test_openai_cli_normalizer_request_from_internal_codex_variant_defaults_stor
|
|||||||
assert out["store"] is False
|
assert out["store"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_codex_envelope_extra_headers_includes_sse_accept_and_session() -> None:
|
def test_codex_envelope_extra_headers_does_not_inject_synthetic_headers() -> None:
|
||||||
from src.services.provider.adapters.codex.envelope import codex_oauth_envelope
|
from src.services.provider.adapters.codex.envelope import codex_oauth_envelope
|
||||||
|
|
||||||
headers = codex_oauth_envelope.extra_headers() or {}
|
assert codex_oauth_envelope.extra_headers() is None
|
||||||
assert headers.get("Accept") == "text/event-stream"
|
|
||||||
assert headers.get("Originator") == "codex_cli_rs"
|
|
||||||
assert headers.get("Version") == "0.101.0"
|
|
||||||
assert headers.get("Connection") == "Keep-Alive"
|
|
||||||
assert isinstance(headers.get("Session_id"), str)
|
|
||||||
assert headers.get("Session_id")
|
|
||||||
|
|
||||||
|
|
||||||
def test_codex_envelope_extra_headers_compact_uses_json_accept() -> None:
|
def test_codex_passthrough_builder_preserves_real_codex_headers() -> None:
|
||||||
from src.services.provider.adapters.codex.context import (
|
from src.services.provider.adapters.codex.envelope import codex_oauth_envelope
|
||||||
CodexRequestContext,
|
|
||||||
set_codex_request_context,
|
builder = PassthroughRequestBuilder()
|
||||||
|
endpoint = SimpleNamespace(api_family="openai", endpoint_kind="cli", header_rules=None)
|
||||||
|
key = SimpleNamespace(api_key="unused")
|
||||||
|
|
||||||
|
headers = builder.build_headers(
|
||||||
|
original_headers={
|
||||||
|
"accept": "text/event-stream",
|
||||||
|
"content-type": "application/json",
|
||||||
|
"user-agent": "Codex Desktop/0.108.0-alpha.12",
|
||||||
|
"originator": "Codex Desktop",
|
||||||
|
"x-codex-turn-metadata": '{"turn_id":"abc"}',
|
||||||
|
"x-forwarded-scheme": "https",
|
||||||
|
"host": "aether.hetunai.cn",
|
||||||
|
"content-length": "123",
|
||||||
|
},
|
||||||
|
endpoint=endpoint,
|
||||||
|
key=key,
|
||||||
|
pre_computed_auth=("Authorization", "Bearer upstream-token"),
|
||||||
|
envelope=codex_oauth_envelope,
|
||||||
)
|
)
|
||||||
from src.services.provider.adapters.codex.envelope import codex_oauth_envelope
|
|
||||||
|
|
||||||
set_codex_request_context(CodexRequestContext(is_compact=True))
|
assert headers["accept"] == "text/event-stream"
|
||||||
headers = codex_oauth_envelope.extra_headers() or {}
|
assert headers["content-type"] == "application/json"
|
||||||
assert headers.get("Accept") == "application/json"
|
assert headers["user-agent"] == "Codex Desktop/0.108.0-alpha.12"
|
||||||
set_codex_request_context(None)
|
assert headers["originator"] == "Codex Desktop"
|
||||||
|
assert headers["x-codex-turn-metadata"] == '{"turn_id":"abc"}'
|
||||||
|
assert headers["Authorization"] == "Bearer upstream-token"
|
||||||
def test_codex_envelope_extra_headers_uses_account_id_header() -> None:
|
assert "Version" not in headers
|
||||||
from src.services.provider.adapters.codex.context import (
|
assert "Session_id" not in headers
|
||||||
CodexRequestContext,
|
assert "Connection" not in headers
|
||||||
set_codex_request_context,
|
assert "Chatgpt-Account-Id" not in headers
|
||||||
)
|
assert "host" not in headers
|
||||||
from src.services.provider.adapters.codex.envelope import codex_oauth_envelope
|
assert "content-length" not in headers
|
||||||
|
assert "x-forwarded-scheme" not in headers
|
||||||
set_codex_request_context(CodexRequestContext(account_id="acc_123"))
|
|
||||||
headers = codex_oauth_envelope.extra_headers() or {}
|
|
||||||
assert headers.get("Chatgpt-Account-Id") == "acc_123"
|
|
||||||
set_codex_request_context(None)
|
|
||||||
|
|
||||||
|
|
||||||
def _encode_unsigned_jwt(payload: dict[str, object]) -> str:
|
def _encode_unsigned_jwt(payload: dict[str, object]) -> str:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from src.api.admin.system import AdminExportConfigAdapter, AdminImportConfigAdapter
|
from src.api.admin.system import AdminExportConfigAdapter, AdminImportConfigAdapter
|
||||||
|
from src.services.provider_ops.types import SENSITIVE_CREDENTIAL_FIELDS
|
||||||
|
|
||||||
|
|
||||||
def test_export_key_api_formats_falls_back_to_provider_endpoints_when_none() -> None:
|
def test_export_key_api_formats_falls_back_to_provider_endpoints_when_none() -> None:
|
||||||
@@ -59,3 +60,57 @@ def test_import_key_api_formats_keeps_explicit_empty_list() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeCrypto:
|
||||||
|
def encrypt(self, value: str) -> str:
|
||||||
|
return f"enc:{value}"
|
||||||
|
|
||||||
|
def decrypt(self, value: str) -> str:
|
||||||
|
return value.removeprefix("enc:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_ops_sensitive_fields_include_refresh_token() -> None:
|
||||||
|
assert "refresh_token" in SENSITIVE_CREDENTIAL_FIELDS
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_provider_config_decrypts_refresh_token() -> None:
|
||||||
|
adapter = AdminExportConfigAdapter()
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"provider_ops": {
|
||||||
|
"connector": {
|
||||||
|
"credentials": {
|
||||||
|
"refresh_token": "enc:rt-1",
|
||||||
|
"api_key": "enc:key-1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = adapter._decrypt_provider_config(config, _FakeCrypto())
|
||||||
|
|
||||||
|
assert result["provider_ops"]["connector"]["credentials"]["refresh_token"] == "rt-1"
|
||||||
|
assert result["provider_ops"]["connector"]["credentials"]["api_key"] == "key-1"
|
||||||
|
assert config["provider_ops"]["connector"]["credentials"]["refresh_token"] == "enc:rt-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_provider_config_encrypts_refresh_token() -> None:
|
||||||
|
adapter = AdminImportConfigAdapter()
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"provider_ops": {
|
||||||
|
"connector": {
|
||||||
|
"credentials": {
|
||||||
|
"refresh_token": "rt-1",
|
||||||
|
"api_key": "key-1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = adapter._encrypt_provider_config(config, _FakeCrypto())
|
||||||
|
|
||||||
|
assert result["provider_ops"]["connector"]["credentials"]["refresh_token"] == "enc:rt-1"
|
||||||
|
assert result["provider_ops"]["connector"]["credentials"]["api_key"] == "enc:key-1"
|
||||||
|
assert config["provider_ops"]["connector"]["credentials"]["refresh_token"] == "rt-1"
|
||||||
|
|||||||
57
tests/unit/test_admin_system_users_export_import.py
Normal file
57
tests/unit/test_admin_system_users_export_import.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from src.api.admin.system import AdminExportUsersAdapter, AdminImportUsersAdapter
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
|
from src.models.database import ApiKey
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_user_api_key_prefers_plaintext_key() -> None:
|
||||||
|
plaintext_key = "ak-user-plain-1"
|
||||||
|
key = ApiKey(
|
||||||
|
id="key-1",
|
||||||
|
user_id="user-1",
|
||||||
|
key_hash=ApiKey.hash_key(plaintext_key),
|
||||||
|
key_encrypted=crypto_service.encrypt(plaintext_key),
|
||||||
|
name="Demo Key",
|
||||||
|
is_standalone=False,
|
||||||
|
balance_used_usd=1.5,
|
||||||
|
current_balance_usd=8.5,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = AdminExportUsersAdapter._serialize_api_key(key, include_is_standalone=True)
|
||||||
|
|
||||||
|
assert data["key"] == plaintext_key
|
||||||
|
assert "key_encrypted" not in data
|
||||||
|
assert data["key_hash"] == ApiKey.hash_key(plaintext_key)
|
||||||
|
assert data["is_standalone"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_user_api_key_material_reencrypts_plaintext_key() -> None:
|
||||||
|
plaintext_key = "ak-user-plain-2"
|
||||||
|
|
||||||
|
key_hash, key_encrypted = AdminImportUsersAdapter._resolve_api_key_material(
|
||||||
|
{
|
||||||
|
"key": plaintext_key,
|
||||||
|
"key_hash": "stale-hash",
|
||||||
|
"key_encrypted": "stale-ciphertext",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert key_hash == ApiKey.hash_key(plaintext_key)
|
||||||
|
assert key_encrypted is not None
|
||||||
|
assert crypto_service.decrypt(key_encrypted) == plaintext_key
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_user_api_key_material_keeps_legacy_encrypted_payload() -> None:
|
||||||
|
legacy_plaintext = "ak-user-legacy-1"
|
||||||
|
legacy_encrypted = crypto_service.encrypt(legacy_plaintext)
|
||||||
|
legacy_hash = ApiKey.hash_key(legacy_plaintext)
|
||||||
|
|
||||||
|
key_hash, key_encrypted = AdminImportUsersAdapter._resolve_api_key_material(
|
||||||
|
{
|
||||||
|
"key_hash": legacy_hash,
|
||||||
|
"key_encrypted": legacy_encrypted,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert key_hash == legacy_hash
|
||||||
|
assert key_encrypted == legacy_encrypted
|
||||||
@@ -65,7 +65,8 @@ class TestHeaderBuilder:
|
|||||||
builder.add("authorization", "b")
|
builder.add("authorization", "b")
|
||||||
built = builder.build()
|
built = builder.build()
|
||||||
assert len(built) == 1
|
assert len(built) == 1
|
||||||
assert list(built.values()) == ["b"]
|
assert built["Authorization"] == "b"
|
||||||
|
assert "authorization" not in built
|
||||||
|
|
||||||
def test_add_protected_does_not_override(self) -> None:
|
def test_add_protected_does_not_override(self) -> None:
|
||||||
builder = HeaderBuilder()
|
builder = HeaderBuilder()
|
||||||
@@ -135,7 +136,8 @@ class TestBuildUpstreamHeaders:
|
|||||||
extra_headers={"User-Agent": "b"},
|
extra_headers={"User-Agent": "b"},
|
||||||
)
|
)
|
||||||
assert len([k for k in result if k.lower() == "user-agent"]) == 1
|
assert len([k for k in result if k.lower() == "user-agent"]) == 1
|
||||||
assert result["User-Agent"] == "b"
|
assert result["user-agent"] == "b"
|
||||||
|
assert "User-Agent" not in result
|
||||||
|
|
||||||
def test_default_content_type(self) -> None:
|
def test_default_content_type(self) -> None:
|
||||||
result = build_upstream_headers_for_endpoint({}, "openai:chat", "provider")
|
result = build_upstream_headers_for_endpoint({}, "openai:chat", "provider")
|
||||||
@@ -169,3 +171,35 @@ class TestCapabilityResolverHeaderParsing:
|
|||||||
request_headers={"x-require-capability": "context_1m"}
|
request_headers={"x-require-capability": "context_1m"}
|
||||||
)
|
)
|
||||||
assert reqs == {"context_1m": True}
|
assert reqs == {"context_1m": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthHeaderCasePreservation:
|
||||||
|
def test_build_upstream_headers_preserves_lowercase_authorization_key(self) -> None:
|
||||||
|
result = build_upstream_headers_for_endpoint(
|
||||||
|
{"authorization": "Bearer client-token", "X-Test": "1"},
|
||||||
|
"openai:chat",
|
||||||
|
"provider",
|
||||||
|
)
|
||||||
|
assert "authorization" in result
|
||||||
|
assert "Authorization" not in result
|
||||||
|
assert result["authorization"] == "Bearer provider"
|
||||||
|
|
||||||
|
def test_passthrough_request_builder_preserves_lowercase_authorization_key(self) -> None:
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from src.api.handlers.base.request_builder import PassthroughRequestBuilder
|
||||||
|
|
||||||
|
builder = PassthroughRequestBuilder()
|
||||||
|
endpoint = SimpleNamespace(api_family="openai", endpoint_kind="cli", header_rules=None)
|
||||||
|
key = SimpleNamespace(api_key="unused")
|
||||||
|
|
||||||
|
headers = builder.build_headers(
|
||||||
|
original_headers={"authorization": "Bearer client-token"},
|
||||||
|
endpoint=endpoint,
|
||||||
|
key=key,
|
||||||
|
pre_computed_auth=("Authorization", "Bearer provider-token"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "authorization" in headers
|
||||||
|
assert "Authorization" not in headers
|
||||||
|
assert headers["authorization"] == "Bearer provider-token"
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
from src.api.admin.provider_query import (
|
from src.api.admin.provider_query import (
|
||||||
_build_direct_test_candidates,
|
_build_direct_test_candidates,
|
||||||
|
_build_test_attempts_from_candidate_keys,
|
||||||
_filter_test_candidates_by_endpoint,
|
_filter_test_candidates_by_endpoint,
|
||||||
|
_resolve_test_effective_model,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -36,3 +38,54 @@ def test_filter_test_candidates_by_endpoint_keeps_matching_candidates() -> None:
|
|||||||
|
|
||||||
assert {candidate.endpoint.id for candidate in filtered} == {endpoint_a.id}
|
assert {candidate.endpoint.id for candidate in filtered} == {endpoint_a.id}
|
||||||
assert all(candidate.endpoint.id != endpoint_b.id for candidate in filtered)
|
assert all(candidate.endpoint.id != endpoint_b.id for candidate in filtered)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_test_effective_model_prefers_pool_key_mapping() -> None:
|
||||||
|
provider, endpoint_a, _endpoint_b = _build_provider()
|
||||||
|
candidate = _build_direct_test_candidates(provider, endpoint_id=endpoint_a.id)[0] # type: ignore[arg-type]
|
||||||
|
pool_key = SimpleNamespace(id="pool-key", _pool_mapping_matched_model="mapped-model")
|
||||||
|
request = SimpleNamespace(mode="global", model_name="gpt-4")
|
||||||
|
|
||||||
|
effective = _resolve_test_effective_model(
|
||||||
|
provider=provider, # type: ignore[arg-type]
|
||||||
|
candidate=candidate,
|
||||||
|
request=request, # type: ignore[arg-type]
|
||||||
|
gm_obj=None,
|
||||||
|
key=pool_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert effective == "mapped-model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_test_attempts_from_candidate_keys_includes_retry_index() -> None:
|
||||||
|
candidate_keys = [
|
||||||
|
SimpleNamespace(
|
||||||
|
candidate_index=2,
|
||||||
|
retry_index=1,
|
||||||
|
key_id="key-b",
|
||||||
|
key_name="Key B",
|
||||||
|
auth_type="api_key",
|
||||||
|
status="failed",
|
||||||
|
skip_reason=None,
|
||||||
|
error_message="timeout",
|
||||||
|
status_code=504,
|
||||||
|
latency_ms=1200,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
attempts = _build_test_attempts_from_candidate_keys(
|
||||||
|
candidate_keys=candidate_keys,
|
||||||
|
candidate_meta_by_pair={
|
||||||
|
(2, "key-b"): {
|
||||||
|
"endpoint_api_format": "openai:chat",
|
||||||
|
"endpoint_base_url": "https://example.test/v1",
|
||||||
|
"effective_model": "mapped-model",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
candidate_meta_by_index={},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(attempts) == 1
|
||||||
|
assert attempts[0].retry_index == 1
|
||||||
|
assert attempts[0].effective_model == "mapped-model"
|
||||||
|
assert attempts[0].endpoint_api_format == "openai:chat"
|
||||||
|
|||||||
Reference in New Issue
Block a user