feat(codex): 支持compact端点非流式请求,更新请求头与字段清理

- Codex适配器支持compact端点:非流式请求使用application/json Accept头,
  stream_policy根据compact上下文返回FORCE_NON_STREAM
- 更新Codex请求头格式:添加Version/Connection头,header key首字母大写
- 简化include列表处理:normalizer和request_patching统一强制为固定列表
- 清理Codex不支持的字段:truncation、context_management、user
- 默认instructions改为空字符串
- 前端用量页面:用户页面使用前端筛选后总数,避免不必要的后端分页请求
This commit is contained in:
fawney19
2026-03-01 18:20:42 +08:00
parent d4840df447
commit a137601728
9 changed files with 190 additions and 67 deletions

View File

@@ -315,18 +315,16 @@ class OpenAICliNormalizer(FormatNormalizer):
# Codex 特定设置(覆盖/删除不支持的字段)
if is_codex:
result["parallel_tool_calls"] = True
# 添加 reasoning.encrypted_content 到 include
include = result.get("include", [])
if not isinstance(include, list):
include = []
if self._CODEX_REQUIRED_INCLUDE not in include:
include.append(self._CODEX_REQUIRED_INCLUDE)
result["include"] = include
# 和 codex passthrough patch 保持一致:固定 include 列表
result["include"] = [self._CODEX_REQUIRED_INCLUDE]
# 删除 Codex 不支持的字段
for key in (
"previous_response_id",
"service_tier",
"max_completion_tokens",
"truncation",
"context_management",
"user",
):
result.pop(key, None)

View File

@@ -30,31 +30,34 @@ class CodexOAuthEnvelope:
"""Provider envelope hooks for Codex OAuth upstream."""
name = "codex:oauth"
_CODEX_VERSION = "0.101.0"
_CODEX_ORIGINATOR = "codex_cli_rs"
def extra_headers(self) -> dict[str, str] | None:
# These headers are best-effort: Codex upstream is stricter than public OpenAI API.
# Keep them provider-scoped (via ProviderEnvelope) to avoid leaking to other upstreams.
# Keep these headers provider-scoped to avoid leaking to other upstreams.
headers: dict[str, str] = {
"OpenAI-Beta": "responses=experimental",
# Codex upstream is strict about Content-Type; variants like
# "application/json; charset=utf-8" are rejected.
"Content-Type": "application/json",
"x-oai-web-search-eligible": "true",
"session_id": str(uuid.uuid4()),
"originator": "codex_cli_rs",
# Ensure SSE is returned when upstream is forced to streaming mode.
"Accept": "text/event-stream",
"Version": self._CODEX_VERSION,
"Session_id": str(uuid.uuid4()),
"Connection": "Keep-Alive",
"Originator": self._CODEX_ORIGINATOR,
}
# Compact endpoint is non-stream; normal responses endpoint expects SSE.
ctx = get_codex_request_context()
is_compact = bool(ctx.is_compact) if ctx else False
headers["Accept"] = "application/json" if is_compact else "text/event-stream"
ua = str(getattr(config, "internal_user_agent_openai_cli", "") or "").strip()
if ua:
headers["User-Agent"] = ua
# Add chatgpt-account-id from context (set by wrap_request).
# Context is NOT cleared here — build_codex_url reads is_compact from it later.
ctx = get_codex_request_context()
if ctx and ctx.account_id:
headers["chatgpt-account-id"] = ctx.account_id
headers["Chatgpt-Account-Id"] = ctx.account_id
return headers

View File

@@ -8,14 +8,14 @@ round-trip, so every field the client sent is preserved as-is unless explicitly
modified here.
Transformations applied:
- Force ``store=false`` (avoid persistence features not supported by some gateways).
- Force ``stream=true`` (Codex gateways require streaming).
- Force ``store=false``.
- Force ``stream=true`` (except compact requests).
- Force ``parallel_tool_calls=true``.
- Ensure ``instructions`` exists (Codex expects it in some deployments).
- Convert ``role=system`` messages to ``role=developer`` (Codex may not accept ``system``).
- Ensure ``instructions`` exists (empty string when absent).
- Convert ``role=system`` messages to ``role=developer``.
- Drop request parameters known to be rejected by Codex gateways.
- Ensure ``include`` contains ``"reasoning.encrypted_content"`` for parity with CLI behavior.
- Remove ``previous_response_id`` (not supported by Codex gateways).
- Force ``include`` to ``["reasoning.encrypted_content"]``.
- Drop compatibility-problematic fields (``context_management`` / ``user``).
"""
from __future__ import annotations
@@ -28,11 +28,11 @@ _REJECTED_PARAMS: frozenset[str] = frozenset(
{
"max_output_tokens",
"max_completion_tokens",
"max_tokens",
"temperature",
"top_p",
"service_tier",
"previous_response_id",
"truncation",
}
)
@@ -53,8 +53,12 @@ def patch_openai_cli_request_for_codex(request_body: dict[str, Any]) -> dict[str
# Codex gateways often reject/ignore persistence; be explicit.
out["store"] = False
# Codex gateways require streaming.
out["stream"] = True
# Codex compact endpoint is non-streaming; normal responses requires stream=true.
is_compact = bool(out.pop("_aether_compact", False))
if is_compact:
out.pop("stream", None)
else:
out["stream"] = True
# Codex expects parallel tool calls enabled.
out["parallel_tool_calls"] = True
@@ -62,7 +66,7 @@ def patch_openai_cli_request_for_codex(request_body: dict[str, Any]) -> dict[str
# Ensure instructions exists (some gateways require it even if empty).
instructions = out.get("instructions")
if not isinstance(instructions, str):
out["instructions"] = "You are a helpful coding assistant."
out["instructions"] = ""
# Convert "system" role to "developer" (Codex behavior).
input_items = out.get("input")
@@ -78,22 +82,12 @@ def patch_openai_cli_request_for_codex(request_body: dict[str, Any]) -> dict[str
patched_items.append(item)
out["input"] = patched_items
# Ensure required include item exists.
include = out.get("include")
if include is None:
out["include"] = [_REQUIRED_INCLUDE_ITEM]
elif isinstance(include, str):
out["include"] = (
[include] if include == _REQUIRED_INCLUDE_ITEM else [include, _REQUIRED_INCLUDE_ITEM]
)
elif isinstance(include, (list, tuple, set)):
include_list = list(include)
if _REQUIRED_INCLUDE_ITEM not in include_list:
include_list.append(_REQUIRED_INCLUDE_ITEM)
out["include"] = include_list
else:
# Unknown type; overwrite to keep behavior deterministic.
out["include"] = [_REQUIRED_INCLUDE_ITEM]
# Keep codex behavior deterministic: force the exact include list.
out["include"] = [_REQUIRED_INCLUDE_ITEM]
# Codex upstream currently rejects these fields.
out.pop("context_management", None)
out.pop("user", None)
return out

View File

@@ -60,6 +60,15 @@ def get_upstream_stream_policy(
provider_obj = getattr(endpoint, "provider", None)
pt = str(provider_type or getattr(provider_obj, "provider_type", "") or "").strip().lower()
sig = str(endpoint_sig or getattr(endpoint, "api_format", "") or "").strip().lower()
is_codex_compact = False
if pt == ProviderType.CODEX and sig == "openai:cli":
try:
from src.services.provider.adapters.codex.context import get_codex_request_context
ctx = get_codex_request_context()
is_codex_compact = bool(ctx and ctx.is_compact)
except Exception:
is_codex_compact = False
# Explicit config wins (unless upstream has a hard constraint).
cfg = getattr(endpoint, "config", None)
@@ -76,6 +85,7 @@ def get_upstream_stream_policy(
pt == ProviderType.CODEX
and sig == "openai:cli"
and parsed == UpstreamStreamPolicy.FORCE_NON_STREAM
and not is_codex_compact
):
return UpstreamStreamPolicy.FORCE_STREAM
if pt == ProviderType.KIRO and parsed == UpstreamStreamPolicy.FORCE_NON_STREAM:
@@ -84,7 +94,11 @@ def get_upstream_stream_policy(
# Safe-by-default: Codex Responses OAuth behaves like SSE-only.
if pt == ProviderType.CODEX and sig == "openai:cli":
return UpstreamStreamPolicy.FORCE_STREAM
return (
UpstreamStreamPolicy.FORCE_NON_STREAM
if is_codex_compact
else UpstreamStreamPolicy.FORCE_STREAM
)
# Kiro upstream streams binary AWS Event Stream; treat as stream-only.
if pt == ProviderType.KIRO: