refactor: 统一代理配置优先级链(key>provider>系统默认)并复用 HTTP 连接池

- 引入 resolve_proxy_param / build_proxy_client_kwargs 工具函数,统一
  httpx 客户端的代理+SSL+超时配置,替换各模块中零散的 get_ssl_context() 调用
- 所有涉及上游请求的模块(provider_query, usage replay, endpoint check,
  model fetch, OAuth, Vertex Auth, Gemini Files/Video 等)改用
  resolve_effective_proxy 按 key > provider > 系统默认优先级解析代理
- 流式请求改用 HTTPClientPool.get_upstream_client 复用连接池,移除各处
  http_client.aclose() 避免关闭共享客户端
- StreamProcessor._cleanup 不再关闭池中客户端,仅清理响应上下文
- 前端 EndpointFormDialog 增加 body_rules 帮助说明 Popover
- Mock handler 补充 OAuth 字段、endpoint extras 及新增 mock 路由
This commit is contained in:
fawney19
2026-02-11 03:16:53 +08:00
parent cd0acf1e6b
commit bda03d187e
22 changed files with 749 additions and 322 deletions

View File

@@ -294,9 +294,71 @@
<div <div
v-if="getEndpointEditBodyRules(endpoint.id).length > 0" v-if="getEndpointEditBodyRules(endpoint.id).length > 0"
class="text-xs text-muted-foreground px-2" class="flex items-center gap-1 text-xs text-muted-foreground px-2"
> >
<code class="bg-muted px-1 rounded">.</code> 嵌套字段 / <code class="bg-muted px-1 rounded">[N]</code> 数组索引;值为 JSON 格式 <span><code class="bg-muted px-1 rounded">.</code> 嵌套字段 / <code class="bg-muted px-1 rounded">[N]</code> 数组索引;值为 JSON 格式</span>
<div class="flex-1" />
<Popover
:open="bodyRuleHelpOpenEndpointId === endpoint.id"
@update:open="(v: boolean) => setBodyRuleHelpOpen(endpoint.id, v)"
>
<PopoverTrigger as-child>
<button
type="button"
class="shrink-0 h-6 w-6 inline-flex items-center justify-center rounded-md hover:bg-muted/60"
title="规则说明"
aria-label="规则说明"
>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground/60" />
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="end"
:side-offset="6"
class="w-80 p-3 !z-[90]"
>
<div class="text-xs space-y-2">
<div>
<div class="font-medium mb-0.5">
路径语法
</div>
<div class="text-muted-foreground">
<code>metadata.user_id</code> 嵌套字段<br>
<code>messages[0].content</code> 数组索引<br>
<code>config\.v1.key</code> 转义点号
</div>
</div>
<div>
<div class="font-medium mb-0.5">
值格式 (JSON)
</div>
<div class="text-muted-foreground">
<code>123</code> 数字 / <code>"text"</code> 字符串 / <code>true</code> 布尔<br>
<code>{"k":"v"}</code> 对象 / <code>[1,2]</code> 数组 / <code>null</code><br>
<code v-pre>{{$original}}</code> 引用原值
</div>
</div>
<div>
<div class="font-medium mb-0.5">
条件运算符
</div>
<div class="text-muted-foreground">
<code>eq</code> <code>neq</code> 等于/不等于<br>
<code>gt</code> <code>lt</code> <code>gte</code> <code>lte</code> 大小比较<br>
<code>starts_with</code> <code>ends_with</code> <code>contains</code> 字符串匹配<br>
<code>matches</code> 正则匹配<br>
<code>exists</code> <code>not_exists</code> 字段存在性<br>
<code>in</code> 在列表中(值填 <code>["a","b"]</code><br>
<code>type_is</code> 类型判断string/number/boolean/array/object/null
</div>
</div>
<div class="text-muted-foreground">
规则按顺序执行,前面的修改对后续规则可见。
</div>
</div>
</PopoverContent>
</Popover>
</div> </div>
<!-- 请求体规则列表 - 次要色边框 --> <!-- 请求体规则列表 - 次要色边框 -->
@@ -338,6 +400,16 @@
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
:class="rule.conditionEnabled ? 'text-primary' : ''"
title="条件触发"
@click="toggleBodyRuleCondition(endpoint.id, index)"
>
<Filter class="w-3 h-3" />
</Button>
<template v-if="rule.action === 'set'"> <template v-if="rule.action === 'set'">
<Input <Input
:model-value="rule.path" :model-value="rule.path"
@@ -452,16 +524,6 @@
:title="getRegexPatternValidationTip(rule)" :title="getRegexPatternValidationTip(rule)"
/> />
</template> </template>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
:class="rule.conditionEnabled ? 'text-primary' : ''"
title="条件触发"
@click="toggleBodyRuleCondition(endpoint.id, index)"
>
<Filter class="w-3 h-3" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -664,8 +726,11 @@ import {
Collapsible, Collapsible,
CollapsibleTrigger, CollapsibleTrigger,
CollapsibleContent, CollapsibleContent,
Popover,
PopoverTrigger,
PopoverContent,
} from '@/components/ui' } from '@/components/ui'
import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateCcw, Radio, CheckCircle, Save, Filter } from 'lucide-vue-next' import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateCcw, Radio, CheckCircle, Save, Filter, HelpCircle } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import AlertDialog from '@/components/common/AlertDialog.vue' import AlertDialog from '@/components/common/AlertDialog.vue'
@@ -815,6 +880,13 @@ const endpointRulesExpanded = ref<Record<string, boolean>>({})
// 请求体规则 Select 的展开状态 // 请求体规则 Select 的展开状态
const bodyRuleSelectOpen = ref<Record<string, boolean>>({}) const bodyRuleSelectOpen = ref<Record<string, boolean>>({})
// 请求体规则说明 Popover 的展开状态
const bodyRuleHelpOpenEndpointId = ref<string | null>(null)
function setBodyRuleHelpOpen(endpointId: string, open: boolean) {
bodyRuleHelpOpenEndpointId.value = open ? endpointId : null
}
// 每个端点的编辑状态(内联编辑) // 每个端点的编辑状态(内联编辑)
const endpointEditStates = ref<Record<string, EndpointEditState>>({}) const endpointEditStates = ref<Record<string, EndpointEditState>>({})
@@ -1783,6 +1855,7 @@ onMounted(() => {
// 监听 props 变化 // 监听 props 变化
watch(() => props.modelValue, (open) => { watch(() => props.modelValue, (open) => {
bodyRuleHelpOpenEndpointId.value = null
if (open) { if (open) {
localEndpoints.value = [...(props.endpoints || [])] localEndpoints.value = [...(props.endpoints || [])]
// 清空编辑状态,重新从端点加载 // 清空编辑状态,重新从端点加载

View File

@@ -416,18 +416,60 @@ const MOCK_ALIASES = [
{ id: 'alias-004', source_model: 'gemini-pro', target_global_model_id: 'gm-005', target_global_model_name: 'gemini-3-pro-preview', target_global_model_display_name: 'Gemini 3 Pro Preview', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' } { id: 'alias-004', source_model: 'gemini-pro', target_global_model_id: 'gm-005', target_global_model_name: 'gemini-3-pro-preview', target_global_model_display_name: 'Gemini 3 Pro Preview', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }
] ]
function normalizeApiFormat(apiFormat: string): string {
return apiFormat.toLowerCase().replace(/_/g, ':')
}
function getMockEndpointExtras(apiFormat: string) {
const normalizedFormat = normalizeApiFormat(apiFormat)
const extras: Record<string, any> = {}
if (normalizedFormat === 'claude:chat') {
extras.header_rules = [
{ action: 'set', key: 'x-app-id', value: 'demo-app' },
{ action: 'rename', from: 'x-client-id', to: 'x-client' },
{ action: 'drop', key: 'x-debug' }
]
extras.body_rules = [
{ action: 'set', path: 'metadata.user_id', value: 'demo-user' },
{ action: 'insert', path: 'messages', index: 0, value: { role: 'system', content: 'You are a helpful assistant.' } },
{ action: 'regex_replace', path: 'messages[0].content', pattern: '\\s+', replacement: ' ', flags: 'm', condition: { path: 'metadata.source', op: 'eq', value: 'internal' } }
]
} else if (normalizedFormat === 'openai:chat') {
extras.custom_path = '/v1/chat/completions'
extras.header_rules = [
{ action: 'set', key: 'x-client', value: 'demo' }
]
extras.format_acceptance_config = {
enabled: true,
accept_formats: ['openai:chat', 'claude:chat']
}
extras.config = { upstream_stream_policy: 'force_stream' }
} else if (normalizedFormat === 'openai:cli') {
extras.config = { upstream_stream_policy: 'force_non_stream' }
} else if (normalizedFormat === 'gemini:chat') {
extras.custom_path = '/v1beta/models/gemini-3-pro-preview:generateContent'
extras.body_rules = [
{ action: 'drop', path: 'metadata.debug' }
]
}
return extras
}
// Mock Endpoint Keys // Mock Endpoint Keys
const MOCK_ENDPOINT_KEYS = [ const MOCK_ENDPOINT_KEYS = [
{ id: 'ekey-001', provider_id: 'provider-001', api_formats: ['claude:chat'], api_key_masked: 'sk-ant...abc1', name: 'Primary Key', rate_multiplier: 1.0, internal_priority: 1, health_score: 0.98, consecutive_failures: 0, request_count: 5000, success_count: 4950, error_count: 50, success_rate: 0.99, avg_response_time_ms: 1200, cache_ttl_minutes: 5, max_probe_interval_minutes: 32, is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString() }, { id: 'ekey-001', provider_id: 'provider-001', api_formats: ['claude:chat'], api_key_masked: 'sk-ant...abc1', auth_type: 'api_key', name: 'Primary Key', rate_multiplier: 1.0, internal_priority: 1, health_score: 0.98, consecutive_failures: 0, request_count: 5000, success_count: 4950, error_count: 50, success_rate: 0.99, avg_response_time_ms: 1200, cache_ttl_minutes: 5, max_probe_interval_minutes: 32, is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString() },
{ id: 'ekey-002', provider_id: 'provider-001', api_formats: ['claude:chat'], api_key_masked: 'sk-ant...def2', name: 'Backup Key', rate_multiplier: 1.0, internal_priority: 2, health_score: 0.95, consecutive_failures: 1, request_count: 2000, success_count: 1950, error_count: 50, success_rate: 0.975, avg_response_time_ms: 1350, cache_ttl_minutes: 5, max_probe_interval_minutes: 32, is_active: true, created_at: '2024-02-01T00:00:00Z', updated_at: new Date().toISOString() }, { id: 'ekey-002', provider_id: 'provider-001', api_formats: ['claude:chat'], api_key_masked: 'sk-ant...def2', auth_type: 'api_key', name: 'Backup Key', rate_multiplier: 1.0, internal_priority: 2, health_score: 0.95, consecutive_failures: 1, request_count: 2000, success_count: 1950, error_count: 50, success_rate: 0.975, avg_response_time_ms: 1350, cache_ttl_minutes: 5, max_probe_interval_minutes: 32, is_active: true, created_at: '2024-02-01T00:00:00Z', updated_at: new Date().toISOString() },
{ id: 'ekey-003', provider_id: 'provider-002', api_formats: ['openai:chat'], api_key_masked: 'sk-oai...ghi3', name: 'OpenAI Main', rate_multiplier: 1.0, internal_priority: 1, health_score: 0.97, consecutive_failures: 0, request_count: 3500, success_count: 3450, error_count: 50, success_rate: 0.986, avg_response_time_ms: 900, cache_ttl_minutes: 5, max_probe_interval_minutes: 32, is_active: true, created_at: '2024-01-15T00:00:00Z', updated_at: new Date().toISOString() } { id: 'ekey-003', provider_id: 'provider-002', api_formats: ['openai:chat'], api_key_masked: 'sk-oai...ghi3', auth_type: 'oauth', name: 'OpenAI OAuth', oauth_email: 'oauth-demo@aether.dev', oauth_expires_at: Math.floor(Date.now() / 1000) + 6 * 3600, oauth_plan_type: 'pro', oauth_account_id: 'acct-demo-002', rate_multiplier: 1.0, internal_priority: 1, health_score: 0.97, consecutive_failures: 0, request_count: 3500, success_count: 3450, error_count: 50, success_rate: 0.986, avg_response_time_ms: 900, cache_ttl_minutes: 5, max_probe_interval_minutes: 32, is_active: true, created_at: '2024-01-15T00:00:00Z', updated_at: new Date().toISOString() }
] ]
// Mock Endpoints // Mock Endpoints
const MOCK_ENDPOINTS = [ const MOCK_ENDPOINTS = [
{ id: 'ep-001', provider_id: 'provider-001', provider_name: 'anthropic', api_format: 'claude:chat', base_url: 'https://api.anthropic.com', max_retries: 2, is_active: true, total_keys: 2, active_keys: 2, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString() }, { id: 'ep-001', provider_id: 'provider-001', provider_name: 'anthropic', api_format: 'claude:chat', base_url: 'https://api.anthropic.com', max_retries: 2, is_active: true, total_keys: 2, active_keys: 2, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString(), ...getMockEndpointExtras('claude:chat') },
{ id: 'ep-002', provider_id: 'provider-002', provider_name: 'openai', api_format: 'openai:chat', base_url: 'https://api.openai.com', max_retries: 2, is_active: true, total_keys: 1, active_keys: 1, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString() }, { id: 'ep-002', provider_id: 'provider-002', provider_name: 'openai', api_format: 'openai:chat', base_url: 'https://api.openai.com', max_retries: 2, is_active: true, total_keys: 1, active_keys: 1, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString(), ...getMockEndpointExtras('openai:chat') },
{ id: 'ep-003', provider_id: 'provider-003', provider_name: 'google', api_format: 'gemini:chat', base_url: 'https://generativelanguage.googleapis.com', max_retries: 2, is_active: true, total_keys: 1, active_keys: 1, created_at: '2024-01-15T00:00:00Z', updated_at: new Date().toISOString() } { id: 'ep-003', provider_id: 'provider-003', provider_name: 'google', api_format: 'gemini:chat', base_url: 'https://generativelanguage.googleapis.com', max_retries: 2, is_active: true, total_keys: 1, active_keys: 1, created_at: '2024-01-15T00:00:00Z', updated_at: new Date().toISOString(), ...getMockEndpointExtras('gemini:chat') }
] ]
// Mock 能力定义 // Mock 能力定义
@@ -1212,21 +1254,24 @@ function generateMockEndpointsForProvider(providerId: string) {
if (!provider || provider.api_formats.length === 0) return [] if (!provider || provider.api_formats.length === 0) return []
return provider.api_formats.map((format, index) => { return provider.api_formats.map((format, index) => {
const normalizedFormat = normalizeApiFormat(format)
const healthDetail = provider.endpoint_health_details.find(h => h.api_format === format) const healthDetail = provider.endpoint_health_details.find(h => h.api_format === format)
const baseUrl = normalizedFormat.includes('claude') ? 'https://api.anthropic.com' :
normalizedFormat.includes('openai') ? 'https://api.openai.com' :
'https://generativelanguage.googleapis.com'
return { return {
id: `ep-${providerId}-${index + 1}`, id: `ep-${providerId}-${index + 1}`,
provider_id: providerId, provider_id: providerId,
provider_name: provider.name, provider_name: provider.name,
api_format: format, api_format: format,
base_url: format.includes('claude') ? 'https://api.anthropic.com' : base_url: baseUrl,
format.includes('openai') ? 'https://api.openai.com' :
'https://generativelanguage.googleapis.com',
max_retries: 2, max_retries: 2,
is_active: healthDetail?.is_active ?? true, is_active: healthDetail?.is_active ?? true,
total_keys: Math.ceil(Math.random() * 3) + 1, total_keys: Math.ceil(Math.random() * 3) + 1,
active_keys: Math.ceil(Math.random() * 2) + 1, active_keys: Math.ceil(Math.random() * 2) + 1,
created_at: provider.created_at, created_at: provider.created_at,
updated_at: new Date().toISOString() updated_at: new Date().toISOString(),
...getMockEndpointExtras(normalizedFormat)
} }
}) })
} }
@@ -1236,28 +1281,44 @@ const PROVIDER_KEYS_CACHE: Record<string, any[]> = {}
function generateMockKeysForProvider(providerId: string, count: number = 2) { function generateMockKeysForProvider(providerId: string, count: number = 2) {
const provider = MOCK_PROVIDERS.find(p => p.id === providerId) const provider = MOCK_PROVIDERS.find(p => p.id === providerId)
const formats = provider?.api_formats || [] const formats = provider?.api_formats || []
const nowSec = Math.floor(Date.now() / 1000)
return Array.from({ length: count }, (_, i) => ({ return Array.from({ length: count }, (_, i) => {
const isOAuth = i === 1
const markInvalid = isOAuth && providerId.endsWith('3')
const oauthFields = isOAuth ? {
auth_type: 'oauth',
oauth_email: 'oauth-demo@aether.dev',
oauth_expires_at: markInvalid ? null : nowSec + 6 * 3600,
oauth_invalid_at: markInvalid ? nowSec - 3600 : null,
oauth_invalid_reason: markInvalid ? '[ACCOUNT_BLOCK] Demo verification required' : null,
oauth_plan_type: 'pro',
oauth_account_id: `acct-${providerId}`
} : { auth_type: 'api_key' }
return {
id: `key-${providerId}-${i + 1}`, id: `key-${providerId}-${i + 1}`,
provider_id: providerId, provider_id: providerId,
api_formats: i === 0 ? formats : formats.slice(0, 1), api_formats: i === 0 ? formats : formats.slice(0, 1),
api_key_masked: `sk-***...${Math.random().toString(36).substring(2, 6)}`, api_key_masked: `sk-***...${Math.random().toString(36).substring(2, 6)}`,
name: i === 0 ? 'Primary Key' : `Backup Key ${i}`, name: i === 0 ? 'Primary Key' : `Backup Key ${i}`,
...oauthFields,
rate_multiplier: 1.0, rate_multiplier: 1.0,
internal_priority: i + 1, internal_priority: i + 1,
health_score: 0.90 + Math.random() * 0.10, // 0.90-1.00 health_score: 0.90 + Math.random() * 0.10,
consecutive_failures: Math.random() > 0.8 ? 1 : 0, consecutive_failures: Math.random() > 0.8 ? 1 : 0,
request_count: 1000 + Math.floor(Math.random() * 5000), request_count: 1000 + Math.floor(Math.random() * 5000),
success_count: 950 + Math.floor(Math.random() * 4800), success_count: 950 + Math.floor(Math.random() * 4800),
error_count: Math.floor(Math.random() * 100), error_count: Math.floor(Math.random() * 100),
success_rate: 0.95 + Math.random() * 0.04, // 0.95-0.99 success_rate: 0.95 + Math.random() * 0.04,
avg_response_time_ms: 800 + Math.floor(Math.random() * 600), avg_response_time_ms: 800 + Math.floor(Math.random() * 600),
cache_ttl_minutes: 5, cache_ttl_minutes: 5,
max_probe_interval_minutes: 32, max_probe_interval_minutes: 32,
is_active: true, is_active: true,
created_at: '2024-01-01T00:00:00Z', created_at: '2024-01-01T00:00:00Z',
updated_at: new Date().toISOString() updated_at: new Date().toISOString()
})) }
})
} }
// 为 provider 生成 models // 为 provider 生成 models
@@ -1492,6 +1553,7 @@ registerDynamicRoute('POST', '/api/admin/endpoints/providers/:providerId/keys',
api_formats: body.api_formats || [], api_formats: body.api_formats || [],
api_key_masked: masked, api_key_masked: masked,
api_key_plain: null, api_key_plain: null,
auth_type: body.auth_type || 'api_key',
name: body.name || 'New Key', name: body.name || 'New Key',
note: body.note, note: body.note,
rate_multiplier: body.rate_multiplier ?? 1.0, rate_multiplier: body.rate_multiplier ?? 1.0,
@@ -1522,6 +1584,102 @@ registerDynamicRoute('POST', '/api/admin/endpoints/providers/:providerId/keys',
return createMockResponse(newKey) return createMockResponse(newKey)
}) })
registerDynamicRoute('POST', '/api/admin/endpoints/providers/:providerId/refresh-quota', async (_config, params) => {
await delay()
requireAdmin()
if (!PROVIDER_KEYS_CACHE[params.providerId]) {
PROVIDER_KEYS_CACHE[params.providerId] = generateMockKeysForProvider(params.providerId, 2)
}
const keys = PROVIDER_KEYS_CACHE[params.providerId] || []
const results = keys.map(key => ({
key_id: key.id,
key_name: key.name || key.id.slice(0, 8),
status: 'success',
metadata: { updated_at: new Date().toISOString() }
}))
return createMockResponse({
success: results.length,
failed: 0,
total: results.length,
results
})
})
registerDynamicRoute('POST', '/api/admin/provider-oauth/keys/:keyId/refresh', async (_config, params) => {
await delay()
requireAdmin()
return createMockResponse({
provider_type: 'codex',
expires_at: Math.floor(Date.now() / 1000) + 6 * 3600,
has_refresh_token: true,
email: 'oauth-demo@aether.dev',
key_id: params.keyId
})
})
registerDynamicRoute('POST', '/api/admin/provider-oauth/providers/:providerId/start', async (_config, params) => {
await delay()
requireAdmin()
return createMockResponse({
authorization_url: `https://example.com/oauth/authorize?provider=${params.providerId}`,
redirect_uri: 'https://aether.local/oauth/callback',
provider_type: 'codex',
instructions: 'Open the authorization URL and paste the callback URL here.'
})
})
registerDynamicRoute('POST', '/api/admin/provider-oauth/providers/:providerId/complete', async (config, _params) => {
await delay()
requireAdmin()
const body = JSON.parse(config.data || '{}')
return createMockResponse({
key_id: `key-oauth-${Date.now()}`,
provider_type: 'codex',
expires_at: Math.floor(Date.now() / 1000) + 24 * 3600,
has_refresh_token: true,
email: body.name ? `${body.name}@demo.dev` : 'oauth-demo@aether.dev'
})
})
registerDynamicRoute('POST', '/api/admin/provider-oauth/providers/:providerId/import-refresh-token', async (config, _params) => {
await delay()
requireAdmin()
const body = JSON.parse(config.data || '{}')
return createMockResponse({
key_id: `key-oauth-${Date.now()}`,
provider_type: 'codex',
expires_at: Math.floor(Date.now() / 1000) + 24 * 3600,
has_refresh_token: true,
email: body.name ? `${body.name}@demo.dev` : 'oauth-demo@aether.dev'
})
})
registerDynamicRoute('POST', '/api/admin/provider-oauth/providers/:providerId/batch-import', async (config, _params) => {
await delay()
requireAdmin()
const body = JSON.parse(config.data || '{}')
const raw = typeof body.credentials === 'string' ? body.credentials.trim() : ''
const lines = raw ? raw.split('\n').filter(line => line.trim() && !line.trim().startsWith('#')) : []
const total = Math.max(Math.min(lines.length, 5), 2)
const results = []
for (let index = 0; index < total; index++) {
results.push({
index,
status: 'success',
key_id: `key-oauth-${Date.now()}-${index}`,
key_name: `Imported OAuth ${index + 1}`,
auth_method: 'oauth'
})
}
return createMockResponse({
total,
success: results.length,
failed: 0,
results
})
})
// Key 更新 // Key 更新
registerDynamicRoute('PUT', '/api/admin/endpoints/keys/:keyId', async (config, params) => { registerDynamicRoute('PUT', '/api/admin/endpoints/keys/:keyId', async (config, params) => {
await delay() await delay()
@@ -1544,6 +1702,26 @@ registerDynamicRoute('GET', '/api/admin/endpoints/keys/:keyId/reveal', async (_c
return createMockResponse({ api_key: 'sk-demo-reveal' }) return createMockResponse({ api_key: 'sk-demo-reveal' })
}) })
registerDynamicRoute('GET', '/api/admin/endpoints/keys/:keyId/export', async (_config, params) => {
await delay()
requireAdmin()
return createMockResponse({
key_id: params.keyId,
provider_type: 'codex',
auth_method: 'oauth',
refresh_token: 'rt-demo',
email: 'oauth-demo@aether.dev',
exported_at: new Date().toISOString()
})
})
registerDynamicRoute('POST', '/api/admin/endpoints/keys/:keyId/clear-oauth-invalid', async (_config, params) => {
await delay()
requireAdmin()
return createMockResponse({ message: 'OAuth invalid cleared (demo)', key_id: params.keyId })
})
// Keys grouped by format // Keys grouped by format
mockHandlers['GET /api/admin/endpoints/keys/grouped-by-format'] = async () => { mockHandlers['GET /api/admin/endpoints/keys/grouped-by-format'] = async () => {
await delay() await delay()
@@ -1765,6 +1943,19 @@ registerDynamicRoute('GET', '/api/admin/endpoints/health/key/:keyId', async (_co
}) })
}) })
registerDynamicRoute('PATCH', '/api/admin/endpoints/health/keys', async () => {
await delay()
requireAdmin()
return createMockResponse({
message: 'All key health recovered (demo)',
recovered_count: 2,
recovered_keys: [
{ key_id: 'key-demo-1', key_name: 'Primary Key', endpoint_id: 'ep-demo-1' },
{ key_id: 'key-demo-2', key_name: 'Backup Key', endpoint_id: 'ep-demo-2' }
]
})
})
// 重置 Key Health // 重置 Key Health
registerDynamicRoute('PATCH', '/api/admin/endpoints/health/keys/:keyId', async (_config, params) => { registerDynamicRoute('PATCH', '/api/admin/endpoints/health/keys/:keyId', async (_config, params) => {
await delay() await delay()

View File

@@ -202,7 +202,9 @@ async def clear_oauth_invalid(
key.is_active = True key.is_active = True
db.commit() db.commit()
logger.info("[OK] 手动清除 Key {}... 的 OAuth 失效标记并自动启用 (原因: {})", key_id[:8], old_reason) logger.info(
"[OK] 手动清除 Key {}... 的 OAuth 失效标记并自动启用 (原因: {})", key_id[:8], old_reason
)
return {"message": "已清除 OAuth 失效标记Key 已自动启用"} return {"message": "已清除 OAuth 失效标记Key 已自动启用"}
@@ -1256,7 +1258,6 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
import httpx import httpx
from src.api.handlers.base.request_builder import get_provider_auth from src.api.handlers.base.request_builder import get_provider_auth
from src.utils.ssl_utils import get_ssl_context
db = context.db db = context.db
provider = db.query(Provider).filter(Provider.id == self.provider_id).first() provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
@@ -1355,8 +1356,21 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
if oauth_account_id and oauth_plan_type and oauth_plan_type.lower() != "free": if oauth_account_id and oauth_plan_type and oauth_plan_type.lower() != "free":
headers["chatgpt-account-id"] = oauth_account_id headers["chatgpt-account-id"] = oauth_account_id
# 解析代理配置key 级别 > provider 级别 > 系统默认)
from src.services.proxy_node.resolver import (
build_proxy_client_kwargs,
resolve_effective_proxy,
)
effective_proxy = resolve_effective_proxy(
getattr(provider, "proxy", None),
getattr(key, "proxy", None),
)
# 使用 wham/usage API 获取限额信息 # 使用 wham/usage API 获取限额信息
async with httpx.AsyncClient(timeout=30.0, verify=get_ssl_context()) as client: async with httpx.AsyncClient(
**build_proxy_client_kwargs(effective_proxy, timeout=30.0)
) as client:
response = await client.get(CODEX_WHAM_USAGE_URL, headers=headers) response = await client.get(CODEX_WHAM_USAGE_URL, headers=headers)
if response.status_code != 200: if response.status_code != 200:
@@ -1421,13 +1435,19 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
from src.services.provider.adapters.antigravity.client import ( from src.services.provider.adapters.antigravity.client import (
AntigravityAccountForbiddenException, AntigravityAccountForbiddenException,
) )
from src.services.proxy_node.resolver import resolve_effective_proxy
effective_proxy = resolve_effective_proxy(
getattr(provider, "proxy", None),
getattr(key, "proxy", None),
)
fetch_ctx = UpstreamModelsFetchContext( fetch_ctx = UpstreamModelsFetchContext(
provider_type="antigravity", provider_type="antigravity",
api_key_value=access_token, api_key_value=access_token,
# antigravity fetcher 不依赖 endpoint mapping # antigravity fetcher 不依赖 endpoint mapping
format_to_endpoint={}, format_to_endpoint={},
proxy_config=getattr(provider, "proxy", None), proxy_config=effective_proxy,
auth_config=auth_info.decrypted_auth_config, auth_config=auth_info.decrypted_auth_config,
) )
@@ -1530,8 +1550,13 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
"message": "无法解密 auth_config可能是加密密钥已更改", "message": "无法解密 auth_config可能是加密密钥已更改",
} }
# 获取代理配置 # 获取代理配置key 级别 > provider 级别)
proxy_config = getattr(provider, "proxy", None) from src.services.proxy_node.resolver import resolve_effective_proxy
proxy_config = resolve_effective_proxy(
getattr(provider, "proxy", None),
getattr(key, "proxy", None),
)
# 调用 Kiro getUsageLimits API # 调用 Kiro getUsageLimits API
try: try:
@@ -1578,7 +1603,9 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
key.oauth_invalid_reason = "Kiro Token 无效或已过期" key.oauth_invalid_reason = "Kiro Token 无效或已过期"
key.is_active = False key.is_active = False
db.commit() db.commit()
logger.warning("[KIRO_QUOTA] Key {} Token 无效,已标记为异常并自动停用", key.id) logger.warning(
"[KIRO_QUOTA] Key {} Token 无效,已标记为异常并自动停用", key.id
)
return { return {
"key_id": key.id, "key_id": key.id,
"key_name": key.name, "key_name": key.name,

View File

@@ -36,8 +36,8 @@ from src.services.model.upstream_fetcher import (
get_adapter_for_format, get_adapter_for_format,
) )
from src.services.provider.oauth_token import resolve_oauth_access_token from src.services.provider.oauth_token import resolve_oauth_access_token
from src.services.proxy_node.resolver import resolve_effective_proxy, resolve_proxy_param
from src.utils.auth_utils import get_current_user from src.utils.auth_utils import get_current_user
from src.utils.ssl_utils import get_ssl_context
router = APIRouter(prefix="/api/admin/provider-query", tags=["Provider Query"]) router = APIRouter(prefix="/api/admin/provider-query", tags=["Provider Query"])
@@ -110,9 +110,15 @@ class _KeyAuthError(Exception):
async def _resolve_key_auth( async def _resolve_key_auth(
api_key: Any, api_key: Any,
provider: Any, provider: Any,
provider_proxy_config: dict[str, Any] | None = None,
) -> tuple[str, dict[str, Any] | None]: ) -> tuple[str, dict[str, Any] | None]:
"""统一解析 Key 的 api_key_value 和 auth_config。 """统一解析 Key 的 api_key_value 和 auth_config。
Args:
api_key: ProviderAPIKey 对象
provider: Provider 对象
provider_proxy_config: 已解析的有效代理配置key > provider 级别)
Returns: Returns:
(api_key_value, auth_config) (api_key_value, auth_config)
@@ -135,7 +141,7 @@ async def _resolve_key_auth(
if getattr(api_key, "auth_config", None) is not None if getattr(api_key, "auth_config", None) is not None
else None else None
), ),
provider_proxy_config=getattr(provider, "proxy", None), provider_proxy_config=provider_proxy_config,
endpoint_api_format=endpoint_api_format, endpoint_api_format=endpoint_api_format,
) )
api_key_value = resolved.access_token api_key_value = resolved.access_token
@@ -267,7 +273,12 @@ async def query_available_models(
# 缓存未命中或强制刷新,实时获取 # 缓存未命中或强制刷新,实时获取
try: try:
api_key_value, auth_config = await _resolve_key_auth(api_key, provider) effective_proxy = resolve_effective_proxy(
getattr(provider, "proxy", None), getattr(api_key, "proxy", None)
)
api_key_value, auth_config = await _resolve_key_auth(
api_key, provider, provider_proxy_config=effective_proxy
)
except _KeyAuthError as e: except _KeyAuthError as e:
return [], f"Key {api_key.name or api_key.id}: {e.message}", False return [], f"Key {api_key.name or api_key.id}: {e.message}", False
@@ -275,7 +286,7 @@ async def query_available_models(
provider_type=str(getattr(provider, "provider_type", "") or ""), provider_type=str(getattr(provider, "provider_type", "") or ""),
api_key_value=str(api_key_value or ""), api_key_value=str(api_key_value or ""),
format_to_endpoint=format_to_endpoint, format_to_endpoint=format_to_endpoint,
proxy_config=getattr(provider, "proxy", None), proxy_config=effective_proxy,
auth_config=auth_config, auth_config=auth_config,
) )
models, errors, has_success, _meta = await fetch_models_for_key( models, errors, has_success, _meta = await fetch_models_for_key(
@@ -433,7 +444,12 @@ async def _fetch_models_antigravity_ordered(
# 实时获取 # 实时获取
try: try:
api_key_value, auth_config = await _resolve_key_auth(api_key, provider) effective_proxy = resolve_effective_proxy(
getattr(provider, "proxy", None), getattr(api_key, "proxy", None)
)
api_key_value, auth_config = await _resolve_key_auth(
api_key, provider, provider_proxy_config=effective_proxy
)
except _KeyAuthError as e: except _KeyAuthError as e:
all_errors.append(f"Key {key_label}: {e.message}") all_errors.append(f"Key {key_label}: {e.message}")
continue continue
@@ -442,7 +458,7 @@ async def _fetch_models_antigravity_ordered(
provider_type=str(getattr(provider, "provider_type", "") or ""), provider_type=str(getattr(provider, "provider_type", "") or ""),
api_key_value=str(api_key_value or ""), api_key_value=str(api_key_value or ""),
format_to_endpoint=format_to_endpoint, format_to_endpoint=format_to_endpoint,
proxy_config=getattr(provider, "proxy", None), proxy_config=effective_proxy,
auth_config=auth_config, auth_config=auth_config,
) )
models, errors, has_success, _meta = await fetch_models_for_key( models, errors, has_success, _meta = await fetch_models_for_key(
@@ -528,7 +544,12 @@ async def _fetch_models_for_single_key(
# 缓存未命中或强制刷新,实时获取 # 缓存未命中或强制刷新,实时获取
try: try:
api_key_value, auth_config = await _resolve_key_auth(api_key, provider) effective_proxy = resolve_effective_proxy(
getattr(provider, "proxy", None), getattr(api_key, "proxy", None)
)
api_key_value, auth_config = await _resolve_key_auth(
api_key, provider, provider_proxy_config=effective_proxy
)
except _KeyAuthError as e: except _KeyAuthError as e:
raise HTTPException(status_code=500, detail=e.message) raise HTTPException(status_code=500, detail=e.message)
@@ -536,7 +557,7 @@ async def _fetch_models_for_single_key(
provider_type=str(getattr(provider, "provider_type", "") or ""), provider_type=str(getattr(provider, "provider_type", "") or ""),
api_key_value=str(api_key_value or ""), api_key_value=str(api_key_value or ""),
format_to_endpoint=format_to_endpoint, format_to_endpoint=format_to_endpoint,
proxy_config=getattr(provider, "proxy", None), proxy_config=effective_proxy,
auth_config=auth_config, auth_config=auth_config,
) )
all_models, errors, has_success, _meta = await fetch_models_for_key( all_models, errors, has_success, _meta = await fetch_models_for_key(
@@ -703,7 +724,9 @@ async def test_model(
encrypted_auth_config=( encrypted_auth_config=(
str(api_key.auth_config) if getattr(api_key, "auth_config", None) else None str(api_key.auth_config) if getattr(api_key, "auth_config", None) else None
), ),
provider_proxy_config=getattr(provider, "proxy", None), provider_proxy_config=resolve_effective_proxy(
getattr(provider, "proxy", None), getattr(api_key, "proxy", None)
),
endpoint_api_format=str(getattr(endpoint, "api_format", "") or ""), endpoint_api_format=str(getattr(endpoint, "api_format", "") or ""),
) )
api_key_value = resolved.access_token api_key_value = resolved.access_token
@@ -780,10 +803,12 @@ async def test_model(
if header_rules: if header_rules:
logger.debug(f"[test-model] 将传递 header_rules 给 check_endpoint: {header_rules}") logger.debug(f"[test-model] 将传递 header_rules 给 check_endpoint: {header_rules}")
# 发送测试请求 # 发送测试请求(使用代理配置)
async with httpx.AsyncClient( test_proxy = resolve_effective_proxy(
timeout=endpoint_config["timeout"], verify=get_ssl_context() getattr(provider, "proxy", None), getattr(api_key, "proxy", None)
) as client: )
test_proxy_param = resolve_proxy_param(test_proxy)
logger.debug("[test-model] 开始端点测试...") logger.debug("[test-model] 开始端点测试...")
# Provider 上下文auth_type 用于 OAuth 认证头处理provider_type 用于特殊路由 # Provider 上下文auth_type 用于 OAuth 认证头处理provider_type 用于特殊路由
@@ -791,7 +816,7 @@ async def test_model(
async def _do_check(req: dict) -> dict: async def _do_check(req: dict) -> dict:
return await adapter_class.check_endpoint( return await adapter_class.check_endpoint(
client, None, # client 参数已不被 run_endpoint_check 使用
endpoint_config["base_url"], endpoint_config["base_url"],
endpoint_config["api_key"], endpoint_config["api_key"],
req, req,
@@ -807,6 +832,7 @@ async def test_model(
auth_type=auth_type, auth_type=auth_type,
provider_type=p_type if p_type else None, provider_type=p_type if p_type else None,
decrypted_auth_config=oauth_meta if oauth_meta else None, decrypted_auth_config=oauth_meta if oauth_meta else None,
proxy_param=test_proxy_param,
) )
def _response_has_error(resp: dict) -> bool: def _response_has_error(resp: dict) -> bool:
@@ -906,9 +932,7 @@ async def test_model(
oauth_email, oauth_email,
) )
else: else:
logger.warning( logger.warning("[test-model] Key {} 因 403 verify 已标记为异常", api_key.id)
"[test-model] Key {} 因 403 verify 已标记为异常", api_key.id
)
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,

View File

@@ -1475,6 +1475,7 @@ async def _resolve_provider_auth(
if auth_type == "oauth": if auth_type == "oauth":
from src.services.provider.oauth_token import resolve_oauth_access_token from src.services.provider.oauth_token import resolve_oauth_access_token
from src.services.proxy_node.resolver import resolve_effective_proxy
# 获取 Provider 对象以读取 proxy 和 provider_type # 获取 Provider 对象以读取 proxy 和 provider_type
provider_obj = db.query(Provider).filter(Provider.id == provider_key.provider_id).first() provider_obj = db.query(Provider).filter(Provider.id == provider_key.provider_id).first()
@@ -1495,7 +1496,14 @@ async def _resolve_provider_auth(
if getattr(provider_key, "auth_config", None) is not None if getattr(provider_key, "auth_config", None) is not None
else None else None
), ),
provider_proxy_config=getattr(provider_obj, "proxy", None) if provider_obj else None, provider_proxy_config=(
resolve_effective_proxy(
getattr(provider_obj, "proxy", None),
getattr(provider_key, "proxy", None),
)
if provider_obj
else None
),
endpoint_api_format=ep_format, endpoint_api_format=ep_format,
) )
access_token = resolved.access_token or "" access_token = resolved.access_token or ""
@@ -1784,12 +1792,25 @@ class AdminUsageReplayAdapter(AdminApiAdapter):
# 发送请求 # 发送请求
try: try:
from src.utils.ssl_utils import get_ssl_context from src.services.proxy_node.resolver import (
build_proxy_client_kwargs,
resolve_effective_proxy,
)
# 解析代理key > provider > 系统默认)
replay_provider = (
db.query(Provider).filter(Provider.id == endpoint.provider_id).first()
if endpoint
else None
)
eff_proxy = resolve_effective_proxy(
getattr(replay_provider, "proxy", None) if replay_provider else None,
getattr(provider_key, "proxy", None) if provider_key else None,
)
start_time = time.monotonic() start_time = time.monotonic()
async with httpx.AsyncClient( async with httpx.AsyncClient(
timeout=60.0, **build_proxy_client_kwargs(eff_proxy, timeout=60.0)
verify=get_ssl_context(),
) as client: ) as client:
response = await client.post( response = await client.post(
url, url,

View File

@@ -638,6 +638,8 @@ class ChatAdapterBase(ApiAdapter):
auth_type: str | None = None, # noqa: ARG003 auth_type: str | None = None, # noqa: ARG003
provider_type: str | None = None, # noqa: ARG003 provider_type: str | None = None, # noqa: ARG003
decrypted_auth_config: dict[str, Any] | None = None, # noqa: ARG003 decrypted_auth_config: dict[str, Any] | None = None, # noqa: ARG003
# 代理参数(已解析,直接传递给 run_endpoint_check
proxy_param: Any | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
测试模型连接性(非流式) 测试模型连接性(非流式)
@@ -700,6 +702,7 @@ class ChatAdapterBase(ApiAdapter):
provider_id=provider_id, provider_id=provider_id,
api_key_id=api_key_id, api_key_id=api_key_id,
model_name=model_name or request_data.get("model"), model_name=model_name or request_data.get("model"),
proxy_param=proxy_param,
) )

View File

@@ -1130,27 +1130,18 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
return _streamified() return _streamified()
# 配置 HTTP 超时
# 注意read timeout 用于检测连接断开,不是整体请求超时
# 整体请求超时由 asyncio.wait_for 控制,使用全局配置
timeout_config = httpx.Timeout(
connect=config.http_connect_timeout,
read=config.http_read_timeout, # 使用全局配置,用于检测连接断开
write=config.http_write_timeout,
pool=config.http_pool_timeout,
)
# 流式请求使用 stream_first_byte_timeout 作为首字节超时 # 流式请求使用 stream_first_byte_timeout 作为首字节超时
# 优先使用 Provider 配置,否则使用全局配置 # 优先使用 Provider 配置,否则使用全局配置
request_timeout = provider.stream_first_byte_timeout or config.stream_first_byte_timeout request_timeout = provider.stream_first_byte_timeout or config.stream_first_byte_timeout
# 创建 HTTP 客户端支持代理配置Key 级别优先于 Provider 级别) # 获取 HTTP 客户端支持代理配置Key 级别优先于 Provider 级别)
# 使用连接池复用客户端,避免每次流式请求都新建 TCP/TLS 连接
from src.clients.http_client import HTTPClientPool from src.clients.http_client import HTTPClientPool
from src.services.proxy_node.resolver import build_stream_kwargs, resolve_delegate_config from src.services.proxy_node.resolver import build_stream_kwargs, resolve_delegate_config
delegate_cfg = resolve_delegate_config(effective_proxy) delegate_cfg = resolve_delegate_config(effective_proxy)
http_client = HTTPClientPool.create_upstream_stream_client( http_client = await HTTPClientPool.get_upstream_client(
delegate_cfg, proxy_config=effective_proxy, timeout=timeout_config delegate_cfg, proxy_config=effective_proxy
) )
# 用于存储内部函数的结果(必须在函数定义前声明,供 nonlocal 使用) # 用于存储内部函数的结果(必须在函数定义前声明,供 nonlocal 使用)
@@ -1214,13 +1205,12 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
break break
except ClientDisconnectedException: except ClientDisconnectedException:
# 客户端断开连接,清理资源 # 客户端断开连接,清理响应上下文(不关闭池中复用的客户端)
if response_ctx is not None: if response_ctx is not None:
try: try:
await response_ctx.__aexit__(None, None, None) await response_ctx.__aexit__(None, None, None)
except Exception: except Exception:
pass pass
await http_client.aclose()
logger.warning(f" [{self.request_id}] 客户端在等待首字节时断开连接") logger.warning(f" [{self.request_id}] 客户端在等待首字节时断开连接")
ctx.status_code = 499 ctx.status_code = 499
ctx.error_message = "client_disconnected_during_prefetch" ctx.error_message = "client_disconnected_during_prefetch"
@@ -1228,13 +1218,12 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
except TimeoutError: except TimeoutError:
# 整体请求超时(建立连接 + 获取首字节) # 整体请求超时(建立连接 + 获取首字节)
# 清理可能已建立的连接上下文 # 清理可能已建立的连接上下文(不关闭池中复用的客户端)
if response_ctx is not None: if response_ctx is not None:
try: try:
await response_ctx.__aexit__(None, None, None) await response_ctx.__aexit__(None, None, None)
except Exception: except Exception:
pass pass
await http_client.aclose()
logger.warning( logger.warning(
f" [{self.request_id}] 请求超时: Provider={provider.name}, timeout={request_timeout}s" f" [{self.request_id}] 请求超时: Provider={provider.name}, timeout={request_timeout}s"
) )
@@ -1244,13 +1233,12 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
) )
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e: except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
# 连接/读写超时:清理可能已建立的连接上下文 # 连接/读写超时:清理可能已建立的连接上下文(不关闭池中复用的客户端)
if response_ctx is not None: if response_ctx is not None:
try: try:
await response_ctx.__aexit__(None, None, None) await response_ctx.__aexit__(None, None, None)
except Exception: except Exception:
pass pass
await http_client.aclose()
if envelope: if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e) envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
if ctx.selected_base_url: if ctx.selected_base_url:
@@ -1289,7 +1277,6 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
logger.error( logger.error(
f"Provider 返回错误: {e.response.status_code}\n Response: {error_text}" f"Provider 返回错误: {e.response.status_code}\n Response: {error_text}"
) )
await http_client.aclose()
# 将上游错误信息附加到异常,以便故障转移时能够返回给客户端 # 将上游错误信息附加到异常,以便故障转移时能够返回给客户端
e.upstream_response = error_text # type: ignore[attr-defined] e.upstream_response = error_text # type: ignore[attr-defined]
raise raise
@@ -1300,11 +1287,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
await response_ctx.__aexit__(None, None, None) await response_ctx.__aexit__(None, None, None)
except Exception: except Exception:
pass pass
await http_client.aclose()
raise raise
except Exception: except Exception:
await http_client.aclose()
raise raise
# 类型断言:成功执行后这些变量不会为 None # 类型断言:成功执行后这些变量不会为 None
@@ -1317,7 +1302,6 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
ctx, ctx,
byte_iterator, byte_iterator,
response_ctx, response_ctx,
http_client,
prefetched_chunks, prefetched_chunks,
start_time=self.start_time, start_time=self.start_time,
) )

View File

@@ -604,6 +604,8 @@ class CliAdapterBase(ApiAdapter):
auth_type: str | None = None, auth_type: str | None = None,
provider_type: str | None = None, provider_type: str | None = None,
decrypted_auth_config: dict[str, Any] | None = None, decrypted_auth_config: dict[str, Any] | None = None,
# 代理参数(已解析,直接传递给 run_endpoint_check
proxy_param: Any | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
测试模型连接性(非流式) 测试模型连接性(非流式)
@@ -779,6 +781,7 @@ class CliAdapterBase(ApiAdapter):
provider_id=provider_id, provider_id=provider_id,
api_key_id=api_key_id, api_key_id=api_key_id,
model_name=effective_model_name, model_name=effective_model_name,
proxy_param=proxy_param,
) )
# ========================================================================= # =========================================================================

View File

@@ -1092,16 +1092,6 @@ class CliMessageHandlerBase(BaseMessageHandler):
return _streamified() return _streamified()
# 配置 HTTP 超时
# 注意read timeout 用于检测连接断开,不是整体请求超时
# 整体请求超时由 _connect_and_prefetch 内部的 asyncio.wait_for 控制
timeout_config = httpx.Timeout(
connect=config.http_connect_timeout,
read=config.http_read_timeout, # 使用全局配置,用于检测连接断开
write=config.http_write_timeout,
pool=config.http_pool_timeout,
)
# 流式请求使用 stream_first_byte_timeout 作为首字节超时 # 流式请求使用 stream_first_byte_timeout 作为首字节超时
# 优先使用 Provider 配置,否则使用全局配置 # 优先使用 Provider 配置,否则使用全局配置
request_timeout = provider.stream_first_byte_timeout or config.stream_first_byte_timeout request_timeout = provider.stream_first_byte_timeout or config.stream_first_byte_timeout
@@ -1116,13 +1106,14 @@ class CliMessageHandlerBase(BaseMessageHandler):
f"timeout={request_timeout}s, 代理={_proxy_label}" f"timeout={request_timeout}s, 代理={_proxy_label}"
) )
# 创建 HTTP 客户端支持代理配置Key 级别优先于 Provider 级别) # 获取 HTTP 客户端支持代理配置Key 级别优先于 Provider 级别)
# 使用连接池复用客户端,避免每次流式请求都新建 TCP/TLS 连接
from src.clients.http_client import HTTPClientPool from src.clients.http_client import HTTPClientPool
from src.services.proxy_node.resolver import build_stream_kwargs, resolve_delegate_config from src.services.proxy_node.resolver import build_stream_kwargs, resolve_delegate_config
delegate_cfg = resolve_delegate_config(effective_proxy) delegate_cfg = resolve_delegate_config(effective_proxy)
http_client = HTTPClientPool.create_upstream_stream_client( http_client = await HTTPClientPool.get_upstream_client(
delegate_cfg, proxy_config=effective_proxy, timeout=timeout_config delegate_cfg, proxy_config=effective_proxy
) )
# 用于存储内部函数的结果(必须在函数定义前声明,供 nonlocal 使用) # 用于存储内部函数的结果(必须在函数定义前声明,供 nonlocal 使用)
@@ -1188,7 +1179,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
except TimeoutError as e: except TimeoutError as e:
# 整体请求超时(建立连接 + 获取首字节) # 整体请求超时(建立连接 + 获取首字节)
# 清理可能已建立的连接上下文 # 清理可能已建立的连接上下文(不关闭池中复用的客户端)
if response_ctx is not None: if response_ctx is not None:
try: try:
await response_ctx.__aexit__(None, None, None) await response_ctx.__aexit__(None, None, None)
@@ -1196,7 +1187,6 @@ class CliMessageHandlerBase(BaseMessageHandler):
pass pass
if envelope: if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e) envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
await http_client.aclose()
logger.warning( logger.warning(
f" [{self.request_id}] 请求超时: Provider={provider.name}, timeout={request_timeout}s" f" [{self.request_id}] 请求超时: Provider={provider.name}, timeout={request_timeout}s"
) )
@@ -1206,13 +1196,12 @@ class CliMessageHandlerBase(BaseMessageHandler):
) )
except ClientDisconnectedException: except ClientDisconnectedException:
# 客户端断开连接,清理资源 # 客户端断开连接,清理响应上下文(不关闭池中复用的客户端)
if response_ctx is not None: if response_ctx is not None:
try: try:
await response_ctx.__aexit__(None, None, None) await response_ctx.__aexit__(None, None, None)
except Exception: except Exception:
pass pass
await http_client.aclose()
logger.warning(f" [{self.request_id}] 客户端在等待首字节时断开连接") logger.warning(f" [{self.request_id}] 客户端在等待首字节时断开连接")
ctx.status_code = 499 ctx.status_code = 499
ctx.error_message = "client_disconnected_during_prefetch" ctx.error_message = "client_disconnected_during_prefetch"
@@ -1225,7 +1214,6 @@ class CliMessageHandlerBase(BaseMessageHandler):
logger.warning( logger.warning(
f"[{envelope.name}] Connection error: {ctx.selected_base_url} ({e})" f"[{envelope.name}] Connection error: {ctx.selected_base_url} ({e})"
) )
await http_client.aclose()
raise raise
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
@@ -1258,23 +1246,20 @@ class CliMessageHandlerBase(BaseMessageHandler):
logger.error( logger.error(
f"Provider 返回错误状态: {e.response.status_code}\n Response: {error_text}" f"Provider 返回错误状态: {e.response.status_code}\n Response: {error_text}"
) )
await http_client.aclose()
# 将上游错误信息附加到异常,以便故障转移时能够返回给客户端 # 将上游错误信息附加到异常,以便故障转移时能够返回给客户端
e.upstream_response = error_text # type: ignore[attr-defined] e.upstream_response = error_text # type: ignore[attr-defined]
raise raise
except EmbeddedErrorException: except EmbeddedErrorException:
# 嵌套错误需要触发重试,关闭连接后重新抛出 # 嵌套错误需要触发重试,关闭连接上下文后重新抛出
try: try:
if response_ctx is not None: if response_ctx is not None:
await response_ctx.__aexit__(None, None, None) await response_ctx.__aexit__(None, None, None)
except Exception: except Exception:
pass pass
await http_client.aclose()
raise raise
except Exception: except Exception:
await http_client.aclose()
raise raise
# 类型断言:成功执行后这些变量不会为 None # 类型断言:成功执行后这些变量不会为 None
@@ -1287,7 +1272,6 @@ class CliMessageHandlerBase(BaseMessageHandler):
ctx, ctx,
byte_iterator, byte_iterator,
response_ctx, response_ctx,
http_client,
prefetched_chunks, prefetched_chunks,
) )
@@ -1296,7 +1280,6 @@ class CliMessageHandlerBase(BaseMessageHandler):
ctx: StreamContext, ctx: StreamContext,
stream_response: httpx.Response, stream_response: httpx.Response,
response_ctx: Any, response_ctx: Any,
http_client: httpx.AsyncClient,
) -> AsyncGenerator[bytes]: ) -> AsyncGenerator[bytes]:
"""创建响应流生成器(使用字节流)""" """创建响应流生成器(使用字节流)"""
try: try:
@@ -1512,10 +1495,6 @@ class CliMessageHandlerBase(BaseMessageHandler):
await response_ctx.__aexit__(None, None, None) await response_ctx.__aexit__(None, None, None)
except Exception: except Exception:
pass pass
try:
await http_client.aclose()
except Exception:
pass
def _flush_remaining_sse_data( def _flush_remaining_sse_data(
self, self,
@@ -1813,7 +1792,6 @@ class CliMessageHandlerBase(BaseMessageHandler):
ctx: StreamContext, ctx: StreamContext,
byte_iterator: Any, byte_iterator: Any,
response_ctx: Any, response_ctx: Any,
http_client: httpx.AsyncClient,
prefetched_chunks: list, prefetched_chunks: list,
) -> AsyncGenerator[bytes]: ) -> AsyncGenerator[bytes]:
"""创建响应流生成器(带预读数据,使用字节流)""" """创建响应流生成器(带预读数据,使用字节流)"""
@@ -2089,10 +2067,6 @@ class CliMessageHandlerBase(BaseMessageHandler):
await response_ctx.__aexit__(None, None, None) await response_ctx.__aexit__(None, None, None)
except Exception: except Exception:
pass pass
try:
await http_client.aclose()
except Exception:
pass
def _handle_sse_event( def _handle_sse_event(
self, self,

View File

@@ -73,6 +73,7 @@ async def run_endpoint_check(
provider_id: str | None = None, provider_id: str | None = None,
db: Any | None = None, # Session对象需要时才导入 db: Any | None = None, # Session对象需要时才导入
user: Any | None = None, # User对象 user: Any | None = None, # User对象
proxy_param: Any | None = None, # httpx 可接受的代理参数
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
执行端点检查(重构版本,使用新的架构): 执行端点检查(重构版本,使用新的架构):
@@ -94,6 +95,7 @@ async def run_endpoint_check(
db=db, db=db,
user=user, user=user,
request_id=str(uuid.uuid4())[:8], request_id=str(uuid.uuid4())[:8],
proxy_param=proxy_param,
) )
# 使用协调器执行检查 # 使用协调器执行检查
@@ -565,6 +567,7 @@ class EndpointCheckRequest:
user: Any | None = None user: Any | None = None
request_id: str | None = None request_id: str | None = None
timeout: float = 30.0 timeout: float = 30.0
proxy_param: Any | None = None # httpx 可接受的代理参数
@dataclass @dataclass
@@ -595,7 +598,21 @@ class HttpRequestExecutor:
is_stream = request.json_body.get("stream", False) if request.json_body else False is_stream = request.json_body.get("stream", False) if request.json_body else False
try: try:
async with httpx.AsyncClient(timeout=self.timeout, verify=get_ssl_context()) as client: from src.services.proxy_node.resolver import build_proxy_client_kwargs
if request.proxy_param is not None:
# 调用方已提供解析好的代理参数,直接使用(跳过系统默认回退)
client_kwargs: dict[str, Any] = {
"timeout": self.timeout,
"verify": get_ssl_context(),
}
if request.proxy_param:
client_kwargs["proxy"] = request.proxy_param
else:
# 未提供代理参数,通过 build_proxy_client_kwargs 统一解析(含系统默认回退)
client_kwargs = build_proxy_client_kwargs(timeout=self.timeout)
async with httpx.AsyncClient(**client_kwargs) as client:
if is_stream: if is_stream:
# 流式请求:读取 SSE 事件直到完成 # 流式请求:读取 SSE 事件直到完成
response_data = await self._execute_stream_request(client, request) response_data = await self._execute_stream_request(client, request)

View File

@@ -575,7 +575,6 @@ class StreamProcessor:
ctx: StreamContext, ctx: StreamContext,
byte_iterator: Any, byte_iterator: Any,
response_ctx: Any, response_ctx: Any,
http_client: httpx.AsyncClient,
prefetched_chunks: list | None = None, prefetched_chunks: list | None = None,
*, *,
start_time: float | None = None, start_time: float | None = None,
@@ -589,7 +588,6 @@ class StreamProcessor:
ctx: 流式上下文 ctx: 流式上下文
byte_iterator: 字节流迭代器 byte_iterator: 字节流迭代器
response_ctx: HTTP 响应上下文管理器 response_ctx: HTTP 响应上下文管理器
http_client: HTTP 客户端
prefetched_chunks: 预读的字节块列表(可选) prefetched_chunks: 预读的字节块列表(可选)
start_time: 请求开始时间,用于计算 TTFB可选 start_time: 请求开始时间,用于计算 TTFB可选
@@ -867,7 +865,11 @@ class StreamProcessor:
self._extract_usage_from_converted_event(ctx, evt, event_type) self._extract_usage_from_converted_event(ctx, evt, event_type)
# 根据客户端格式生成 SSE 事件 # 根据客户端格式生成 SSE 事件
out.append(_format_sse_event(evt) if isinstance(evt, dict) else f"data: {json.dumps(evt, ensure_ascii=False)}\n\n".encode()) out.append(
_format_sse_event(evt)
if isinstance(evt, dict)
else f"data: {json.dumps(evt, ensure_ascii=False)}\n\n".encode()
)
return out return out
# 统一处理 prefetched + iterator # 统一处理 prefetched + iterator
@@ -1109,7 +1111,7 @@ class StreamProcessor:
ctx.perf_metrics["stream_chunks"] = int(ctx.chunk_count) ctx.perf_metrics["stream_chunks"] = int(ctx.chunk_count)
if ctx.data_count: if ctx.data_count:
ctx.perf_metrics["stream_data_events"] = int(ctx.data_count) ctx.perf_metrics["stream_data_events"] = int(ctx.data_count)
await self._cleanup(response_ctx, http_client) await self._cleanup(response_ctx)
def _process_line( def _process_line(
self, self,
@@ -1363,17 +1365,12 @@ class StreamProcessor:
async def _cleanup( async def _cleanup(
self, self,
response_ctx: Any, response_ctx: Any,
http_client: httpx.AsyncClient,
) -> None: ) -> None:
"""清理资源""" """清理响应上下文(不关闭池中复用的客户端)"""
try: try:
await response_ctx.__aexit__(None, None, None) await response_ctx.__aexit__(None, None, None)
except Exception: except Exception:
pass pass
try:
await http_client.aclose()
except Exception:
pass
async def create_smoothed_stream( async def create_smoothed_stream(

View File

@@ -269,6 +269,8 @@ class GeminiChatAdapter(ChatAdapterBase):
auth_type: str | None = None, auth_type: str | None = None,
provider_type: str | None = None, provider_type: str | None = None,
decrypted_auth_config: dict[str, Any] | None = None, decrypted_auth_config: dict[str, Any] | None = None,
# 代理参数(已解析,直接传递给 run_endpoint_check
proxy_param: Any | 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
@@ -363,6 +365,7 @@ class GeminiChatAdapter(ChatAdapterBase):
provider_id=provider_id, provider_id=provider_id,
api_key_id=api_key_id, api_key_id=api_key_id,
model_name=effective_model_name, model_name=effective_model_name,
proxy_param=proxy_param,
) )

View File

@@ -449,9 +449,25 @@ class GeminiVeoHandler(VideoHandlerBase):
import httpx import httpx
try: try:
# 解析代理配置key > provider > 系统默认)
from src.services.proxy_node.resolver import (
build_proxy_client_kwargs,
resolve_effective_proxy,
)
provider = getattr(endpoint, "provider", None) if endpoint else None
eff_proxy = resolve_effective_proxy(
getattr(provider, "proxy", None) if provider else None,
getattr(key, "proxy", None),
)
# 使用 follow_redirects=True 跟随重定向 # 使用 follow_redirects=True 跟随重定向
async with httpx.AsyncClient( async with httpx.AsyncClient(
follow_redirects=True, timeout=httpx.Timeout(300.0) **build_proxy_client_kwargs(
eff_proxy,
timeout=httpx.Timeout(300.0),
follow_redirects=True,
)
) as client: ) as client:
response = await client.get(task.video_url, headers=download_headers) response = await client.get(task.video_url, headers=download_headers)
except Exception as exc: except Exception as exc:

View File

@@ -778,7 +778,11 @@ async def download_file(
# 使用 follow_redirects=True 跟随重定向Gemini 文件下载会重定向) # 使用 follow_redirects=True 跟随重定向Gemini 文件下载会重定向)
try: try:
async with httpx.AsyncClient(follow_redirects=True, timeout=httpx.Timeout(300.0)) as client: from src.services.proxy_node.resolver import build_proxy_client_kwargs
async with httpx.AsyncClient(
**build_proxy_client_kwargs(timeout=httpx.Timeout(300.0), follow_redirects=True)
) as client:
response = await client.get(upstream_url, headers=headers) response = await client.get(upstream_url, headers=headers)
except Exception as exc: except Exception as exc:
logger.error("Gemini Files download failed: {}", exc) logger.error("Gemini Files download failed: {}", exc)

View File

@@ -10,7 +10,6 @@ from __future__ import annotations
import json import json
import time import time
from collections import OrderedDict from collections import OrderedDict
from typing import Optional, Tuple
import httpx import httpx
import jwt import jwt
@@ -129,7 +128,11 @@ class VertexAuthService:
# 获取新 Token # 获取新 Token
try: try:
signed_jwt = self._create_jwt() signed_jwt = self._create_jwt()
async with httpx.AsyncClient(timeout=30) as client:
# 使用系统默认代理Vertex AI token endpoint 是外部服务)
from src.services.proxy_node.resolver import build_proxy_client_kwargs
async with httpx.AsyncClient(**build_proxy_client_kwargs(timeout=30)) as client:
resp = await client.post( resp = await client.post(
self.TOKEN_URL, self.TOKEN_URL,
data={ data={

View File

@@ -1,13 +1,12 @@
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode, urlparse, urlunparse from urllib.parse import urlencode, urlparse, urlunparse
import httpx import httpx
from src.services.auth.oauth.models import OAuthToken, OAuthUserInfo from src.services.auth.oauth.models import OAuthToken, OAuthUserInfo
from src.utils.ssl_utils import get_ssl_context
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.database import OAuthProvider from src.models.database import OAuthProvider
@@ -98,9 +97,8 @@ class OAuthProviderBase(ABC):
timeout_seconds: float = 5.0, timeout_seconds: float = 5.0,
headers: dict[str, str] | None = None, headers: dict[str, str] | None = None,
) -> httpx.Response: ) -> httpx.Response:
async with httpx.AsyncClient( client_kwargs = self._build_http_client_kwargs(timeout_seconds)
timeout=httpx.Timeout(timeout_seconds), verify=get_ssl_context() async with httpx.AsyncClient(**client_kwargs) as client:
) as client:
return await client.post(url, data=data, headers=headers) return await client.post(url, data=data, headers=headers)
async def _http_get( async def _http_get(
@@ -110,7 +108,12 @@ class OAuthProviderBase(ABC):
timeout_seconds: float = 5.0, timeout_seconds: float = 5.0,
headers: dict[str, str] | None = None, headers: dict[str, str] | None = None,
) -> httpx.Response: ) -> httpx.Response:
async with httpx.AsyncClient( client_kwargs = self._build_http_client_kwargs(timeout_seconds)
timeout=httpx.Timeout(timeout_seconds), verify=get_ssl_context() async with httpx.AsyncClient(**client_kwargs) as client:
) as client:
return await client.get(url, headers=headers) return await client.get(url, headers=headers)
@staticmethod
def _build_http_client_kwargs(timeout_seconds: float = 5.0) -> dict[str, Any]:
from src.services.proxy_node.resolver import build_proxy_client_kwargs
return build_proxy_client_kwargs(timeout=httpx.Timeout(timeout_seconds))

View File

@@ -24,7 +24,17 @@ from src.services.auth.oauth.state import consume_oauth_state, create_oauth_stat
from src.services.auth.service import AuthService from src.services.auth.service import AuthService
from src.services.cache.user_cache import UserCacheService from src.services.cache.user_cache import UserCacheService
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
from src.utils.ssl_utils import get_ssl_context
def _build_oauth_client_kwargs(
timeout_seconds: float = 5.0, follow_redirects: bool = False
) -> dict[str, Any]:
"""构建 OAuth HTTP 客户端参数(含系统默认代理)"""
from src.services.proxy_node.resolver import build_proxy_client_kwargs
return build_proxy_client_kwargs(
timeout=httpx.Timeout(timeout_seconds), follow_redirects=follow_redirects
)
class OAuthService: class OAuthService:
@@ -835,7 +845,7 @@ class OAuthService:
async def _reachable(url: str) -> bool: async def _reachable(url: str) -> bool:
try: try:
async with httpx.AsyncClient( async with httpx.AsyncClient(
timeout=httpx.Timeout(5.0), follow_redirects=False, verify=get_ssl_context() **_build_oauth_client_kwargs(5.0, follow_redirects=False)
) as client: ) as client:
await client.get(url) await client.get(url)
return True return True
@@ -851,9 +861,7 @@ class OAuthService:
if has_secret and client_secret: if has_secret and client_secret:
# 使用无效 code 做一次 token 请求(仅做粗略判定) # 使用无效 code 做一次 token 请求(仅做粗略判定)
try: try:
async with httpx.AsyncClient( async with httpx.AsyncClient(**_build_oauth_client_kwargs(5.0)) as client:
timeout=httpx.Timeout(5.0), verify=get_ssl_context()
) as client:
resp = await client.post( resp = await client.post(
token_url, token_url,
data={ data={
@@ -914,7 +922,7 @@ class OAuthService:
async def _reachable(url: str) -> bool: async def _reachable(url: str) -> bool:
try: try:
async with httpx.AsyncClient( async with httpx.AsyncClient(
timeout=httpx.Timeout(5.0), follow_redirects=False, verify=get_ssl_context() **_build_oauth_client_kwargs(5.0, follow_redirects=False)
) as client: ) as client:
await client.get(url) await client.get(url)
return True return True
@@ -930,9 +938,7 @@ class OAuthService:
if client_secret: if client_secret:
# 使用无效 code 做一次 token 请求(仅做粗略判定) # 使用无效 code 做一次 token 请求(仅做粗略判定)
try: try:
async with httpx.AsyncClient( async with httpx.AsyncClient(**_build_oauth_client_kwargs(5.0)) as client:
timeout=httpx.Timeout(5.0), verify=get_ssl_context()
) as client:
resp = await client.post( resp = await client.post(
token_url, token_url,
data={ data={

View File

@@ -35,6 +35,7 @@ from src.services.model.upstream_fetcher import (
merge_upstream_metadata, merge_upstream_metadata,
) )
from src.services.provider.oauth_token import resolve_oauth_access_token from src.services.provider.oauth_token import resolve_oauth_access_token
from src.services.proxy_node.resolver import resolve_effective_proxy
from src.services.system.scheduler import get_scheduler from src.services.system.scheduler import get_scheduler
# 从环境变量读取间隔,默认 1440 分钟1 天),限制在 60-10080 分钟之间 # 从环境变量读取间隔,默认 1440 分钟1 天),限制在 60-10080 分钟之间
@@ -448,7 +449,9 @@ class ModelFetchScheduler:
encrypted_auth_config if isinstance(encrypted_auth_config, str) else None encrypted_auth_config if isinstance(encrypted_auth_config, str) else None
), ),
format_to_endpoint=format_to_endpoint, format_to_endpoint=format_to_endpoint,
proxy_config=getattr(provider, "proxy", None), proxy_config=resolve_effective_proxy(
getattr(provider, "proxy", None), getattr(key, "proxy", None)
),
) )
async def _update_key_after_fetch( async def _update_key_after_fetch(

View File

@@ -15,7 +15,6 @@ import httpx
from src.core.api_format import get_extra_headers_from_endpoint from src.core.api_format import get_extra_headers_from_endpoint
from src.core.logger import logger from src.core.logger import logger
from src.utils.ssl_utils import get_ssl_context
# 并发请求限制 # 并发请求限制
MAX_CONCURRENT_REQUESTS = 5 MAX_CONCURRENT_REQUESTS = 5
@@ -100,7 +99,7 @@ async def _fetch_models_default(
) -> tuple[list[dict], list[str], bool, dict[str, Any] | None]: ) -> tuple[list[dict], list[str], bool, dict[str, Any] | None]:
endpoint_configs = build_all_format_configs(ctx.api_key_value, ctx.format_to_endpoint) endpoint_configs = build_all_format_configs(ctx.api_key_value, ctx.format_to_endpoint)
models, errors, has_success = await fetch_models_from_endpoints( models, errors, has_success = await fetch_models_from_endpoints(
endpoint_configs, timeout=timeout_seconds endpoint_configs, timeout=timeout_seconds, proxy_config=ctx.proxy_config
) )
return models, errors, has_success, None return models, errors, has_success, None
@@ -230,6 +229,7 @@ def build_all_format_configs(
async def fetch_models_from_endpoints( async def fetch_models_from_endpoints(
endpoint_configs: list[dict], endpoint_configs: list[dict],
timeout: float = 30.0, timeout: float = 30.0,
proxy_config: dict[str, Any] | None = None,
) -> tuple[list[dict], list[str], bool]: ) -> tuple[list[dict], list[str], bool]:
""" """
从多个端点并发获取模型 从多个端点并发获取模型
@@ -237,10 +237,13 @@ async def fetch_models_from_endpoints(
Args: Args:
endpoint_configs: 端点配置列表,每个配置包含 api_key, base_url, api_format, extra_headers endpoint_configs: 端点配置列表,每个配置包含 api_key, base_url, api_format, extra_headers
timeout: 请求超时时间(秒) timeout: 请求超时时间(秒)
proxy_config: 代理配置(可选),支持系统默认回退
Returns: Returns:
(模型列表, 错误列表, 是否有成功) (模型列表, 错误列表, 是否有成功)
""" """
from src.services.proxy_node.resolver import build_proxy_client_kwargs
all_models: list[dict] = [] all_models: list[dict] = []
errors: list[str] = [] errors: list[str] = []
has_success = False has_success = False
@@ -279,7 +282,9 @@ async def fetch_models_from_endpoints(
logger.exception("获取 {} 模型出错", api_format) logger.exception("获取 {} 模型出错", api_format)
return [], f"{api_format}: error", False return [], f"{api_format}: error", False
async with httpx.AsyncClient(timeout=timeout, verify=get_ssl_context()) as client: async with httpx.AsyncClient(
**build_proxy_client_kwargs(proxy_config, timeout=timeout)
) as client:
results = await asyncio.gather(*[fetch_one(client, c) for c in endpoint_configs]) results = await asyncio.gather(*[fetch_one(client, c) for c in endpoint_configs])
for models, error, success in results: for models, error, success in results:
all_models.extend(models) all_models.extend(models)

View File

@@ -292,6 +292,66 @@ def resolve_effective_proxy(
return provider_proxy return provider_proxy
def resolve_proxy_param(
proxy_config: dict[str, Any] | None = None,
) -> str | httpx.Proxy | None:
"""
将代理配置解析为 httpx 可接受的代理参数(含系统默认回退)
优先级proxy_config -> 系统默认代理 -> None直连
Args:
proxy_config: 代理配置字典(通常来自 resolve_effective_proxy 的返回值)
Returns:
httpx 可接受的 proxy 参数,或 None
"""
url = build_proxy_url(proxy_config) if proxy_config else None
if not url:
sys_proxy = get_system_proxy_config()
if sys_proxy:
try:
url = build_proxy_url(sys_proxy)
except Exception as exc:
logger.warning("resolve_proxy_param: 构建系统默认代理 URL 失败: {}", exc)
url = None
return make_proxy_param(url)
def build_proxy_client_kwargs(
proxy_config: dict[str, Any] | None = None,
*,
timeout: float = 30.0,
verify: Any | None = None,
**extra: Any,
) -> dict[str, Any]:
"""
构建包含代理配置的 httpx.AsyncClient 初始化参数。
将 resolve_proxy_param + dict 构建 + 条件 proxy 赋值合并为一步,
减少调用方的样板代码。
Args:
proxy_config: 代理配置字典(通常来自 resolve_effective_proxy
timeout: 请求超时(秒)
verify: SSL 验证参数None 时自动使用 get_ssl_context()
**extra: 其他 httpx.AsyncClient 参数(如 follow_redirects
Returns:
可直接解包传给 httpx.AsyncClient 的参数字典
"""
if verify is None:
from src.utils.ssl_utils import get_ssl_context
verify = get_ssl_context()
kwargs: dict[str, Any] = {"timeout": timeout, "verify": verify, **extra}
proxy_param = resolve_proxy_param(proxy_config)
if proxy_param:
kwargs["proxy"] = proxy_param
return kwargs
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 代理 URL 构建 # 代理 URL 构建
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -175,14 +175,20 @@ class RequestExecutor:
) )
else: else:
# 非流式请求:标记为 success 状态 # 非流式请求:标记为 success 状态
from src.services.proxy_node.resolver import resolve_proxy_info from src.services.proxy_node.resolver import (
resolve_effective_proxy,
resolve_proxy_info,
)
_eff_proxy = resolve_effective_proxy(
getattr(provider, "proxy", None), getattr(key, "proxy", None)
)
_extra: dict[str, Any] = { _extra: dict[str, Any] = {
"is_cached_user": is_cached_user, "is_cached_user": is_cached_user,
"model_name": model_name, "model_name": model_name,
"api_format": api_format, "api_format": api_format,
} }
_pi = resolve_proxy_info(getattr(provider, "proxy", None)) _pi = resolve_proxy_info(_eff_proxy)
if _pi: if _pi:
_extra["proxy"] = _pi _extra["proxy"] = _pi
RequestCandidateService.mark_candidate_success( RequestCandidateService.mark_candidate_success(

View File

@@ -708,11 +708,15 @@ class TaskService:
ThinkingSignatureException, ThinkingSignatureException,
UpstreamClientException, UpstreamClientException,
) )
from src.services.proxy_node.resolver import resolve_proxy_info from src.services.proxy_node.resolver import resolve_effective_proxy, resolve_proxy_info
from src.services.request.executor import ExecutionError from src.services.request.executor import ExecutionError
# 提前解析代理信息,写入候选记录的 extra_data用于链路追踪展示 # 提前解析代理信息,写入候选记录的 extra_data用于链路追踪展示
_proxy_info = resolve_proxy_info(getattr(candidate.provider, "proxy", None)) _eff_proxy = resolve_effective_proxy(
getattr(candidate.provider, "proxy", None),
getattr(candidate.key, "proxy", None),
)
_proxy_info = resolve_proxy_info(_eff_proxy)
_proxy_extra: dict[str, Any] | None = {"proxy": _proxy_info} if _proxy_info else None _proxy_extra: dict[str, Any] | None = {"proxy": _proxy_info} if _proxy_info else None
if not isinstance(exec_err, ExecutionError): if not isinstance(exec_err, ExecutionError):