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

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

View File

@@ -202,7 +202,9 @@ async def clear_oauth_invalid(
key.is_active = True
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 已自动启用"}
@@ -1256,7 +1258,6 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
import httpx
from src.api.handlers.base.request_builder import get_provider_auth
from src.utils.ssl_utils import get_ssl_context
db = context.db
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":
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 获取限额信息
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)
if response.status_code != 200:
@@ -1421,13 +1435,19 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
from src.services.provider.adapters.antigravity.client import (
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(
provider_type="antigravity",
api_key_value=access_token,
# antigravity fetcher 不依赖 endpoint mapping
format_to_endpoint={},
proxy_config=getattr(provider, "proxy", None),
proxy_config=effective_proxy,
auth_config=auth_info.decrypted_auth_config,
)
@@ -1530,8 +1550,13 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
"message": "无法解密 auth_config可能是加密密钥已更改",
}
# 获取代理配置
proxy_config = getattr(provider, "proxy", None)
# 获取代理配置key 级别 > provider 级别)
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
try:
@@ -1578,7 +1603,9 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
key.oauth_invalid_reason = "Kiro Token 无效或已过期"
key.is_active = False
db.commit()
logger.warning("[KIRO_QUOTA] Key {} Token 无效,已标记为异常并自动停用", key.id)
logger.warning(
"[KIRO_QUOTA] Key {} Token 无效,已标记为异常并自动停用", key.id
)
return {
"key_id": key.id,
"key_name": key.name,

View File

@@ -36,8 +36,8 @@ from src.services.model.upstream_fetcher import (
get_adapter_for_format,
)
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.ssl_utils import get_ssl_context
router = APIRouter(prefix="/api/admin/provider-query", tags=["Provider Query"])
@@ -110,9 +110,15 @@ class _KeyAuthError(Exception):
async def _resolve_key_auth(
api_key: Any,
provider: Any,
provider_proxy_config: dict[str, Any] | None = None,
) -> tuple[str, dict[str, Any] | None]:
"""统一解析 Key 的 api_key_value 和 auth_config。
Args:
api_key: ProviderAPIKey 对象
provider: Provider 对象
provider_proxy_config: 已解析的有效代理配置key > provider 级别)
Returns:
(api_key_value, auth_config)
@@ -135,7 +141,7 @@ async def _resolve_key_auth(
if getattr(api_key, "auth_config", None) is not None
else None
),
provider_proxy_config=getattr(provider, "proxy", None),
provider_proxy_config=provider_proxy_config,
endpoint_api_format=endpoint_api_format,
)
api_key_value = resolved.access_token
@@ -267,7 +273,12 @@ async def query_available_models(
# 缓存未命中或强制刷新,实时获取
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:
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 ""),
api_key_value=str(api_key_value or ""),
format_to_endpoint=format_to_endpoint,
proxy_config=getattr(provider, "proxy", None),
proxy_config=effective_proxy,
auth_config=auth_config,
)
models, errors, has_success, _meta = await fetch_models_for_key(
@@ -433,7 +444,12 @@ async def _fetch_models_antigravity_ordered(
# 实时获取
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:
all_errors.append(f"Key {key_label}: {e.message}")
continue
@@ -442,7 +458,7 @@ async def _fetch_models_antigravity_ordered(
provider_type=str(getattr(provider, "provider_type", "") or ""),
api_key_value=str(api_key_value or ""),
format_to_endpoint=format_to_endpoint,
proxy_config=getattr(provider, "proxy", None),
proxy_config=effective_proxy,
auth_config=auth_config,
)
models, errors, has_success, _meta = await fetch_models_for_key(
@@ -528,7 +544,12 @@ async def _fetch_models_for_single_key(
# 缓存未命中或强制刷新,实时获取
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:
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 ""),
api_key_value=str(api_key_value or ""),
format_to_endpoint=format_to_endpoint,
proxy_config=getattr(provider, "proxy", None),
proxy_config=effective_proxy,
auth_config=auth_config,
)
all_models, errors, has_success, _meta = await fetch_models_for_key(
@@ -703,7 +724,9 @@ async def test_model(
encrypted_auth_config=(
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 ""),
)
api_key_value = resolved.access_token
@@ -780,186 +803,187 @@ async def test_model(
if header_rules:
logger.debug(f"[test-model] 将传递 header_rules 给 check_endpoint: {header_rules}")
# 发送测试请求
async with httpx.AsyncClient(
timeout=endpoint_config["timeout"], verify=get_ssl_context()
) as client:
logger.debug("[test-model] 开始端点测试...")
# 发送测试请求(使用代理配置)
test_proxy = resolve_effective_proxy(
getattr(provider, "proxy", None), getattr(api_key, "proxy", None)
)
test_proxy_param = resolve_proxy_param(test_proxy)
# Provider 上下文auth_type 用于 OAuth 认证头处理provider_type 用于特殊路由
p_type = str(getattr(provider, "provider_type", "") or "").lower()
logger.debug("[test-model] 开始端点测试...")
async def _do_check(req: dict) -> dict:
return await adapter_class.check_endpoint(
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,
)
# Provider 上下文auth_type 用于 OAuth 认证头处理provider_type 用于特殊路由
p_type = str(getattr(provider, "provider_type", "") or "").lower()
def _response_has_error(resp: dict) -> bool:
"""快速判断响应是否包含错误"""
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
async def _do_check(req: dict) -> dict:
return await adapter_class.check_endpoint(
None, # client 参数已不被 run_endpoint_check 使用
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,
proxy_param=test_proxy_param,
)
# 策略:优先流式,若失败回退到非流式
used_stream = True
logger.debug("[test-model] 尝试流式请求...")
def _response_has_error(resp: dict) -> bool:
"""快速判断响应是否包含错误"""
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)
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)
# 记录提供商返回信息
logger.debug("[test-model] 端点测试结果:")
logger.debug(f"[test-model] Status Code: {response.get('status_code')}")
logger.debug(f"[test-model] Response Headers: {response.get('headers', {})}")
response_data = response.get("response", {})
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
# 记录提供商返回信息
logger.debug("[test-model] 端点测试结果:")
logger.debug(f"[test-model] Status Code: {response.get('status_code')}")
logger.debug(f"[test-model] Response Headers: {response.get('headers', {})}")
response_data = response.get("response", {})
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):
try:
parsed_body = json.loads(response_body)
except json.JSONDecodeError:
pass
if isinstance(response_body, str):
try:
parsed_body = json.loads(response_body)
except json.JSONDecodeError:
pass
if isinstance(parsed_body, dict) and "error" in parsed_body:
error_obj = parsed_body["error"]
# 兼容 error 可能是字典或字符串的情况
if isinstance(error_obj, dict):
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:
error_obj = parsed_body["error"]
# 兼容 error 可能是字典或字符串的情况
if isinstance(error_obj, dict):
error_message = error_obj.get("message", "")
logger.debug(f"[test-model] Error Message: {error_message}")
# Antigravity 403 "verify your account" → 标记账号异常
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",
# Antigravity 403 "verify your account" → 标记账号异常
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()
)
else:
logger.debug(f"[test-model] Error: {error_obj}")
# error_obj 可能是字符串,截断以避免泄露过多上游信息
raise HTTPException(
status_code=500,
detail=str(error_obj)[:500] if error_obj else "Provider error",
):
from datetime import datetime, timezone
from src.services.provider.oauth_token import (
OAUTH_ACCOUNT_BLOCK_PREFIX,
)
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(
status_code=500,
detail=str(response["error"])[:500],
detail=str(error_message)[:500] if error_message else "Provider error",
)
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]}...")
elif "content" in response_data and response_data["content"]:
content = str(response_data["content"])
logger.debug(f"[test-model] Error: {error_obj}")
# error_obj 可能是字符串,截断以避免泄露过多上游信息
raise HTTPException(
status_code=500,
detail=str(error_obj)[:500] if error_obj else "Provider error",
)
elif "error" in response:
logger.debug(f"[test-model] Error: {response['error']}")
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]}...")
elif "content" in response_data and response_data["content"]:
content = str(response_data["content"])
logger.debug(f"[test-model] Content Preview: {content[:200]}...")
# 检查测试是否成功基于HTTP状态码
status_code = response.get("status_code", 0)
is_success = status_code == 200 and "error" not in response
# 检查测试是否成功基于HTTP状态码
status_code = response.get("status_code", 0)
is_success = status_code == 200 and "error" not in response
return {
"success": is_success,
"data": {
"stream": used_stream,
"response": response,
},
"provider": {
"id": provider.id,
"name": provider.name,
},
"model": request.model_name,
"endpoint": {
"id": endpoint.id,
"api_format": endpoint.api_format,
"base_url": endpoint.base_url,
},
}
return {
"success": is_success,
"data": {
"stream": used_stream,
"response": response,
},
"provider": {
"id": provider.id,
"name": provider.name,
},
"model": request.model_name,
"endpoint": {
"id": endpoint.id,
"api_format": endpoint.api_format,
"base_url": endpoint.base_url,
},
}
except Exception as e:
logger.error(f"[test-model] Error testing model {request.model_name}: {e}")

View File

@@ -1475,6 +1475,7 @@ async def _resolve_provider_auth(
if auth_type == "oauth":
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_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
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,
)
access_token = resolved.access_token or ""
@@ -1784,12 +1792,25 @@ class AdminUsageReplayAdapter(AdminApiAdapter):
# 发送请求
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()
async with httpx.AsyncClient(
timeout=60.0,
verify=get_ssl_context(),
**build_proxy_client_kwargs(eff_proxy, timeout=60.0)
) as client:
response = await client.post(
url,

View File

@@ -638,6 +638,8 @@ class ChatAdapterBase(ApiAdapter):
auth_type: str | None = None, # noqa: ARG003
provider_type: str | 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]:
"""
测试模型连接性(非流式)
@@ -700,6 +702,7 @@ class ChatAdapterBase(ApiAdapter):
provider_id=provider_id,
api_key_id=api_key_id,
model_name=model_name or request_data.get("model"),
proxy_param=proxy_param,
)

View File

@@ -1130,27 +1130,18 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
return _streamified()
# 配置 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 作为首字节超时
# 优先使用 Provider 配置,否则使用全局配置
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.services.proxy_node.resolver import build_stream_kwargs, resolve_delegate_config
delegate_cfg = resolve_delegate_config(effective_proxy)
http_client = HTTPClientPool.create_upstream_stream_client(
delegate_cfg, proxy_config=effective_proxy, timeout=timeout_config
http_client = await HTTPClientPool.get_upstream_client(
delegate_cfg, proxy_config=effective_proxy
)
# 用于存储内部函数的结果(必须在函数定义前声明,供 nonlocal 使用)
@@ -1214,13 +1205,12 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
break
except ClientDisconnectedException:
# 客户端断开连接,清理资源
# 客户端断开连接,清理响应上下文(不关闭池中复用的客户端)
if response_ctx is not None:
try:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
logger.warning(f" [{self.request_id}] 客户端在等待首字节时断开连接")
ctx.status_code = 499
ctx.error_message = "client_disconnected_during_prefetch"
@@ -1228,13 +1218,12 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
except TimeoutError:
# 整体请求超时(建立连接 + 获取首字节)
# 清理可能已建立的连接上下文
# 清理可能已建立的连接上下文(不关闭池中复用的客户端)
if response_ctx is not None:
try:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
logger.warning(
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:
# 连接/读写超时:清理可能已建立的连接上下文
# 连接/读写超时:清理可能已建立的连接上下文(不关闭池中复用的客户端)
if response_ctx is not None:
try:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
if ctx.selected_base_url:
@@ -1289,7 +1277,6 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
logger.error(
f"Provider 返回错误: {e.response.status_code}\n Response: {error_text}"
)
await http_client.aclose()
# 将上游错误信息附加到异常,以便故障转移时能够返回给客户端
e.upstream_response = error_text # type: ignore[attr-defined]
raise
@@ -1300,11 +1287,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
raise
except Exception:
await http_client.aclose()
raise
# 类型断言:成功执行后这些变量不会为 None
@@ -1317,7 +1302,6 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
ctx,
byte_iterator,
response_ctx,
http_client,
prefetched_chunks,
start_time=self.start_time,
)

View File

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

View File

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

View File

@@ -73,6 +73,7 @@ async def run_endpoint_check(
provider_id: str | None = None,
db: Any | None = None, # Session对象需要时才导入
user: Any | None = None, # User对象
proxy_param: Any | None = None, # httpx 可接受的代理参数
) -> dict[str, Any]:
"""
执行端点检查(重构版本,使用新的架构):
@@ -94,6 +95,7 @@ async def run_endpoint_check(
db=db,
user=user,
request_id=str(uuid.uuid4())[:8],
proxy_param=proxy_param,
)
# 使用协调器执行检查
@@ -565,6 +567,7 @@ class EndpointCheckRequest:
user: Any | None = None
request_id: str | None = None
timeout: float = 30.0
proxy_param: Any | None = None # httpx 可接受的代理参数
@dataclass
@@ -595,7 +598,21 @@ class HttpRequestExecutor:
is_stream = request.json_body.get("stream", False) if request.json_body else False
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:
# 流式请求:读取 SSE 事件直到完成
response_data = await self._execute_stream_request(client, request)

View File

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

View File

@@ -269,6 +269,8 @@ class GeminiChatAdapter(ChatAdapterBase):
auth_type: str | None = None,
provider_type: str | None = None,
decrypted_auth_config: dict[str, Any] | None = None,
# 代理参数(已解析,直接传递给 run_endpoint_check
proxy_param: Any | None = None,
) -> dict[str, Any]:
"""测试 Gemini API 模型连接性(非流式)"""
from src.api.handlers.base.endpoint_checker import run_endpoint_check
@@ -363,6 +365,7 @@ class GeminiChatAdapter(ChatAdapterBase):
provider_id=provider_id,
api_key_id=api_key_id,
model_name=effective_model_name,
proxy_param=proxy_param,
)

View File

@@ -449,9 +449,25 @@ class GeminiVeoHandler(VideoHandlerBase):
import httpx
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 跟随重定向
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:
response = await client.get(task.video_url, headers=download_headers)
except Exception as exc:

View File

@@ -778,7 +778,11 @@ async def download_file(
# 使用 follow_redirects=True 跟随重定向Gemini 文件下载会重定向)
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)
except Exception as exc:
logger.error("Gemini Files download failed: {}", exc)

View File

@@ -10,7 +10,6 @@ from __future__ import annotations
import json
import time
from collections import OrderedDict
from typing import Optional, Tuple
import httpx
import jwt
@@ -129,7 +128,11 @@ class VertexAuthService:
# 获取新 Token
try:
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(
self.TOKEN_URL,
data={

View File

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

View File

@@ -24,7 +24,17 @@ from src.services.auth.oauth.state import consume_oauth_state, create_oauth_stat
from src.services.auth.service import AuthService
from src.services.cache.user_cache import UserCacheService
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:
@@ -835,7 +845,7 @@ class OAuthService:
async def _reachable(url: str) -> bool:
try:
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:
await client.get(url)
return True
@@ -851,9 +861,7 @@ class OAuthService:
if has_secret and client_secret:
# 使用无效 code 做一次 token 请求(仅做粗略判定)
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(5.0), verify=get_ssl_context()
) as client:
async with httpx.AsyncClient(**_build_oauth_client_kwargs(5.0)) as client:
resp = await client.post(
token_url,
data={
@@ -914,7 +922,7 @@ class OAuthService:
async def _reachable(url: str) -> bool:
try:
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:
await client.get(url)
return True
@@ -930,9 +938,7 @@ class OAuthService:
if client_secret:
# 使用无效 code 做一次 token 请求(仅做粗略判定)
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(5.0), verify=get_ssl_context()
) as client:
async with httpx.AsyncClient(**_build_oauth_client_kwargs(5.0)) as client:
resp = await client.post(
token_url,
data={

View File

@@ -35,6 +35,7 @@ from src.services.model.upstream_fetcher import (
merge_upstream_metadata,
)
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
# 从环境变量读取间隔,默认 1440 分钟1 天),限制在 60-10080 分钟之间
@@ -448,7 +449,9 @@ class ModelFetchScheduler:
encrypted_auth_config if isinstance(encrypted_auth_config, str) else None
),
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(

View File

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

View File

@@ -292,6 +292,66 @@ def resolve_effective_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 构建
# ---------------------------------------------------------------------------

View File

@@ -175,14 +175,20 @@ class RequestExecutor:
)
else:
# 非流式请求:标记为 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] = {
"is_cached_user": is_cached_user,
"model_name": model_name,
"api_format": api_format,
}
_pi = resolve_proxy_info(getattr(provider, "proxy", None))
_pi = resolve_proxy_info(_eff_proxy)
if _pi:
_extra["proxy"] = _pi
RequestCandidateService.mark_candidate_success(

View File

@@ -708,11 +708,15 @@ class TaskService:
ThinkingSignatureException,
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
# 提前解析代理信息,写入候选记录的 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
if not isinstance(exec_err, ExecutionError):