feat: OAuth 账户管理、维护调度、端点健康检查增强及前端优化

- 新增 OAuth 账户管理对话框和提供商详情抽屉中的 OAuth 信息展示
- 新增维护调度器(maintenance_scheduler)支持定时清理和健康检查
- 增强端点健康检查器,支持更多检测策略
- 重构 codex 服务为 metadata_collectors 模块
- 优化 OpenAI CLI normalizer 代码结构
- 前端: 改进使用量表格、统计图表、指南页面和异步任务管理
- 扩展多个数据库字符串列为 TEXT 类型
- 新增倒计时 composable 和 provider OAuth API 端点
This commit is contained in:
fawney19
2026-02-04 23:59:45 +08:00
parent 24c9105628
commit 4d6e7c094f
64 changed files with 3885 additions and 930 deletions

View File

@@ -68,7 +68,6 @@ from src.models.database import (
User,
)
from src.services.cache.aware_scheduler import ProviderCandidate
from src.services.provider.codex import maybe_patch_request_for_codex
from src.services.provider.transport import (
build_provider_url,
get_vertex_ai_effective_format,
@@ -720,6 +719,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
key_id=str(key.id),
provider_api_format=str(endpoint.api_format) if endpoint.api_format else None,
)
ctx.provider_type = str(getattr(provider, "provider_type", "") or "")
# ctx.api_format 是枚举,需要取 value 作为字符串
_api_format_str = (
@@ -754,13 +754,18 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
else:
request_body = dict(original_request_body)
# 确定目标变体(用于 Codex 等需要特殊处理的上游)
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
target_variant = provider_type if provider_type == "codex" else None
# 跨格式:先做请求体转换(失败触发 failover
registry = get_format_converter_registry()
if needs_conversion:
registry = get_format_converter_registry()
request_body = registry.convert_request(
request_body,
str(client_api_format),
str(provider_api_format),
target_variant=target_variant,
)
# 格式转换后,为需要 model 字段的格式设置模型名
self._set_model_after_conversion(
@@ -779,13 +784,14 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
else:
# 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段)
request_body = self.prepare_provider_request_body(request_body)
# Provider-specific compatibility patches (e.g. Codex requires store=false and instructions).
request_body = maybe_patch_request_for_codex(
provider_type=str(getattr(provider, "provider_type", "") or ""),
provider_api_format=str(provider_api_format),
request_body=request_body,
)
# 同格式时也需要应用 target_variant 转换(如 Codex
if target_variant:
request_body = registry.convert_request(
request_body,
str(provider_api_format),
str(provider_api_format),
target_variant=target_variant,
)
# 构建请求(上游始终使用 header 认证,不跟随客户端的 query 方式)
provider_payload, provider_headers = self._request_builder.build(
@@ -1087,13 +1093,18 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
else:
request_body = dict(request_body_ref["body"])
# 确定目标变体(用于 Codex 等需要特殊处理的上游)
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
target_variant = provider_type if provider_type == "codex" else None
# 跨格式:先做请求体转换(失败触发 failover
registry = get_format_converter_registry()
if needs_conversion:
registry = get_format_converter_registry()
request_body = registry.convert_request(
request_body,
client_api_format,
provider_api_format,
target_variant=target_variant,
)
# 格式转换后,为需要 model 字段的格式设置模型名
self._set_model_after_conversion(
@@ -1112,13 +1123,14 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
else:
# 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段)
request_body = self.prepare_provider_request_body(request_body)
# Provider-specific compatibility patches (e.g. Codex requires store=false and instructions).
request_body = maybe_patch_request_for_codex(
provider_type=str(getattr(provider, "provider_type", "") or ""),
provider_api_format=str(provider_api_format),
request_body=request_body,
)
# 同格式时也需要应用 target_variant 转换(如 Codex
if target_variant:
request_body = registry.convert_request(
request_body,
provider_api_format,
provider_api_format,
target_variant=target_variant,
)
# 构建请求(上游始终使用 header 认证,不跟随客户端的 query 方式)
provider_payload, provider_hdrs = self._request_builder.build(

View File

@@ -638,13 +638,13 @@ class CliAdapterBase(ApiAdapter):
url = cls.build_endpoint_url(base_url, request_data, model_name)
# 合并 CLI 额外头部到 extra_headers
cli_extra = cls.get_cli_extra_headers()
cli_extra = cls.get_cli_extra_headers(base_url=base_url)
merged_extra = dict(extra_headers) if extra_headers else {}
merged_extra.update(cli_extra)
# 使用统一的头部构建函数
headers = cls.build_headers_with_extra(api_key, merged_extra if merged_extra else None)
body = cls.build_request_body(request_data)
body = cls.build_request_body(request_data, base_url=base_url)
# 获取有效的模型名称
effective_model_name = model_name or request_data.get("model")
@@ -686,17 +686,25 @@ class CliAdapterBase(ApiAdapter):
raise NotImplementedError(f"{cls.FORMAT_ID} adapter must implement build_endpoint_url")
@classmethod
def build_request_body(cls, request_data: dict[str, Any] | None = None) -> dict[str, Any]:
def build_request_body(
cls,
request_data: dict[str, Any] | None = None,
*,
base_url: str | None = None,
) -> dict[str, Any]:
"""构建测试请求体,使用转换器注册表自动处理格式转换
Args:
request_data: 可选的请求数据,会与默认测试请求合并
base_url: API 基础 URL用于判断特殊端点如 Codex
Returns:
转换为目标 API 格式的请求体
"""
from src.api.handlers.base.request_builder import build_test_request_body
# 基类不使用 base_url子类可覆盖以支持特殊端点
_ = base_url
return build_test_request_body(cls.FORMAT_ID, request_data)
@classmethod
@@ -710,13 +718,16 @@ class CliAdapterBase(ApiAdapter):
return None
@classmethod
def get_cli_extra_headers(cls) -> dict[str, str]:
def get_cli_extra_headers(cls, *, base_url: str | None = None) -> dict[str, str]:
"""
获取CLI额外请求头 - 子类可覆盖
用于 check_endpoint 测试请求时添加额外的头部。
默认实现只添加 User-Agent如果有
Args:
base_url: API 基础 URL子类可据此判断特殊端点如 Codex
Returns:
额外请求头字典
"""

View File

@@ -72,7 +72,6 @@ from src.models.database import (
User,
)
from src.services.cache.aware_scheduler import ProviderCandidate
from src.services.provider.codex import maybe_patch_request_for_codex
from src.services.provider.transport import build_provider_url
from src.services.system.config import SystemConfigService
from src.utils.sse_parser import SSEEventParser
@@ -425,6 +424,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
mapped_model: str | None,
fallback_model: str,
is_stream: bool,
*,
target_variant: str | None = None,
) -> tuple[dict[str, Any], str]:
"""
跨格式请求转换的公共逻辑
@@ -438,6 +439,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
mapped_model: 映射后的模型名
fallback_model: 备用模型名(通常是原始请求的 model
is_stream: 是否流式请求
target_variant: 目标变体(如 "codex"),用于同格式但有细微差异的上游
Returns:
(转换后的请求体, 用于 URL 的模型名)
@@ -447,6 +449,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
request_body,
str(client_api_format),
str(provider_api_format),
target_variant=target_variant,
)
# 先计算 URL 模型(在清理 body 中的 model 字段之前)
@@ -697,6 +700,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 记录 Provider 信息
ctx.provider_name = str(provider.name)
ctx.provider_id = str(provider.id)
ctx.provider_type = str(getattr(provider, "provider_type", "") or "")
ctx.endpoint_id = str(endpoint.id)
ctx.key_id = str(key.id)
@@ -730,6 +734,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
)
ctx.needs_conversion = needs_conversion
# 确定目标变体(用于 Codex 等需要特殊处理的上游)
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
target_variant = provider_type if provider_type == "codex" else None
# 跨格式:先做请求体转换(失败触发 failover
if needs_conversion and provider_api_format:
request_body, url_model = self._convert_request_for_cross_format(
@@ -739,6 +747,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
mapped_model,
ctx.model,
is_stream=True,
target_variant=target_variant,
)
else:
# 同格式:按原逻辑做轻量清理(子类可覆盖)
@@ -746,13 +755,15 @@ class CliMessageHandlerBase(BaseMessageHandler):
url_model = (
self.get_model_for_url(request_body, mapped_model) or mapped_model or ctx.model
)
# Provider-specific compatibility patches (e.g. Codex requires store=false and instructions).
request_body = maybe_patch_request_for_codex(
provider_type=str(getattr(provider, "provider_type", "") or ""),
provider_api_format=str(provider_api_format),
request_body=request_body,
)
# 同格式时也需要应用 target_variant 转换(如 Codex
if target_variant:
registry = get_format_converter_registry()
request_body = registry.convert_request(
request_body,
provider_api_format,
provider_api_format,
target_variant=target_variant,
)
# 获取认证信息(处理 Service Account 等异步认证场景)
auth_info = await get_provider_auth(endpoint, key)
@@ -1922,12 +1933,19 @@ class CliMessageHandlerBase(BaseMessageHandler):
try:
from src.models.database import ApiKey as ApiKeyModel
# 采集上游元数据(仅成功请求)
if ctx.is_success():
self._collect_upstream_metadata(bg_db, ctx)
user = bg_db.query(User).filter(User.id == ctx.user_id).first()
api_key = bg_db.query(ApiKeyModel).filter(ApiKeyModel.id == ctx.api_key_id).first()
if not user or not api_key:
logger.warning(
f"[{ctx.request_id}] 无法记录统计: user={user is not None}, api_key={api_key is not None}"
"[{}] 无法记录统计: user={} api_key={}",
ctx.request_id,
user is not None,
api_key is not None,
)
return
@@ -2153,6 +2171,19 @@ class CliMessageHandlerBase(BaseMessageHandler):
except Exception as e:
logger.exception("记录流式统计信息时出错")
@staticmethod
def _collect_upstream_metadata(db: Session, ctx: StreamContext) -> None:
"""采集上游元数据并更新 ProviderAPIKey.upstream_metadata带节流"""
from src.services.provider.metadata_collectors import collect_and_save_upstream_metadata
collect_and_save_upstream_metadata(
db,
provider_type=ctx.provider_type or "",
key_id=ctx.key_id or "",
response_headers=ctx.response_headers or {},
request_id=ctx.request_id or "",
)
async def _record_stream_failure(
self,
ctx: StreamContext,
@@ -2285,6 +2316,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
)
needs_conversion = bool(getattr(candidate, "needs_conversion", False))
# 确定目标变体(用于 Codex 等需要特殊处理的上游)
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
target_variant = provider_type if provider_type == "codex" else None
# 跨格式:先做请求体转换(失败触发 failover
if needs_conversion and provider_api_format:
request_body, url_model = self._convert_request_for_cross_format(
@@ -2294,6 +2329,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
mapped_model,
model,
is_stream=False,
target_variant=target_variant,
)
else:
# 同格式:按原逻辑做轻量清理(子类可覆盖)
@@ -2301,13 +2337,15 @@ class CliMessageHandlerBase(BaseMessageHandler):
url_model = (
self.get_model_for_url(request_body, mapped_model) or mapped_model or model
)
# Provider-specific compatibility patches (e.g. Codex requires store=false and instructions).
request_body = maybe_patch_request_for_codex(
provider_type=str(getattr(provider, "provider_type", "") or ""),
provider_api_format=str(provider_api_format),
request_body=request_body,
)
# 同格式时也需要应用 target_variant 转换(如 Codex
if target_variant:
registry = get_format_converter_registry()
request_body = registry.convert_request(
request_body,
provider_api_format,
provider_api_format,
target_variant=target_variant,
)
# 获取认证信息(处理 Service Account 等异步认证场景)
auth_info = await get_provider_auth(endpoint, key)

View File

@@ -271,7 +271,7 @@ async def _calculate_and_record_usage(
cache_read_input_tokens=cache_read_input_tokens,
request_type="endpoint_test", # 使用特殊的请求类型标识测试
api_format=api_format,
is_stream=False,
is_stream=request_data.get("stream", False) if request_data else False,
response_time_ms=response_time_ms,
first_byte_time_ms=response_time_ms,
status_code=status_code,
@@ -587,58 +587,191 @@ class HttpRequestExecutor:
self.timeout = timeout
async def execute(self, request: EndpointCheckRequest) -> EndpointCheckResult:
"""执行HTTP请求"""
"""执行HTTP请求(支持流式和非流式响应)"""
start_time = time.time()
request_id = request.request_id or str(uuid.uuid4())[:8]
# 检查是否是流式请求
is_stream = request.json_body.get("stream", False) if request.json_body else False
try:
# 使用httpx进行异步请求
async with httpx.AsyncClient(timeout=self.timeout, verify=get_ssl_context()) as client:
response = await client.post(
url=request.url, json=request.json_body, headers=request.headers
)
if is_stream:
# 流式请求:读取 SSE 事件直到完成
response_data = await self._execute_stream_request(client, request)
end_time = time.time()
response_time_ms = int((end_time - start_time) * 1000)
end_time = time.time()
response_time_ms = int((end_time - start_time) * 1000)
if response_data.get("error"):
# 流式请求返回错误
return EndpointCheckResult(
status_code=response_data.get("status_code", 500),
headers=response_data.get("headers", {}),
response_time_ms=response_time_ms,
request_id=request_id,
response_data=None,
error_message=response_data.get("error"),
)
# 处理响应
if response.status_code == 200:
try:
response_data = response.json()
logger.debug(
f"[{request.api_format}] check_endpoint | response | json={_truncate_repr(response_data)}"
return EndpointCheckResult(
status_code=200,
headers=response_data.get("headers", {}),
response_time_ms=response_time_ms,
request_id=request_id,
response_data=response_data.get("final_response"),
)
else:
# 非流式请求:直接读取响应
response = await client.post(
url=request.url, json=request.json_body, headers=request.headers
)
except Exception:
response_data = None
logger.debug(f"[{request.api_format}] check_endpoint | response | invalid json")
return EndpointCheckResult(
status_code=response.status_code,
headers=dict(response.headers),
response_time_ms=response_time_ms,
request_id=request_id,
response_data=response_data,
)
else:
# 对于非200状态码使用错误处理器
error_body = response.text[:500] if response.text else "(empty)"
logger.debug(
f"[{request.api_format}] check_endpoint | response | error={error_body}"
)
end_time = time.time()
response_time_ms = int((end_time - start_time) * 1000)
# 创建HTTPStatusError让错误处理器处理
http_error = httpx.HTTPStatusError(
message=f"HTTP {response.status_code}: {error_body}",
request=None, # 我们不需要完整的request对象
response=response,
)
if response.status_code == 200:
try:
response_data = response.json()
logger.debug(
f"[{request.api_format}] check_endpoint | response | json={_truncate_repr(response_data)}"
)
except Exception:
response_data = None
logger.debug(
f"[{request.api_format}] check_endpoint | response | invalid json"
)
return await ErrorHandler.handle_error(http_error, request)
return EndpointCheckResult(
status_code=response.status_code,
headers=dict(response.headers),
response_time_ms=response_time_ms,
request_id=request_id,
response_data=response_data,
)
else:
error_body = response.text[:500] if response.text else "(empty)"
logger.debug(
f"[{request.api_format}] check_endpoint | response | error={error_body}"
)
http_error = httpx.HTTPStatusError(
message=f"HTTP {response.status_code}: {error_body}",
request=None,
response=response,
)
return await ErrorHandler.handle_error(http_error, request)
except Exception as e:
# 使用统一错误处理器处理异常
return await ErrorHandler.handle_error(e, request)
async def _execute_stream_request(
self, client: httpx.AsyncClient, request: EndpointCheckRequest
) -> dict[str, Any]:
"""执行流式请求并收集响应"""
try:
async with client.stream(
"POST", request.url, json=request.json_body, headers=request.headers
) as response:
headers = dict(response.headers)
if response.status_code != 200:
error_body = ""
async for chunk in response.aiter_text():
error_body += chunk
if len(error_body) > 500:
break
logger.debug(
"[{}] check_endpoint | stream error | {}",
request.api_format,
error_body[:500],
)
return {
"error": f"HTTP {response.status_code}: {error_body[:500]}",
"status_code": response.status_code,
"headers": headers,
}
# 收集 SSE 事件(兼容多种 API 格式)
final_response: dict[str, Any] = {}
collected_text = ""
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue
data_str = line[5:].strip()
if data_str == "[DONE]":
break
try:
event = json.loads(data_str)
event_type = event.get("type", "")
# OpenAI Responses API 事件
if event_type == "response.output_text.delta":
delta = event.get("delta", "")
if isinstance(delta, str):
collected_text += delta
elif event_type == "response.completed":
final_response = event.get("response", {})
break
# OpenAI Chat Completions 格式
elif "choices" in event:
for choice in event.get("choices", []):
delta = choice.get("delta", {})
content = delta.get("content")
if content:
collected_text += content
if choice.get("finish_reason"):
final_response = event
break
# Claude Messages API 格式
elif event_type == "content_block_delta":
delta = event.get("delta", {})
text = delta.get("text", "")
if text:
collected_text += text
elif event_type == "message_stop":
break
# Gemini SSE 格式
elif "candidates" in event:
for candidate in event.get("candidates", []):
content = candidate.get("content", {})
for part in content.get("parts", []):
text = part.get("text", "")
if text:
collected_text += text
except json.JSONDecodeError:
continue
# 如果没有收到最终响应事件,构建一个基本响应
if not final_response:
final_response = {
"status": "completed",
"output": [
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": collected_text}],
}
],
}
logger.debug(
"[{}] check_endpoint | stream completed | text_length={}",
request.api_format,
len(collected_text),
)
return {"final_response": final_response, "headers": headers}
except Exception as e:
logger.warning("[{}] check_endpoint | stream error | {}", request.api_format, e)
return {"error": str(e), "status_code": 500, "headers": {}}
class UsageCalculator:
"""用量计算器 - 专门负责Token计数和费用计算"""

View File

@@ -15,12 +15,14 @@ from __future__ import annotations
import json
import time
import httpx
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import httpx
from sqlalchemy.orm import object_session
from src.clients.redis_client import get_redis_client
from src.core.api_format import (
UPSTREAM_DROP_HEADERS,
HeaderBuilder,
@@ -30,9 +32,6 @@ from src.core.api_format import (
from src.core.crypto import crypto_service
from src.core.logger import logger
from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token
from sqlalchemy.orm import object_session
from src.clients.redis_client import get_redis_client
if TYPE_CHECKING:
from src.models.database import ProviderAPIKey, ProviderEndpoint
@@ -108,6 +107,8 @@ def get_test_request_data(request_data: dict[str, Any] | None = None) -> dict[st
def build_test_request_body(
format_id: str,
request_data: dict[str, Any] | None = None,
*,
target_variant: str | None = None,
) -> dict[str, Any]:
"""构建测试请求体,自动处理格式转换
@@ -116,6 +117,7 @@ def build_test_request_body(
Args:
format_id: 目标 endpoint signature"claude:chat", "gemini:chat", "openai:cli"
request_data: 可选的请求数据,会与默认测试请求合并
target_variant: 目标变体(如 "codex"),用于同格式但有细微差异的上游
Returns:
转换为目标 API 格式的请求体
@@ -124,21 +126,19 @@ def build_test_request_body(
format_conversion_registry,
register_default_normalizers,
)
from src.core.api_format.utils import get_base_format
register_default_normalizers()
# 获取测试请求数据OpenAI 格式)
source_data = get_test_request_data(request_data)
# CLI 格式使用基础格式进行转换claude:cli -> claude:chat
target_format = get_base_format(format_id) or format_id
# 使用注册表进行格式转换 (openai:chat -> 目标基础格式)
# 直接使用目标格式进行转换,不再转换为基础格式
# 这样 openai:cli 会正确转换为 Responses API 格式
return format_conversion_registry.convert_request(
source_data,
make_signature_key("openai", "chat"),
target_format,
format_id,
target_variant=target_variant,
)

View File

@@ -39,6 +39,7 @@ class StreamContext:
# Provider 信息(在请求执行时填充)
provider_name: str | None = None
provider_id: str | None = None
provider_type: str | None = None # Provider 类型(如 codex用于元数据采集
endpoint_id: str | None = None
key_id: str | None = None
attempt_id: str | None = None

View File

@@ -96,6 +96,10 @@ class StreamTelemetryRecorder:
bg_db = next(db_gen)
try:
# 采集上游元数据(仅成功请求,放在 writer 获取之前以确保执行)
if ctx.is_success():
self._collect_upstream_metadata(bg_db, ctx)
writer = await self._get_telemetry_writer(bg_db, ctx, response_time_ms)
if writer is None:
return
@@ -502,6 +506,19 @@ class StreamTelemetryRecorder:
error_message=ctx.error_message or f"HTTP {ctx.status_code}",
)
@staticmethod
def _collect_upstream_metadata(db: Session, ctx: StreamContext) -> None:
"""采集上游元数据并更新 ProviderAPIKey.upstream_metadata带节流"""
from src.services.provider.metadata_collectors import collect_and_save_upstream_metadata
collect_and_save_upstream_metadata(
db,
provider_type=ctx.provider_type or "",
key_id=ctx.key_id or "",
response_headers=ctx.response_headers or {},
request_id=ctx.request_id or "",
)
def _get_status_from_ctx(self, ctx: StreamContext) -> str:
"""根据上下文获取状态字符串"""
if ctx.is_success():

View File

@@ -140,9 +140,9 @@ class ClaudeCliAdapter(CliAdapterBase):
return config.internal_user_agent_claude_cli
@classmethod
def get_cli_extra_headers(cls) -> dict[str, str]:
def get_cli_extra_headers(cls, *, base_url: str | None = None) -> dict[str, str]:
"""获取Claude CLI额外请求头包含 x-app: cli 标识"""
headers = super().get_cli_extra_headers()
headers = super().get_cli_extra_headers(base_url=base_url)
headers["x-app"] = "cli" # 标识 CLI 模式,让上游使用正确的认证方式
return headers

View File

@@ -158,9 +158,9 @@ class GeminiCliAdapter(CliAdapterBase):
return config.internal_user_agent_gemini_cli
@classmethod
def get_cli_extra_headers(cls) -> dict[str, str]:
def get_cli_extra_headers(cls, *, base_url: str | None = None) -> dict[str, str]:
"""获取Gemini CLI额外请求头包含 x-app: cli 标识"""
headers = super().get_cli_extra_headers()
headers = super().get_cli_extra_headers(base_url=base_url)
headers["x-app"] = "cli" # 标识 CLI 模式,让上游使用正确的 adapter
return headers

View File

@@ -6,6 +6,7 @@ OpenAI CLI Adapter - 基于通用 CLI Adapter 基类的简化实现
from __future__ import annotations
import uuid
from typing import Any
import httpx
@@ -67,20 +68,74 @@ class OpenAICliAdapter(CliAdapterBase):
def build_endpoint_url(
cls, base_url: str, request_data: dict[str, Any], model_name: str | None = None
) -> str:
"""构建OpenAI CLI API端点URL"""
"""构建OpenAI CLI API端点URL(使用 Responses API
对于 Codex OAuth 端点(如 chatgpt.com/backend-api/codex直接追加 /responses
对于标准 OpenAI API使用 /v1/responses。
"""
base_url = base_url.rstrip("/")
# Codex OAuth 端点chatgpt.com/backend-api/codex -> /responses
if cls._is_codex_url(base_url):
return f"{base_url}/responses"
# 标准 OpenAI API
if base_url.endswith("/v1"):
return f"{base_url}/chat/completions"
return f"{base_url}/responses"
else:
return f"{base_url}/v1/chat/completions"
return f"{base_url}/v1/responses"
@classmethod
def _is_codex_url(cls, base_url: str) -> bool:
"""判断是否是 Codex OAuth 端点"""
return "/backend-api/codex" in base_url or base_url.endswith("/codex")
# build_request_body 使用基类实现
# OPENAI -> OPENAI_CLI 无转换器,会直接透传原始请求
# OpenAI CLI normalizer 会自动添加 instructions 字段
@classmethod
def build_request_body(
cls,
request_data: dict[str, Any] | None = None,
*,
base_url: str | None = None,
) -> dict[str, Any]:
"""构建测试请求体Codex 端点需要强制 stream=true 等特性)"""
from src.api.handlers.base.request_builder import build_test_request_body
target_variant = "codex" if base_url and cls._is_codex_url(base_url) else None
return build_test_request_body(
cls.FORMAT_ID,
request_data,
target_variant=target_variant,
)
@classmethod
def get_cli_user_agent(cls) -> str | None:
"""获取OpenAI CLI User-Agent"""
return config.internal_user_agent_openai_cli
@classmethod
def get_cli_extra_headers(cls, *, base_url: str | None = None) -> dict[str, str]:
"""
获取额外请求头
对于 Codex OAuth 端点,添加特定头部(缺少可能导致 Cloudflare 拦截)。
对于标准 OpenAI API 端点,仅添加 User-Agent。
"""
headers: dict[str, str] = {}
# User-Agent
cli_user_agent = cls.get_cli_user_agent()
if cli_user_agent:
headers["User-Agent"] = cli_user_agent
# 仅 Codex 端点添加特定头部
if base_url and cls._is_codex_url(base_url):
headers["x-oai-web-search-eligible"] = "true"
headers["session_id"] = str(uuid.uuid4())
headers["accept"] = "text/event-stream"
headers["originator"] = "codex_cli_rs"
return headers
__all__ = ["OpenAICliAdapter"]