Merge branch 'fawney19:master' into master

This commit is contained in:
NyaDoo
2026-01-16 16:24:41 +08:00
committed by GitHub
26 changed files with 146 additions and 146 deletions

View File

@@ -76,7 +76,6 @@ export interface ProviderExport {
provider_priority?: number provider_priority?: number
is_active: boolean is_active: boolean
concurrent_limit?: number | null concurrent_limit?: number | null
timeout?: number | null
max_retries?: number | null max_retries?: number | null
proxy?: any proxy?: any
config?: any config?: any
@@ -89,7 +88,6 @@ export interface EndpointExport {
api_format: string api_format: string
base_url: string base_url: string
headers?: any headers?: any
timeout?: number
max_retries?: number max_retries?: number
is_active: boolean is_active: boolean
custom_path?: string | null custom_path?: string | null

View File

@@ -28,7 +28,6 @@ export async function createEndpoint(
base_url: string base_url: string
custom_path?: string custom_path?: string
header_rules?: HeaderRule[] header_rules?: HeaderRule[]
timeout?: number
max_retries?: number max_retries?: number
is_active?: boolean is_active?: boolean
config?: Record<string, any> config?: Record<string, any>
@@ -48,7 +47,6 @@ export async function updateEndpoint(
base_url: string base_url: string
custom_path: string | null custom_path: string | null
header_rules: HeaderRule[] header_rules: HeaderRule[]
timeout: number
max_retries: number max_retries: number
is_active: boolean is_active: boolean
config: Record<string, any> config: Record<string, any>

View File

@@ -34,7 +34,6 @@ export async function updateProvider(
quota_expires_at: string quota_expires_at: string
rpm_limit: number | null rpm_limit: number | null
// 请求配置(从 Endpoint 迁移) // 请求配置(从 Endpoint 迁移)
timeout: number
max_retries: number max_retries: number
proxy: ProxyConfig | null proxy: ProxyConfig | null
cache_ttl_minutes: number // 0表示不支持缓存>0表示支持缓存并设置TTL(分钟) cache_ttl_minutes: number // 0表示不支持缓存>0表示支持缓存并设置TTL(分钟)

View File

@@ -96,7 +96,6 @@ export interface ProviderEndpoint {
custom_path?: string // 自定义请求路径(可选,为空则使用 API 格式默认路径) custom_path?: string // 自定义请求路径(可选,为空则使用 API 格式默认路径)
// 请求头配置 // 请求头配置
header_rules?: HeaderRule[] // 请求头规则列表,支持 set/drop/rename 操作 header_rules?: HeaderRule[] // 请求头规则列表,支持 set/drop/rename 操作
timeout: number
max_retries: number max_retries: number
is_active: boolean is_active: boolean
config?: Record<string, any> config?: Record<string, any>
@@ -302,7 +301,6 @@ export interface ProviderWithEndpointsSummary {
quota_last_reset_at?: string // 当前周期开始时间 quota_last_reset_at?: string // 当前周期开始时间
quota_expires_at?: string quota_expires_at?: string
// 请求配置(从 Endpoint 迁移) // 请求配置(从 Endpoint 迁移)
timeout?: number // 请求超时(秒)
max_retries?: number // 最大重试次数 max_retries?: number // 最大重试次数
proxy?: ProxyConfig | null // 代理配置 proxy?: ProxyConfig | null // 代理配置
is_active: boolean is_active: boolean

View File

@@ -80,29 +80,16 @@
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="space-y-1.5">
<div class="space-y-1.5"> <Label>最大重试次数</Label>
<Label>超时时间 ()</Label> <Input
<Input :model-value="form.max_retries ?? ''"
:model-value="form.timeout ?? ''" type="number"
type="number" min="0"
min="1" max="10"
max="600" placeholder="默认 2"
placeholder="默认 300" @update:model-value="(v) => form.max_retries = parseNumberInput(v)"
@update:model-value="(v) => form.timeout = parseNumberInput(v)" />
/>
</div>
<div class="space-y-1.5">
<Label>最大重试次数</Label>
<Input
:model-value="form.max_retries ?? ''"
type="number"
min="0"
max="10"
placeholder="默认 2"
@update:model-value="(v) => form.max_retries = parseNumberInput(v)"
/>
</div>
</div> </div>
</div> </div>
@@ -279,7 +266,6 @@ const form = ref({
rate_limit: undefined as number | undefined, rate_limit: undefined as number | undefined,
concurrent_limit: undefined as number | undefined, concurrent_limit: undefined as number | undefined,
// 请求配置 // 请求配置
timeout: undefined as number | undefined,
max_retries: undefined as number | undefined, max_retries: undefined as number | undefined,
// 代理配置(扁平化便于表单绑定) // 代理配置(扁平化便于表单绑定)
proxy_enabled: false, proxy_enabled: false,
@@ -304,7 +290,6 @@ function resetForm() {
rate_limit: undefined, rate_limit: undefined,
concurrent_limit: undefined, concurrent_limit: undefined,
// 请求配置 // 请求配置
timeout: undefined,
max_retries: undefined, max_retries: undefined,
// 代理配置 // 代理配置
proxy_enabled: false, proxy_enabled: false,
@@ -335,7 +320,6 @@ function loadProviderData() {
rate_limit: undefined, rate_limit: undefined,
concurrent_limit: undefined, concurrent_limit: undefined,
// 请求配置 // 请求配置
timeout: props.provider.timeout ?? undefined,
max_retries: props.provider.max_retries ?? undefined, max_retries: props.provider.max_retries ?? undefined,
// 代理配置 // 代理配置
proxy_enabled: proxy?.enabled ?? false, proxy_enabled: proxy?.enabled ?? false,
@@ -391,7 +375,6 @@ const handleSubmit = async () => {
provider_priority: form.value.provider_priority, provider_priority: form.value.provider_priority,
is_active: form.value.is_active, is_active: form.value.is_active,
// 请求配置 // 请求配置
timeout: form.value.timeout ?? undefined,
max_retries: form.value.max_retries ?? undefined, max_retries: form.value.max_retries ?? undefined,
proxy, proxy,
} }

View File

@@ -425,9 +425,9 @@ const MOCK_ENDPOINT_KEYS = [
// Mock Endpoints // Mock Endpoints
const MOCK_ENDPOINTS = [ const MOCK_ENDPOINTS = [
{ id: 'ep-001', provider_id: 'provider-001', provider_name: 'anthropic', api_format: 'CLAUDE', base_url: 'https://api.anthropic.com', timeout: 300, 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', 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-002', provider_id: 'provider-002', provider_name: 'openai', api_format: 'OPENAI', base_url: 'https://api.openai.com', timeout: 60, 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', 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-003', provider_id: 'provider-003', provider_name: 'google', api_format: 'GEMINI', base_url: 'https://generativelanguage.googleapis.com', timeout: 60, 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', 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() }
] ]
// Mock 能力定义 // Mock 能力定义
@@ -1221,7 +1221,6 @@ function generateMockEndpointsForProvider(providerId: string) {
base_url: format.includes('CLAUDE') ? 'https://api.anthropic.com' : base_url: format.includes('CLAUDE') ? 'https://api.anthropic.com' :
format.includes('OPENAI') ? 'https://api.openai.com' : format.includes('OPENAI') ? 'https://api.openai.com' :
'https://generativelanguage.googleapis.com', 'https://generativelanguage.googleapis.com',
timeout: 300,
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,

View File

@@ -65,7 +65,6 @@ async def list_provider_endpoints(
- `api_format`: API 格式 - `api_format`: API 格式
- `base_url`: 基础 URL - `base_url`: 基础 URL
- `custom_path`: 自定义路径 - `custom_path`: 自定义路径
- `timeout`: 超时时间(秒)
- `max_retries`: 最大重试次数 - `max_retries`: 最大重试次数
- `is_active`: 是否活跃 - `is_active`: 是否活跃
- `total_keys`: Key 总数 - `total_keys`: Key 总数
@@ -103,7 +102,6 @@ async def create_provider_endpoint(
- `base_url`: 基础 URL - `base_url`: 基础 URL
- `custom_path`: 自定义路径(可选) - `custom_path`: 自定义路径(可选)
- `header_rules`: 请求头规则列表(可选,支持 set/drop/rename 操作) - `header_rules`: 请求头规则列表(可选,支持 set/drop/rename 操作)
- `timeout`: 超时时间(秒,默认 300
- `max_retries`: 最大重试次数(默认 2 - `max_retries`: 最大重试次数(默认 2
- `config`: 额外配置(可选) - `config`: 额外配置(可选)
- `proxy`: 代理配置(可选) - `proxy`: 代理配置(可选)
@@ -139,7 +137,6 @@ async def get_endpoint(
- `api_format`: API 格式 - `api_format`: API 格式
- `base_url`: 基础 URL - `base_url`: 基础 URL
- `custom_path`: 自定义路径 - `custom_path`: 自定义路径
- `timeout`: 超时时间(秒)
- `max_retries`: 最大重试次数 - `max_retries`: 最大重试次数
- `is_active`: 是否活跃 - `is_active`: 是否活跃
- `total_keys`: Key 总数 - `total_keys`: Key 总数
@@ -170,7 +167,6 @@ async def update_endpoint(
- `base_url`: 基础 URL - `base_url`: 基础 URL
- `custom_path`: 自定义路径 - `custom_path`: 自定义路径
- `header_rules`: 请求头规则列表 - `header_rules`: 请求头规则列表
- `timeout`: 超时时间(秒)
- `max_retries`: 最大重试次数 - `max_retries`: 最大重试次数
- `is_active`: 是否活跃 - `is_active`: 是否活跃
- `config`: 额外配置 - `config`: 额外配置
@@ -306,7 +302,6 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
base_url=self.endpoint_data.base_url, base_url=self.endpoint_data.base_url,
custom_path=self.endpoint_data.custom_path, custom_path=self.endpoint_data.custom_path,
header_rules=self.endpoint_data.header_rules, header_rules=self.endpoint_data.header_rules,
timeout=self.endpoint_data.timeout,
max_retries=self.endpoint_data.max_retries, max_retries=self.endpoint_data.max_retries,
is_active=True, is_active=True,
config=self.endpoint_data.config, config=self.endpoint_data.config,

View File

@@ -317,14 +317,14 @@ async def test_model(
logger.error(f"[test-model] Failed to decrypt API key: {e}") logger.error(f"[test-model] Failed to decrypt API key: {e}")
raise HTTPException(status_code=500, detail="Failed to decrypt API key") raise HTTPException(status_code=500, detail="Failed to decrypt API key")
# 构建请求配置timeout 从 Provider 读取) # 构建请求配置
endpoint_config = { endpoint_config = {
"api_key": api_key_value, "api_key": api_key_value,
"api_key_id": api_key.id, # 添加API Key ID用于用量记录 "api_key_id": api_key.id, # 添加API Key ID用于用量记录
"base_url": endpoint.base_url, "base_url": endpoint.base_url,
"api_format": endpoint.api_format, "api_format": endpoint.api_format,
"extra_headers": get_extra_headers_from_endpoint(endpoint), "extra_headers": get_extra_headers_from_endpoint(endpoint),
"timeout": provider.timeout or TimeoutDefaults.HTTP_REQUEST, "timeout": TimeoutDefaults.HTTP_REQUEST,
} }
try: try:

View File

@@ -140,7 +140,6 @@ async def create_provider(request: Request, db: Session = Depends(get_db)):
- `provider_priority`: 提供商优先级(数字越小优先级越高,默认 100 - `provider_priority`: 提供商优先级(数字越小优先级越高,默认 100
- `is_active`: 是否启用(默认 true - `is_active`: 是否启用(默认 true
- `concurrent_limit`: 并发限制(可选) - `concurrent_limit`: 并发限制(可选)
- `timeout`: 请求超时(秒,可选)
- `max_retries`: 最大重试次数(可选) - `max_retries`: 最大重试次数(可选)
- `proxy`: 代理配置(可选) - `proxy`: 代理配置(可选)
- `config`: 额外配置信息JSON可选 - `config`: 额外配置信息JSON可选
@@ -176,7 +175,6 @@ async def update_provider(provider_id: str, request: Request, db: Session = Depe
- `provider_priority`: 提供商优先级 - `provider_priority`: 提供商优先级
- `is_active`: 是否启用 - `is_active`: 是否启用
- `concurrent_limit`: 并发限制 - `concurrent_limit`: 并发限制
- `timeout`: 请求超时(秒)
- `max_retries`: 最大重试次数 - `max_retries`: 最大重试次数
- `proxy`: 代理配置 - `proxy`: 代理配置
- `config`: 额外配置信息JSON - `config`: 额外配置信息JSON
@@ -293,7 +291,6 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
provider_priority=validated_data.provider_priority, provider_priority=validated_data.provider_priority,
is_active=validated_data.is_active, is_active=validated_data.is_active,
concurrent_limit=validated_data.concurrent_limit, concurrent_limit=validated_data.concurrent_limit,
timeout=validated_data.timeout,
max_retries=validated_data.max_retries, max_retries=validated_data.max_retries,
proxy=validated_data.proxy.model_dump() if validated_data.proxy else None, proxy=validated_data.proxy.model_dump() if validated_data.proxy else None,
config=validated_data.config, config=validated_data.config,

View File

@@ -302,7 +302,6 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
quota_reset_day=provider.quota_reset_day, quota_reset_day=provider.quota_reset_day,
quota_last_reset_at=provider.quota_last_reset_at, quota_last_reset_at=provider.quota_last_reset_at,
quota_expires_at=provider.quota_expires_at, quota_expires_at=provider.quota_expires_at,
timeout=provider.timeout,
max_retries=provider.max_retries, max_retries=provider.max_retries,
proxy=provider.proxy, proxy=provider.proxy,
total_endpoints=total_endpoints, total_endpoints=total_endpoints,

View File

@@ -774,7 +774,6 @@ class AdminExportConfigAdapter(AdminApiAdapter):
"api_format": ep.api_format, "api_format": ep.api_format,
"base_url": ep.base_url, "base_url": ep.base_url,
"header_rules": ep.header_rules, "header_rules": ep.header_rules,
"timeout": ep.timeout,
"max_retries": ep.max_retries, "max_retries": ep.max_retries,
"is_active": ep.is_active, "is_active": ep.is_active,
"custom_path": ep.custom_path, "custom_path": ep.custom_path,
@@ -855,7 +854,6 @@ class AdminExportConfigAdapter(AdminApiAdapter):
"provider_priority": provider.provider_priority, "provider_priority": provider.provider_priority,
"is_active": provider.is_active, "is_active": provider.is_active,
"concurrent_limit": provider.concurrent_limit, "concurrent_limit": provider.concurrent_limit,
"timeout": provider.timeout,
"max_retries": provider.max_retries, "max_retries": provider.max_retries,
"proxy": provider.proxy, "proxy": provider.proxy,
"config": provider.config, "config": provider.config,
@@ -1006,7 +1004,6 @@ class AdminImportConfigAdapter(AdminApiAdapter):
existing_provider.concurrent_limit = prov_data.get( existing_provider.concurrent_limit = prov_data.get(
"concurrent_limit" "concurrent_limit"
) )
existing_provider.timeout = prov_data.get("timeout", existing_provider.timeout)
existing_provider.max_retries = prov_data.get( existing_provider.max_retries = prov_data.get(
"max_retries", existing_provider.max_retries "max_retries", existing_provider.max_retries
) )
@@ -1031,7 +1028,6 @@ class AdminImportConfigAdapter(AdminApiAdapter):
provider_priority=prov_data.get("provider_priority", 100), provider_priority=prov_data.get("provider_priority", 100),
is_active=prov_data.get("is_active", True), is_active=prov_data.get("is_active", True),
concurrent_limit=prov_data.get("concurrent_limit"), concurrent_limit=prov_data.get("concurrent_limit"),
timeout=prov_data.get("timeout"),
max_retries=prov_data.get("max_retries"), max_retries=prov_data.get("max_retries"),
proxy=prov_data.get("proxy"), proxy=prov_data.get("proxy"),
config=prov_data.get("config"), config=prov_data.get("config"),
@@ -1064,7 +1060,6 @@ class AdminImportConfigAdapter(AdminApiAdapter):
"base_url", existing_ep.base_url "base_url", existing_ep.base_url
) )
existing_ep.header_rules = ep_data.get("header_rules") existing_ep.header_rules = ep_data.get("header_rules")
existing_ep.timeout = ep_data.get("timeout", 300)
existing_ep.max_retries = ep_data.get("max_retries", 2) existing_ep.max_retries = ep_data.get("max_retries", 2)
existing_ep.is_active = ep_data.get("is_active", True) existing_ep.is_active = ep_data.get("is_active", True)
existing_ep.custom_path = ep_data.get("custom_path") existing_ep.custom_path = ep_data.get("custom_path")
@@ -1079,7 +1074,6 @@ class AdminImportConfigAdapter(AdminApiAdapter):
api_format=ep_data["api_format"], api_format=ep_data["api_format"],
base_url=ep_data["base_url"], base_url=ep_data["base_url"],
header_rules=ep_data.get("header_rules"), header_rules=ep_data.get("header_rules"),
timeout=ep_data.get("timeout", 300),
max_retries=ep_data.get("max_retries", 2), max_retries=ep_data.get("max_retries", 2),
is_active=ep_data.get("is_active", True), is_active=ep_data.get("is_active", True),
custom_path=ep_data.get("custom_path"), custom_path=ep_data.get("custom_path"),

View File

@@ -20,6 +20,7 @@ Chat Handler Base - Chat API 格式的通用基类
""" """
import asyncio import asyncio
import json
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, AsyncGenerator, Callable, Dict, Optional from typing import Any, AsyncGenerator, Callable, Dict, Optional
@@ -476,7 +477,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 配置 HTTP 超时 # 配置 HTTP 超时
# 注意read timeout 用于检测连接断开,不是整体请求超时 # 注意read timeout 用于检测连接断开,不是整体请求超时
# 整体请求超时由 asyncio.wait_for 控制,使用 provider.timeout # 整体请求超时由 asyncio.wait_for 控制,使用全局配置
timeout_config = httpx.Timeout( timeout_config = httpx.Timeout(
connect=config.http_connect_timeout, connect=config.http_connect_timeout,
read=config.http_read_timeout, # 使用全局配置,用于检测连接断开 read=config.http_read_timeout, # 使用全局配置,用于检测连接断开
@@ -484,8 +485,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
pool=config.http_pool_timeout, pool=config.http_pool_timeout,
) )
# provider.timeout 作为整体请求超时(建立连接 + 获取首字节 # 流式请求使用 stream_first_byte_timeout 作为首字节超时
request_timeout = float(provider.timeout or 300) request_timeout = config.stream_first_byte_timeout
# 创建 HTTP 客户端(支持代理配置,从 Provider 读取) # 创建 HTTP 客户端(支持代理配置,从 Provider 读取)
from src.clients.http_client import HTTPClientPool from src.clients.http_client import HTTPClientPool
@@ -527,7 +528,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
try: try:
# 使用 asyncio.wait_for 包裹整个"建立连接 + 获取首字节"阶段 # 使用 asyncio.wait_for 包裹整个"建立连接 + 获取首字节"阶段
# provider.timeout 控制整体超时,避免上游长时间无响应 # stream_first_byte_timeout 控制首字节超时,避免上游长时间无响应
await asyncio.wait_for(_connect_and_prefetch(), timeout=request_timeout) await asyncio.wait_for(_connect_and_prefetch(), timeout=request_timeout)
except asyncio.TimeoutError: except asyncio.TimeoutError:
@@ -716,7 +717,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 注意:使用 get_proxy_client 复用连接池,不再每次创建新客户端 # 注意:使用 get_proxy_client 复用连接池,不再每次创建新客户端
from src.clients.http_client import HTTPClientPool from src.clients.http_client import HTTPClientPool
request_timeout = float(provider.timeout or 300) # 非流式请求使用 http_request_timeout 作为整体超时
request_timeout = config.http_request_timeout
http_client = await HTTPClientPool.get_proxy_client( http_client = await HTTPClientPool.get_proxy_client(
proxy_config=provider.proxy, proxy_config=provider.proxy,
) )
@@ -803,6 +805,26 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
upstream_status=resp.status_code, upstream_status=resp.status_code,
upstream_response=raw_content, upstream_response=raw_content,
) )
# 检查响应体中的嵌套错误HTTP 200 但响应体包含错误)
if isinstance(response_json, dict):
parser = get_parser_for_format(api_format)
if parser.is_error_response(response_json):
parsed = parser.parse_response(response_json, 200)
logger.warning(
f" [{self.request_id}] 非流式检测到嵌套错误: "
f"Provider={provider.name}, "
f"error_type={parsed.error_type}, "
f"embedded_status={parsed.embedded_status_code}, "
f"message={parsed.error_message}"
)
raise EmbeddedErrorException(
provider_name=str(provider.name),
error_code=parsed.embedded_status_code,
error_message=parsed.error_message,
error_status=parsed.error_type,
)
return response_json if isinstance(response_json, dict) else {} return response_json if isinstance(response_json, dict) else {}
try: try:

View File

@@ -490,8 +490,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
pool=config.http_pool_timeout, pool=config.http_pool_timeout,
) )
# provider.timeout 作为整体请求超时(建立连接 + 获取首字节 # 流式请求使用 stream_first_byte_timeout 作为首字节超时
request_timeout = float(provider.timeout or 300) request_timeout = config.stream_first_byte_timeout
logger.debug( logger.debug(
f" └─ [{self.request_id}] 发送流式请求: " f" └─ [{self.request_id}] 发送流式请求: "
@@ -539,7 +539,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
try: try:
# 使用 asyncio.wait_for 包裹整个"建立连接 + 获取首字节"阶段 # 使用 asyncio.wait_for 包裹整个"建立连接 + 获取首字节"阶段
# provider.timeout 控制整体超时,避免上游长时间无响应 # stream_first_byte_timeout 控制首字节超时,避免上游长时间无响应
await asyncio.wait_for(_connect_and_prefetch(), timeout=request_timeout) await asyncio.wait_for(_connect_and_prefetch(), timeout=request_timeout)
except asyncio.TimeoutError: except asyncio.TimeoutError:
@@ -1633,7 +1633,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 注意:使用 get_proxy_client 复用连接池,不再每次创建新客户端 # 注意:使用 get_proxy_client 复用连接池,不再每次创建新客户端
from src.clients.http_client import HTTPClientPool from src.clients.http_client import HTTPClientPool
request_timeout = float(provider.timeout or 300) # 非流式请求使用 http_request_timeout 作为整体超时
request_timeout = config.http_request_timeout
http_client = await HTTPClientPool.get_proxy_client( http_client = await HTTPClientPool.get_proxy_client(
proxy_config=provider.proxy, proxy_config=provider.proxy,
) )

View File

@@ -5,6 +5,7 @@
不再经过 Protocol 抽象层。 不再经过 Protocol 抽象层。
""" """
import re
from typing import Any, Dict, Optional, Tuple, Type from typing import Any, Dict, Optional, Tuple, Type
from src.api.handlers.base.response_parser import ( from src.api.handlers.base.response_parser import (
@@ -58,6 +59,71 @@ def _check_nested_error(response: Dict[str, Any]) -> Tuple[bool, Optional[Dict[s
return False, None return False, None
def _extract_embedded_status_code(error_info: Optional[Dict[str, Any]]) -> Optional[int]:
"""
从错误信息中提取嵌套的状态码
支持多种格式:
1. 直接的 code 字段: {"code": 400}
2. status 字段: {"status": 400}
3. 从 message 中正则提取: "Request failed with status code 400"
4. 从 type 字段映射: "invalid_request_error" -> 400
Args:
error_info: 错误信息字典
Returns:
提取的状态码,如果无法提取则返回 None
"""
if not error_info:
return None
# 1. 直接的 code 字段Gemini 等)
code = error_info.get("code")
if isinstance(code, int) and 100 <= code < 600:
return code
if isinstance(code, str) and code.isdigit():
code_int = int(code)
if 100 <= code_int < 600:
return code_int
# 2. status 字段
status = error_info.get("status")
if isinstance(status, int) and 100 <= status < 600:
return status
if isinstance(status, str) and status.isdigit():
status_int = int(status)
if 100 <= status_int < 600:
return status_int
# 3. 从 message 中正则提取 (例如 "Request failed with status code 400")
message = error_info.get("message", "")
if message:
# 匹配 "status code XXX" 或 "status XXX" 或 "HTTP XXX"
match = re.search(r"(?:status\s*(?:code\s*)?|HTTP\s*)(\d{3})", message, re.IGNORECASE)
if match:
code_int = int(match.group(1))
if 100 <= code_int < 600:
return code_int
# 4. 从 type 字段映射常见的错误类型
error_type = error_info.get("type", "")
type_to_status = {
"invalid_request_error": 400,
"authentication_error": 401,
"permission_error": 403,
"not_found_error": 404,
"rate_limit_error": 429,
"overloaded_error": 503,
"api_error": 500,
"internal_error": 500,
}
if error_type and error_type.lower() in type_to_status:
return type_to_status[error_type.lower()]
return None
class OpenAIResponseParser(ResponseParser): class OpenAIResponseParser(ResponseParser):
"""OpenAI 格式响应解析器""" """OpenAI 格式响应解析器"""
@@ -141,6 +207,7 @@ class OpenAIResponseParser(ResponseParser):
result.is_error = True result.is_error = True
result.error_type = error_info.get("type") result.error_type = error_info.get("type")
result.error_message = error_info.get("message") result.error_message = error_info.get("message")
result.embedded_status_code = _extract_embedded_status_code(error_info)
return result return result
@@ -273,6 +340,7 @@ class ClaudeResponseParser(ResponseParser):
result.is_error = True result.is_error = True
result.error_type = error_info.get("type") result.error_type = error_info.get("type")
result.error_message = error_info.get("message") result.error_message = error_info.get("message")
result.embedded_status_code = _extract_embedded_status_code(error_info)
return result return result
@@ -417,6 +485,7 @@ class GeminiResponseParser(ResponseParser):
result.is_error = True result.is_error = True
result.error_type = error_info.get("status") result.error_type = error_info.get("status")
result.error_message = error_info.get("message") result.error_message = error_info.get("message")
result.embedded_status_code = _extract_embedded_status_code(error_info)
return result return result

View File

@@ -87,6 +87,8 @@ class ParsedResponse:
is_error: bool = False is_error: bool = False
error_type: Optional[str] = None error_type: Optional[str] = None
error_message: Optional[str] = None error_message: Optional[str] = None
# 从响应体解析出的嵌套状态码(当 HTTP 200 但响应体含错误时使用)
embedded_status_code: Optional[int] = None
class ResponseParser(ABC): class ResponseParser(ABC):

View File

@@ -286,15 +286,12 @@ class StreamProcessor:
f" [{self.request_id}] 检测到嵌套错误: " f" [{self.request_id}] 检测到嵌套错误: "
f"Provider={provider.name}, " f"Provider={provider.name}, "
f"error_type={parsed.error_type}, " f"error_type={parsed.error_type}, "
f"embedded_status={parsed.embedded_status_code}, "
f"message={parsed.error_message}" f"message={parsed.error_message}"
) )
raise EmbeddedErrorException( raise EmbeddedErrorException(
provider_name=str(provider.name), provider_name=str(provider.name),
error_code=( error_code=parsed.embedded_status_code,
int(parsed.error_type)
if parsed.error_type and parsed.error_type.isdigit()
else None
),
error_message=parsed.error_message, error_message=parsed.error_message,
error_status=parsed.error_type, error_status=parsed.error_type,
) )

View File

@@ -186,15 +186,12 @@ def check_prefetched_response_error(
f" [{request_id}] 检测到 JSON 错误响应: " f" [{request_id}] 检测到 JSON 错误响应: "
f"Provider={provider_name}, " f"Provider={provider_name}, "
f"error_type={parsed.error_type}, " f"error_type={parsed.error_type}, "
f"embedded_status={parsed.embedded_status_code}, "
f"message={parsed.error_message}" f"message={parsed.error_message}"
) )
raise EmbeddedErrorException( raise EmbeddedErrorException(
provider_name=provider_name, provider_name=provider_name,
error_code=( error_code=parsed.embedded_status_code,
int(parsed.error_type)
if parsed.error_type and parsed.error_type.isdigit()
else None
),
error_message=parsed.error_message, error_message=parsed.error_message,
error_status=parsed.error_type, error_status=parsed.error_type,
) )

View File

@@ -214,16 +214,14 @@ class TimeoutDefaults:
"""超时配置默认值(秒) """超时配置默认值(秒)
超时配置说明: 超时配置说明:
- 全局默认值和 Provider 默认值统一为 120 秒 - 非流式请求超时由环境变量 HTTP_REQUEST_TIMEOUT 控制(默认 300 秒
- 120 秒是 LLM API 的合理默认值: - 流式请求首字节超时由环境变量 STREAM_FIRST_BYTE_TIMEOUT 控制(默认 30 秒)
* 大多数请求在 30 秒内完成 - 此处的常量仅用于无法访问 config 的场景(如模型查询测试)
* 复杂推理(如 Claude extended thinking可能需要 60-90 秒
* 120 秒足够覆盖大部分场景,同时避免线程池被长时间占用
- 如需更长超时,可在 Provider 级别单独配置
""" """
# HTTP 请求默认超时(与 Provider 默认值保持一致 # HTTP 请求默认超时(用于模型查询等测试场景
HTTP_REQUEST = 120 # 2分钟 # 与 config.http_request_timeout 默认值保持一致
HTTP_REQUEST = 300 # 5分钟
# 数据库连接池获取超时 # 数据库连接池获取超时
DB_POOL = 30 DB_POOL = 30

View File

@@ -144,6 +144,8 @@ class Config:
self.http_read_timeout = float(os.getenv("HTTP_READ_TIMEOUT", "60.0")) self.http_read_timeout = float(os.getenv("HTTP_READ_TIMEOUT", "60.0"))
self.http_write_timeout = float(os.getenv("HTTP_WRITE_TIMEOUT", "60.0")) self.http_write_timeout = float(os.getenv("HTTP_WRITE_TIMEOUT", "60.0"))
self.http_pool_timeout = float(os.getenv("HTTP_POOL_TIMEOUT", "10.0")) self.http_pool_timeout = float(os.getenv("HTTP_POOL_TIMEOUT", "10.0"))
# HTTP_REQUEST_TIMEOUT: 非流式请求整体超时(秒),默认 300 秒
self.http_request_timeout = float(os.getenv("HTTP_REQUEST_TIMEOUT", "300.0"))
# HTTP 连接池配置 # HTTP 连接池配置
# HTTP_MAX_CONNECTIONS: 最大连接数,影响并发能力 # HTTP_MAX_CONNECTIONS: 最大连接数,影响并发能力
@@ -167,10 +169,9 @@ class Config:
# STREAM_PREFETCH_LINES: 预读行数,用于检测嵌套错误 # STREAM_PREFETCH_LINES: 预读行数,用于检测嵌套错误
# STREAM_STATS_DELAY: 统计记录延迟(秒),等待流完全关闭 # STREAM_STATS_DELAY: 统计记录延迟(秒),等待流完全关闭
# STREAM_FIRST_BYTE_TIMEOUT: 首字节超时(秒),等待首字节超过此时间触发故障转移 # STREAM_FIRST_BYTE_TIMEOUT: 首字节超时(秒),等待首字节超过此时间触发故障转移
# 范围: 10-120 秒,默认 30 秒(必须小于 http_write_timeout 避免竞态)
self.stream_prefetch_lines = int(os.getenv("STREAM_PREFETCH_LINES", "5")) self.stream_prefetch_lines = int(os.getenv("STREAM_PREFETCH_LINES", "5"))
self.stream_stats_delay = float(os.getenv("STREAM_STATS_DELAY", "0.1")) self.stream_stats_delay = float(os.getenv("STREAM_STATS_DELAY", "0.1"))
self.stream_first_byte_timeout = self._parse_ttfb_timeout() self.stream_first_byte_timeout = float(os.getenv("STREAM_FIRST_BYTE_TIMEOUT", "30.0"))
# 请求体读取超时(秒) # 请求体读取超时(秒)
# REQUEST_BODY_TIMEOUT: 等待客户端发送完整请求体的超时时间 # REQUEST_BODY_TIMEOUT: 等待客户端发送完整请求体的超时时间
@@ -289,39 +290,6 @@ class Config:
# 最小 10 个保活连接,最大不超过 max_connections # 最小 10 个保活连接,最大不超过 max_connections
return max(10, min(keepalive, self.http_max_connections)) return max(10, min(keepalive, self.http_max_connections))
def _parse_ttfb_timeout(self) -> float:
"""
解析 TTFB 超时配置,带错误处理和范围限制
TTFB (Time To First Byte) 用于检测慢响应的 Provider超时触发故障转移。
此值必须小于 http_write_timeout避免竞态条件。
Returns:
超时时间(秒),范围 10-120默认 30
"""
default_timeout = 30.0
min_timeout = 10.0
max_timeout = 120.0 # 必须小于 http_write_timeout (默认 60s) 的 2 倍
raw_value = os.getenv("STREAM_FIRST_BYTE_TIMEOUT", str(default_timeout))
try:
timeout = float(raw_value)
except ValueError:
# 延迟导入避免循环依赖Config 初始化时 logger 可能未就绪)
self._ttfb_config_warning = (
f"无效的 STREAM_FIRST_BYTE_TIMEOUT 配置 '{raw_value}',使用默认值 {default_timeout}"
)
return default_timeout
# 范围限制
clamped = max(min_timeout, min(max_timeout, timeout))
if clamped != timeout:
self._ttfb_config_warning = (
f"STREAM_FIRST_BYTE_TIMEOUT={timeout}秒超出范围 [{min_timeout}-{max_timeout}]"
f"已调整为 {clamped}"
)
return clamped
def _validate_pool_config(self) -> None: def _validate_pool_config(self) -> None:
"""验证连接池配置是否安全""" """验证连接池配置是否安全"""
total_per_worker = self.db_pool_size + self.db_max_overflow total_per_worker = self.db_pool_size + self.db_max_overflow
@@ -369,10 +337,6 @@ class Config:
if hasattr(self, "_pool_config_warning") and self._pool_config_warning: if hasattr(self, "_pool_config_warning") and self._pool_config_warning:
logger.warning(self._pool_config_warning) logger.warning(self._pool_config_warning)
# TTFB 超时配置警告
if hasattr(self, "_ttfb_config_warning") and self._ttfb_config_warning:
logger.warning(self._ttfb_config_warning)
# 管理员密码检查(必须在环境变量中设置) # 管理员密码检查(必须在环境变量中设置)
if hasattr(self, "_missing_admin_password") and self._missing_admin_password: if hasattr(self, "_missing_admin_password") and self._missing_admin_password:
logger.error("必须设置 ADMIN_PASSWORD 环境变量!") logger.error("必须设置 ADMIN_PASSWORD 环境变量!")

View File

@@ -88,7 +88,6 @@ class CreateProviderRequest(BaseModel):
is_active: Optional[bool] = Field(True, description="是否启用") is_active: Optional[bool] = Field(True, description="是否启用")
concurrent_limit: Optional[int] = Field(None, ge=0, description="并发限制") concurrent_limit: Optional[int] = Field(None, ge=0, description="并发限制")
# 请求配置(从 Endpoint 迁移) # 请求配置(从 Endpoint 迁移)
timeout: Optional[int] = Field(300, ge=1, le=600, description="请求超时(秒)")
max_retries: Optional[int] = Field(2, ge=0, le=10, description="最大重试次数") max_retries: Optional[int] = Field(2, ge=0, le=10, description="最大重试次数")
proxy: Optional[ProxyConfig] = Field(None, description="代理配置") proxy: Optional[ProxyConfig] = Field(None, description="代理配置")
config: Optional[Dict[str, Any]] = Field(None, description="其他配置") config: Optional[Dict[str, Any]] = Field(None, description="其他配置")
@@ -159,7 +158,6 @@ class UpdateProviderRequest(BaseModel):
is_active: Optional[bool] = None is_active: Optional[bool] = None
concurrent_limit: Optional[int] = Field(None, ge=0) concurrent_limit: Optional[int] = Field(None, ge=0)
# 请求配置(从 Endpoint 迁移) # 请求配置(从 Endpoint 迁移)
timeout: Optional[int] = Field(None, ge=1, le=600, description="请求超时(秒)")
max_retries: Optional[int] = Field(None, ge=0, le=10, description="最大重试次数") max_retries: Optional[int] = Field(None, ge=0, le=10, description="最大重试次数")
proxy: Optional[ProxyConfig] = Field(None, description="代理配置") proxy: Optional[ProxyConfig] = Field(None, description="代理配置")
config: Optional[Dict[str, Any]] = None config: Optional[Dict[str, Any]] = None

View File

@@ -403,7 +403,6 @@ class ProviderUpdate(BaseModel):
api_format: Optional[str] = None api_format: Optional[str] = None
base_url: Optional[str] = None base_url: Optional[str] = None
headers: Optional[dict] = None headers: Optional[dict] = None
timeout: Optional[int] = Field(None, ge=1, le=600)
max_retries: Optional[int] = Field(None, ge=0, le=10) max_retries: Optional[int] = Field(None, ge=0, le=10)
priority: Optional[int] = None priority: Optional[int] = None
weight: Optional[float] = Field(None, gt=0) weight: Optional[float] = Field(None, gt=0)
@@ -423,7 +422,6 @@ class ProviderResponse(BaseModel):
api_format: str api_format: str
base_url: str base_url: str
headers: Optional[dict] headers: Optional[dict]
timeout: int
max_retries: int max_retries: int
priority: int priority: int
weight: float weight: float

View File

@@ -552,11 +552,10 @@ class Provider(Base):
concurrent_limit = Column(Integer, nullable=True) # 并发请求限制 concurrent_limit = Column(Integer, nullable=True) # 并发请求限制
# 请求配置(从 Endpoint 迁移,作为全局默认值) # 请求配置(从 Endpoint 迁移,作为全局默认值)
# 超时 300 秒对于 LLM API 是合理的默认值 # [已废弃] timeout 字段不再使用,超时由环境变量控制
# - 大多数请求在 30 秒内完成 # - 非流式请求: HTTP_REQUEST_TIMEOUT默认 300
# - 复杂推理(如 Claude thinking可能需要 60-120 秒 # - 流式首字节: STREAM_FIRST_BYTE_TIMEOUT默认 30 秒
# - 300 秒足够覆盖极端场景(如超长上下文、复杂工具调用 timeout = Column(Integer, default=300, nullable=True) # [已废弃] 请求超时(秒
timeout = Column(Integer, default=300, nullable=True) # 请求超时(秒)
max_retries = Column(Integer, default=2, nullable=True) # 最大重试次数 max_retries = Column(Integer, default=2, nullable=True) # 最大重试次数
proxy = Column(JSONB, nullable=True) # 代理配置: {url, username, password, enabled} proxy = Column(JSONB, nullable=True) # 代理配置: {url, username, password, enabled}
@@ -604,7 +603,7 @@ class ProviderEndpoint(Base):
# 请求配置 # 请求配置
header_rules = Column(JSON, nullable=True) # 请求头规则 [{action, key, value, from, to}] header_rules = Column(JSON, nullable=True) # 请求头规则 [{action, key, value, from, to}]
timeout = Column(Integer, default=300) # 超时(秒) timeout = Column(Integer, default=300) # [已废弃] 超时(秒),由环境变量控制
max_retries = Column(Integer, default=2) # 最大重试次数 max_retries = Column(Integer, default=2) # 最大重试次数
# 状态 # 状态

View File

@@ -37,7 +37,6 @@ class ProviderEndpointCreate(BaseModel):
description="请求头规则列表,支持 set/drop/rename 操作", description="请求头规则列表,支持 set/drop/rename 操作",
) )
timeout: int = Field(default=300, ge=10, le=600, description="超时时间(秒)")
max_retries: int = Field(default=2, ge=0, le=10, description="最大重试次数") max_retries: int = Field(default=2, ge=0, le=10, description="最大重试次数")
# 额外配置 # 额外配置
@@ -81,7 +80,6 @@ class ProviderEndpointUpdate(BaseModel):
description="请求头规则列表,支持 set/drop/rename 操作", description="请求头规则列表,支持 set/drop/rename 操作",
) )
timeout: Optional[int] = Field(default=None, ge=10, le=600, description="超时时间(秒)")
max_retries: Optional[int] = Field(default=None, ge=0, le=10, description="最大重试次数") max_retries: Optional[int] = Field(default=None, ge=0, le=10, description="最大重试次数")
is_active: Optional[bool] = Field(default=None, description="是否启用") is_active: Optional[bool] = Field(default=None, description="是否启用")
config: Optional[Dict[str, Any]] = Field(default=None, description="额外配置") config: Optional[Dict[str, Any]] = Field(default=None, description="额外配置")
@@ -117,7 +115,6 @@ class ProviderEndpointResponse(BaseModel):
default=None, description="请求头规则列表" default=None, description="请求头规则列表"
) )
timeout: int
max_retries: int max_retries: int
# 状态 # 状态
@@ -619,7 +616,6 @@ class ProviderWithEndpointsSummary(BaseModel):
quota_expires_at: Optional[datetime] = Field(default=None, description="配额过期时间") quota_expires_at: Optional[datetime] = Field(default=None, description="配额过期时间")
# 请求配置(从 Endpoint 迁移) # 请求配置(从 Endpoint 迁移)
timeout: Optional[int] = Field(default=300, description="请求超时(秒)")
max_retries: Optional[int] = Field(default=2, description="最大重试次数") max_retries: Optional[int] = Field(default=2, description="最大重试次数")
proxy: Optional[Dict[str, Any]] = Field(default=None, description="代理配置") proxy: Optional[Dict[str, Any]] = Field(default=None, description="代理配置")

View File

@@ -388,7 +388,7 @@ class AuthService:
if key_record.is_locked: if key_record.is_locked:
logger.warning("API认证失败 - 密钥已被管理员锁定") logger.warning("API认证失败 - 密钥已被管理员锁定")
raise ForbiddenException("API密钥已被管理员锁定,请联系管理员") raise ForbiddenException("该密钥已被管理员锁定,请联系管理员")
# 检查过期时间 # 检查过期时间
if key_record.expires_at: if key_record.expires_at:

View File

@@ -358,6 +358,8 @@ class FallbackOrchestrator:
# 需要检查错误消息是否为客户端错误(如 prompt is too long这类错误不应重试 # 需要检查错误消息是否为客户端错误(如 prompt is too long这类错误不应重试
if isinstance(cause, EmbeddedErrorException): if isinstance(cause, EmbeddedErrorException):
error_message = cause.error_message or "" error_message = cause.error_message or ""
# 使用嵌入式状态码(如果有),否则默认 200
embedded_status = cause.error_code or 200
if self._error_classifier.is_client_error(error_message): if self._error_classifier.is_client_error(error_message):
logger.warning( logger.warning(
f" [{request_id}] 嵌入式客户端错误,停止重试: {error_message[:200]}" f" [{request_id}] 嵌入式客户端错误,停止重试: {error_message[:200]}"
@@ -366,7 +368,7 @@ class FallbackOrchestrator:
client_error = UpstreamClientException( client_error = UpstreamClientException(
message=error_message or "请求无效", message=error_message or "请求无效",
provider_name=str(provider.name), provider_name=str(provider.name),
status_code=200, # 嵌入式错误的 HTTP 状态码通常是 200 status_code=embedded_status,
upstream_error=error_message, upstream_error=error_message,
) )
RequestCandidateService.mark_candidate_failed( RequestCandidateService.mark_candidate_failed(
@@ -374,7 +376,7 @@ class FallbackOrchestrator:
candidate_id=candidate_record_id, candidate_id=candidate_record_id,
error_type="UpstreamClientException", error_type="UpstreamClientException",
error_message=error_message, error_message=error_message,
status_code=200, status_code=embedded_status,
latency_ms=elapsed_ms, latency_ms=elapsed_ms,
concurrent_requests=captured_key_concurrent, concurrent_requests=captured_key_concurrent,
) )
@@ -396,7 +398,7 @@ class FallbackOrchestrator:
candidate_id=candidate_record_id, candidate_id=candidate_record_id,
error_type="EmbeddedErrorException", error_type="EmbeddedErrorException",
error_message=error_message, error_message=error_message,
status_code=200, status_code=embedded_status,
latency_ms=elapsed_ms, latency_ms=elapsed_ms,
concurrent_requests=captured_key_concurrent, concurrent_requests=captured_key_concurrent,
) )

View File

@@ -1703,11 +1703,9 @@ class UsageService:
Returns: Returns:
请求状态列表 请求状态列表
""" """
from src.models.database import ProviderEndpoint
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
# 构建基础查询,包含端点的 timeout 配置 # 构建基础查询
query = db.query( query = db.query(
Usage.id, Usage.id,
Usage.status, Usage.status,
@@ -1722,8 +1720,7 @@ class UsageService:
Usage.first_byte_time_ms, # 首字时间 (TTFB) Usage.first_byte_time_ms, # 首字时间 (TTFB)
Usage.created_at, Usage.created_at,
Usage.provider_endpoint_id, Usage.provider_endpoint_id,
ProviderEndpoint.timeout.label("endpoint_timeout"), )
).outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
# 管理员轮询:可附带 provider 与上游 key 名称(注意:不要在普通用户接口暴露上游 key 信息) # 管理员轮询:可附带 provider 与上游 key 名称(注意:不要在普通用户接口暴露上游 key 信息)
if include_admin_fields: if include_admin_fields:
@@ -1751,8 +1748,8 @@ class UsageService:
timeout_ids = [] timeout_ids = []
for r in records: for r in records:
if r.status in ("pending", "streaming") and r.created_at: if r.status in ("pending", "streaming") and r.created_at:
# 使用端点配置的超时时间,若无则使用默认值 # 使用全局配置的超时时间
timeout_seconds = r.endpoint_timeout or default_timeout_seconds timeout_seconds = default_timeout_seconds
# 处理时区:如果 created_at 没有时区信息,假定为 UTC # 处理时区:如果 created_at 没有时区信息,假定为 UTC
created_at = r.created_at created_at = r.created_at