mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
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:
@@ -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 || [])]
|
||||||
// 清空编辑状态,重新从端点加载
|
// 清空编辑状态,重新从端点加载
|
||||||
|
|||||||
@@ -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) => {
|
||||||
id: `key-${providerId}-${i + 1}`,
|
const isOAuth = i === 1
|
||||||
provider_id: providerId,
|
const markInvalid = isOAuth && providerId.endsWith('3')
|
||||||
api_formats: i === 0 ? formats : formats.slice(0, 1),
|
const oauthFields = isOAuth ? {
|
||||||
api_key_masked: `sk-***...${Math.random().toString(36).substring(2, 6)}`,
|
auth_type: 'oauth',
|
||||||
name: i === 0 ? 'Primary Key' : `Backup Key ${i}`,
|
oauth_email: 'oauth-demo@aether.dev',
|
||||||
rate_multiplier: 1.0,
|
oauth_expires_at: markInvalid ? null : nowSec + 6 * 3600,
|
||||||
internal_priority: i + 1,
|
oauth_invalid_at: markInvalid ? nowSec - 3600 : null,
|
||||||
health_score: 0.90 + Math.random() * 0.10, // 0.90-1.00
|
oauth_invalid_reason: markInvalid ? '[ACCOUNT_BLOCK] Demo verification required' : null,
|
||||||
consecutive_failures: Math.random() > 0.8 ? 1 : 0,
|
oauth_plan_type: 'pro',
|
||||||
request_count: 1000 + Math.floor(Math.random() * 5000),
|
oauth_account_id: `acct-${providerId}`
|
||||||
success_count: 950 + Math.floor(Math.random() * 4800),
|
} : { auth_type: 'api_key' }
|
||||||
error_count: Math.floor(Math.random() * 100),
|
|
||||||
success_rate: 0.95 + Math.random() * 0.04, // 0.95-0.99
|
return {
|
||||||
avg_response_time_ms: 800 + Math.floor(Math.random() * 600),
|
id: `key-${providerId}-${i + 1}`,
|
||||||
cache_ttl_minutes: 5,
|
provider_id: providerId,
|
||||||
max_probe_interval_minutes: 32,
|
api_formats: i === 0 ? formats : formats.slice(0, 1),
|
||||||
is_active: true,
|
api_key_masked: `sk-***...${Math.random().toString(36).substring(2, 6)}`,
|
||||||
created_at: '2024-01-01T00:00:00Z',
|
name: i === 0 ? 'Primary Key' : `Backup Key ${i}`,
|
||||||
updated_at: new Date().toISOString()
|
...oauthFields,
|
||||||
}))
|
rate_multiplier: 1.0,
|
||||||
|
internal_priority: i + 1,
|
||||||
|
health_score: 0.90 + Math.random() * 0.10,
|
||||||
|
consecutive_failures: Math.random() > 0.8 ? 1 : 0,
|
||||||
|
request_count: 1000 + Math.floor(Math.random() * 5000),
|
||||||
|
success_count: 950 + Math.floor(Math.random() * 4800),
|
||||||
|
error_count: Math.floor(Math.random() * 100),
|
||||||
|
success_rate: 0.95 + Math.random() * 0.04,
|
||||||
|
avg_response_time_ms: 800 + Math.floor(Math.random() * 600),
|
||||||
|
cache_ttl_minutes: 5,
|
||||||
|
max_probe_interval_minutes: 32,
|
||||||
|
is_active: true,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,186 +803,187 @@ 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:
|
)
|
||||||
logger.debug("[test-model] 开始端点测试...")
|
test_proxy_param = resolve_proxy_param(test_proxy)
|
||||||
|
|
||||||
# Provider 上下文:auth_type 用于 OAuth 认证头处理,provider_type 用于特殊路由
|
logger.debug("[test-model] 开始端点测试...")
|
||||||
p_type = str(getattr(provider, "provider_type", "") or "").lower()
|
|
||||||
|
|
||||||
async def _do_check(req: dict) -> dict:
|
# Provider 上下文:auth_type 用于 OAuth 认证头处理,provider_type 用于特殊路由
|
||||||
return await adapter_class.check_endpoint(
|
p_type = str(getattr(provider, "provider_type", "") or "").lower()
|
||||||
client,
|
|
||||||
endpoint_config["base_url"],
|
|
||||||
endpoint_config["api_key"],
|
|
||||||
req,
|
|
||||||
extra_headers if extra_headers else None,
|
|
||||||
body_rules=body_rules,
|
|
||||||
header_rules=header_rules,
|
|
||||||
db=db,
|
|
||||||
user=current_user,
|
|
||||||
provider_name=provider.name,
|
|
||||||
provider_id=provider.id,
|
|
||||||
api_key_id=endpoint_config.get("api_key_id"),
|
|
||||||
model_name=request.model_name,
|
|
||||||
auth_type=auth_type,
|
|
||||||
provider_type=p_type if p_type else None,
|
|
||||||
decrypted_auth_config=oauth_meta if oauth_meta else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _response_has_error(resp: dict) -> bool:
|
async def _do_check(req: dict) -> dict:
|
||||||
"""快速判断响应是否包含错误"""
|
return await adapter_class.check_endpoint(
|
||||||
if "error" in resp:
|
None, # client 参数已不被 run_endpoint_check 使用
|
||||||
return True
|
endpoint_config["base_url"],
|
||||||
if resp.get("status_code", 0) != 200:
|
endpoint_config["api_key"],
|
||||||
return True
|
req,
|
||||||
resp_data = resp.get("response", {})
|
extra_headers if extra_headers else None,
|
||||||
resp_body = resp_data.get("response_body", {})
|
body_rules=body_rules,
|
||||||
parsed = resp_body
|
header_rules=header_rules,
|
||||||
if isinstance(resp_body, str):
|
db=db,
|
||||||
try:
|
user=current_user,
|
||||||
parsed = json.loads(resp_body)
|
provider_name=provider.name,
|
||||||
except (json.JSONDecodeError, ValueError):
|
provider_id=provider.id,
|
||||||
pass
|
api_key_id=endpoint_config.get("api_key_id"),
|
||||||
if isinstance(parsed, dict) and "error" in parsed:
|
model_name=request.model_name,
|
||||||
return True
|
auth_type=auth_type,
|
||||||
return False
|
provider_type=p_type if p_type else None,
|
||||||
|
decrypted_auth_config=oauth_meta if oauth_meta else None,
|
||||||
|
proxy_param=test_proxy_param,
|
||||||
|
)
|
||||||
|
|
||||||
# 策略:优先流式,若失败回退到非流式
|
def _response_has_error(resp: dict) -> bool:
|
||||||
used_stream = True
|
"""快速判断响应是否包含错误"""
|
||||||
logger.debug("[test-model] 尝试流式请求...")
|
if "error" in resp:
|
||||||
|
return True
|
||||||
|
if resp.get("status_code", 0) != 200:
|
||||||
|
return True
|
||||||
|
resp_data = resp.get("response", {})
|
||||||
|
resp_body = resp_data.get("response_body", {})
|
||||||
|
parsed = resp_body
|
||||||
|
if isinstance(resp_body, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(resp_body)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
pass
|
||||||
|
if isinstance(parsed, dict) and "error" in parsed:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 策略:优先流式,若失败回退到非流式
|
||||||
|
used_stream = True
|
||||||
|
logger.debug("[test-model] 尝试流式请求...")
|
||||||
|
response = await _do_check(check_request)
|
||||||
|
|
||||||
|
if _response_has_error(response):
|
||||||
|
logger.info(
|
||||||
|
"[test-model] 流式请求失败 (status={}),回退到非流式请求",
|
||||||
|
response.get("status_code", "?"),
|
||||||
|
)
|
||||||
|
check_request["stream"] = False
|
||||||
|
used_stream = False
|
||||||
response = await _do_check(check_request)
|
response = await _do_check(check_request)
|
||||||
|
|
||||||
if _response_has_error(response):
|
# 记录提供商返回信息
|
||||||
logger.info(
|
logger.debug("[test-model] 端点测试结果:")
|
||||||
"[test-model] 流式请求失败 (status={}),回退到非流式请求",
|
logger.debug(f"[test-model] Status Code: {response.get('status_code')}")
|
||||||
response.get("status_code", "?"),
|
logger.debug(f"[test-model] Response Headers: {response.get('headers', {})}")
|
||||||
)
|
response_data = response.get("response", {})
|
||||||
check_request["stream"] = False
|
response_body = response_data.get("response_body", {})
|
||||||
used_stream = False
|
logger.debug(f"[test-model] Response Data: {response_data}")
|
||||||
response = await _do_check(check_request)
|
logger.debug(f"[test-model] Response Body: {response_body}")
|
||||||
|
# 尝试解析 response_body (通常是 JSON 字符串)
|
||||||
|
parsed_body = response_body
|
||||||
|
import json
|
||||||
|
|
||||||
# 记录提供商返回信息
|
if isinstance(response_body, str):
|
||||||
logger.debug("[test-model] 端点测试结果:")
|
try:
|
||||||
logger.debug(f"[test-model] Status Code: {response.get('status_code')}")
|
parsed_body = json.loads(response_body)
|
||||||
logger.debug(f"[test-model] Response Headers: {response.get('headers', {})}")
|
except json.JSONDecodeError:
|
||||||
response_data = response.get("response", {})
|
pass
|
||||||
response_body = response_data.get("response_body", {})
|
|
||||||
logger.debug(f"[test-model] Response Data: {response_data}")
|
|
||||||
logger.debug(f"[test-model] Response Body: {response_body}")
|
|
||||||
# 尝试解析 response_body (通常是 JSON 字符串)
|
|
||||||
parsed_body = response_body
|
|
||||||
import json
|
|
||||||
|
|
||||||
if isinstance(response_body, str):
|
if isinstance(parsed_body, dict) and "error" in parsed_body:
|
||||||
try:
|
error_obj = parsed_body["error"]
|
||||||
parsed_body = json.loads(response_body)
|
# 兼容 error 可能是字典或字符串的情况
|
||||||
except json.JSONDecodeError:
|
if isinstance(error_obj, dict):
|
||||||
pass
|
error_message = error_obj.get("message", "")
|
||||||
|
logger.debug(f"[test-model] Error Message: {error_message}")
|
||||||
|
|
||||||
if isinstance(parsed_body, dict) and "error" in parsed_body:
|
# Antigravity 403 "verify your account" → 标记账号异常
|
||||||
error_obj = parsed_body["error"]
|
if (
|
||||||
# 兼容 error 可能是字典或字符串的情况
|
api_key
|
||||||
if isinstance(error_obj, dict):
|
and auth_type == "oauth"
|
||||||
error_message = error_obj.get("message", "")
|
and error_obj.get("code") == 403
|
||||||
logger.debug(f"[test-model] Error Message: {error_message}")
|
and (
|
||||||
|
"verify" in error_message.lower()
|
||||||
# Antigravity 403 "verify your account" → 标记账号异常
|
or "permission" in str(error_obj.get("status", "")).lower()
|
||||||
if (
|
|
||||||
api_key
|
|
||||||
and auth_type == "oauth"
|
|
||||||
and error_obj.get("code") == 403
|
|
||||||
and (
|
|
||||||
"verify" in error_message.lower()
|
|
||||||
or "permission" in str(error_obj.get("status", "")).lower()
|
|
||||||
)
|
|
||||||
):
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from src.services.provider.oauth_token import (
|
|
||||||
OAUTH_ACCOUNT_BLOCK_PREFIX,
|
|
||||||
)
|
|
||||||
|
|
||||||
api_key.oauth_invalid_at = datetime.now(timezone.utc)
|
|
||||||
api_key.oauth_invalid_reason = (
|
|
||||||
f"{OAUTH_ACCOUNT_BLOCK_PREFIX}Google 要求验证账号"
|
|
||||||
)
|
|
||||||
api_key.is_active = False
|
|
||||||
db.commit()
|
|
||||||
oauth_email = None
|
|
||||||
if getattr(api_key, "auth_config", None):
|
|
||||||
try:
|
|
||||||
decrypted = crypto_service.decrypt(api_key.auth_config)
|
|
||||||
parsed = json.loads(decrypted)
|
|
||||||
if isinstance(parsed, dict):
|
|
||||||
email_val = parsed.get("email")
|
|
||||||
if isinstance(email_val, str) and email_val.strip():
|
|
||||||
oauth_email = email_val.strip()
|
|
||||||
except Exception:
|
|
||||||
oauth_email = None
|
|
||||||
if oauth_email:
|
|
||||||
logger.warning(
|
|
||||||
"[test-model] Key {} (email={}) 因 403 verify 已标记为异常",
|
|
||||||
api_key.id,
|
|
||||||
oauth_email,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"[test-model] Key {} 因 403 verify 已标记为异常", api_key.id
|
|
||||||
)
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=str(error_message)[:500] if error_message else "Provider error",
|
|
||||||
)
|
)
|
||||||
else:
|
):
|
||||||
logger.debug(f"[test-model] Error: {error_obj}")
|
from datetime import datetime, timezone
|
||||||
# error_obj 可能是字符串,截断以避免泄露过多上游信息
|
|
||||||
raise HTTPException(
|
from src.services.provider.oauth_token import (
|
||||||
status_code=500,
|
OAUTH_ACCOUNT_BLOCK_PREFIX,
|
||||||
detail=str(error_obj)[:500] if error_obj else "Provider error",
|
|
||||||
)
|
)
|
||||||
elif "error" in response:
|
|
||||||
logger.debug(f"[test-model] Error: {response['error']}")
|
api_key.oauth_invalid_at = datetime.now(timezone.utc)
|
||||||
|
api_key.oauth_invalid_reason = (
|
||||||
|
f"{OAUTH_ACCOUNT_BLOCK_PREFIX}Google 要求验证账号"
|
||||||
|
)
|
||||||
|
api_key.is_active = False
|
||||||
|
db.commit()
|
||||||
|
oauth_email = None
|
||||||
|
if getattr(api_key, "auth_config", None):
|
||||||
|
try:
|
||||||
|
decrypted = crypto_service.decrypt(api_key.auth_config)
|
||||||
|
parsed = json.loads(decrypted)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
email_val = parsed.get("email")
|
||||||
|
if isinstance(email_val, str) and email_val.strip():
|
||||||
|
oauth_email = email_val.strip()
|
||||||
|
except Exception:
|
||||||
|
oauth_email = None
|
||||||
|
if oauth_email:
|
||||||
|
logger.warning(
|
||||||
|
"[test-model] Key {} (email={}) 因 403 verify 已标记为异常",
|
||||||
|
api_key.id,
|
||||||
|
oauth_email,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("[test-model] Key {} 因 403 verify 已标记为异常", api_key.id)
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=str(response["error"])[:500],
|
detail=str(error_message)[:500] if error_message else "Provider error",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 如果有选择或消息,记录内容预览
|
logger.debug(f"[test-model] Error: {error_obj}")
|
||||||
if isinstance(response_data, dict):
|
# error_obj 可能是字符串,截断以避免泄露过多上游信息
|
||||||
if "choices" in response_data and response_data["choices"]:
|
raise HTTPException(
|
||||||
choice = response_data["choices"][0]
|
status_code=500,
|
||||||
if "message" in choice:
|
detail=str(error_obj)[:500] if error_obj else "Provider error",
|
||||||
content = choice["message"].get("content", "")
|
)
|
||||||
logger.debug(f"[test-model] Content Preview: {content[:200]}...")
|
elif "error" in response:
|
||||||
elif "content" in response_data and response_data["content"]:
|
logger.debug(f"[test-model] Error: {response['error']}")
|
||||||
content = str(response_data["content"])
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=str(response["error"])[:500],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 如果有选择或消息,记录内容预览
|
||||||
|
if isinstance(response_data, dict):
|
||||||
|
if "choices" in response_data and response_data["choices"]:
|
||||||
|
choice = response_data["choices"][0]
|
||||||
|
if "message" in choice:
|
||||||
|
content = choice["message"].get("content", "")
|
||||||
logger.debug(f"[test-model] Content Preview: {content[:200]}...")
|
logger.debug(f"[test-model] Content Preview: {content[:200]}...")
|
||||||
|
elif "content" in response_data and response_data["content"]:
|
||||||
|
content = str(response_data["content"])
|
||||||
|
logger.debug(f"[test-model] Content Preview: {content[:200]}...")
|
||||||
|
|
||||||
# 检查测试是否成功(基于HTTP状态码)
|
# 检查测试是否成功(基于HTTP状态码)
|
||||||
status_code = response.get("status_code", 0)
|
status_code = response.get("status_code", 0)
|
||||||
is_success = status_code == 200 and "error" not in response
|
is_success = status_code == 200 and "error" not in response
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": is_success,
|
"success": is_success,
|
||||||
"data": {
|
"data": {
|
||||||
"stream": used_stream,
|
"stream": used_stream,
|
||||||
"response": response,
|
"response": response,
|
||||||
},
|
},
|
||||||
"provider": {
|
"provider": {
|
||||||
"id": provider.id,
|
"id": provider.id,
|
||||||
"name": provider.name,
|
"name": provider.name,
|
||||||
},
|
},
|
||||||
"model": request.model_name,
|
"model": request.model_name,
|
||||||
"endpoint": {
|
"endpoint": {
|
||||||
"id": endpoint.id,
|
"id": endpoint.id,
|
||||||
"api_format": endpoint.api_format,
|
"api_format": endpoint.api_format,
|
||||||
"base_url": endpoint.base_url,
|
"base_url": endpoint.base_url,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[test-model] Error testing model {request.model_name}: {e}")
|
logger.error(f"[test-model] Error testing model {request.model_name}: {e}")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 构建
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user