feat(codex): support native Anthropic Messages protocol as upstream (#5071)

* feat(codex): support native Anthropic Messages protocol as upstream

Allow gateways that only expose the native Anthropic Messages protocol
(/v1/messages) to be used by Codex: the local proxy performs bidirectional
request/response/streaming conversion between Responses and Anthropic.

Backend:
- Add two conversion modules: transform_codex_anthropic / streaming_codex_anthropic
- codex.rs: add routing detection and auth: ANTHROPIC_AUTH_TOKEN→Bearer (default),
  ANTHROPIC_API_KEY→x-api-key, mutually exclusive
- handlers.rs: add handle_codex_anthropic_to_responses_transform
- forwarder.rs: support optional Claude Code client fingerprint impersonation
  (User-Agent / anthropic-beta / x-app / system prompt first-line injection)
  and /responses→/v1/messages rewriting
- codex_config: the anthropic format reuses the NativeResponses profile to strip
  custom tools
- ProviderMeta: add impersonateClaudeCode

Frontend:
- CodexApiFormat: add "anthropic"; the form adds auth field selection and an
  impersonation toggle
- Add en/ja/zh/zh-TW copy

Robustness:
- Downgrade when tool history / forced tool_choice conflicts with extended
  thinking, avoiding upstream 400s
- Emit cache_creation_input_tokens in usage and use saturating_add to guard
  against overflow
- Append a unique suffix to non-streaming/streaming output-item ids to avoid
  multi-segment text/thinking overwriting each other

* fix(codex): harden Anthropic bridge against empty text blocks and truncated streams

- Drop empty/whitespace-only text content blocks when rebuilding Anthropic
  messages from Responses history; Anthropic 400s on empty text blocks (e.g. an
  empty assistant text emitted alongside a tool_use), which broke follow-up and
  tool-result requests. Also drop messages left without content.
- Do not report a truncated Anthropic stream as completed: when the SSE
  connection ends before message_stop with no stop_reason, emit an incomplete
  response if partial output exists, or a failed (stream_truncated) response
  otherwise, mirroring the chat converter's EOF handling.

* fix(codex): resolve Anthropic-bridge review findings

Blocking:
1. Defer stripping the [1m] long-context marker until after catalog matching and model write-back, re-stripping it on the final Codex→Anthropic body and setting a flag to emit the context-1m-2025-08-07 beta header, so the marker is no longer lost or overridden by the default model.
2. Gate Anthropic thinking on the trailing turn only (via trailing_turn_allows_thinking) instead of scanning full history, so a Codex session that resends history each round no longer permanently loses thinking after the first tool call.
3. Inject 5m ephemeral cache_control on the Codex→Anthropic body by reusing cache_injector (handling the system string→array conversion), so system/tools/history are cached instead of re-sent at full price every round.
4. Add a shared base_url_is_full_endpoint helper (normalizing whitespace/query/fragment/trailing slash) used by both the Anthropic and Chat paths, so a base URL already ending in /v1/messages is treated as a full endpoint instead of double-appending to /v1/messages/v1/messages.
5. Align the catalog tool-profile predicate with the routing predicate so resolve_codex_catalog_tool_profile returns the Anthropic profile whenever the request converts to Anthropic, preventing freeform tools like apply_patch from being silently filtered by a ProxyChat catalog.
6. Explicitly disable native web_search for the Anthropic profile (including the no-catalog branch) via set_codex_native_web_search_field, so Codex no longer treats it as available while the transform silently drops it.
7. Only forward tool_choice when tools survive filtering, dropping it otherwise, to avoid a non-retryable 400 from upstream when tool_choice is sent with no tools.
8. Lower the fallback default to max_tokens=8192 (only when max_output_tokens is omitted) and clamp the thinking budget to max_tokens/2, disabling thinking below the 1024 floor, to avoid hard 400s on low-output-ceiling models/gateways.

Minor:
9. Centralize the Codex/OpenAI fingerprint-header denylist in is_codex_client_fingerprint_header so impersonating Claude Code uniformly drops originator/session_id/conversation_id/chatgpt-account-id/x-client-request-id/openai-* and the x-stainless-*/x-codex-* prefixes.
10. Retain content_block_start.input as start_input and fall back to it at block close when no input_json_delta arrived, so a gateway that carries the full tool input on the start event no longer yields empty tool arguments.
11. Extract a shared codex_responses_sse module as the single Responses SSE envelope builder that both the chat and anthropic streaming emitters delegate to, with byte-for-byte-unchanged wire output, so future event-format fixes touch one place instead of two.

* fix(codex): add per-provider max_output_tokens override for Codex→Anthropic path

Codex does not forward model_max_output_tokens in the request body,
causing the proxy to fall back to a conservative 8192 default. This
truncates long or thinking-heavy responses (stop_reason=max_tokens).

- Add maxOutputTokens field to ProviderMeta (Rust + TypeScript)
- Inject the value into the Anthropic request body before transform,
  taking precedence over request-supplied and default values
- Add numeric input in Codex form fields (Anthropic format only)
- Add i18n entries for label, placeholder, and hint (en/zh/zh-TW/ja)
- Include roundtrip and omission unit tests for the new field

* fix(codex): harden Anthropic protocol bridge

* fix(codex): address Anthropic bridge review

* fix(codex): preserve flattened Anthropic inputs

* fix(codex): harden Anthropic recovery paths

* fix(ci): satisfy Rust clippy

---------

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
Yeeyzy
2026-07-10 23:06:30 +08:00
committed by GitHub
parent 98ccde0050
commit 99e11e0851
29 changed files with 5733 additions and 371 deletions
+8 -2
View File
@@ -22,6 +22,7 @@ import {
extractCodexBaseUrl,
extractCodexExperimentalBearerToken,
extractCodexWireApi,
isCodexAnthropicWireApi,
isCodexChatWireApi,
} from "@/utils/providerConfigUtils";
import { useProviderHealth } from "@/lib/query/failover";
@@ -220,11 +221,16 @@ export function ProviderCard({
provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH;
const codexNeedsRouting = useMemo(() => {
if (appId !== "codex" || provider.category === "official") return false;
if (provider.meta?.apiFormat === "openai_chat") return true;
if (
provider.meta?.apiFormat === "openai_chat" ||
provider.meta?.apiFormat === "anthropic"
)
return true;
const config = (provider.settingsConfig as Record<string, any>)?.config;
return (
typeof config === "string" &&
isCodexChatWireApi(extractCodexWireApi(config))
(isCodexChatWireApi(extractCodexWireApi(config)) ||
isCodexAnthropicWireApi(extractCodexWireApi(config)))
);
}, [
appId,
@@ -36,6 +36,7 @@ import { CustomUserAgentField } from "./CustomUserAgentField";
import { LocalProxyRequestOverridesField } from "./LocalProxyRequestOverridesField";
import { cn } from "@/lib/utils";
import type {
ClaudeApiKeyField,
CodexApiFormat,
CodexCatalogModel,
CodexChatReasoning,
@@ -73,6 +74,15 @@ interface CodexFormFieldsProps {
// Note: wire_api is always "responses" for Codex; apiFormat controls proxy-layer conversion
apiFormat: CodexApiFormat;
onApiFormatChange: (format: CodexApiFormat) => void;
// Auth field for the Anthropic Messages upstream (only used when apiFormat === "anthropic")
anthropicAuthField: ClaudeApiKeyField;
onAnthropicAuthFieldChange: (value: ClaudeApiKeyField) => void;
// Anthropic path: whether to emulate the Claude Code client
impersonateClaudeCode: boolean;
onImpersonateClaudeCodeChange: (value: boolean) => void;
// Anthropic path: output ceiling override (empty string = use default). Digits only.
maxOutputTokens: string;
onMaxOutputTokensChange: (value: string) => void;
codexChatReasoning?: CodexChatReasoning;
onCodexChatReasoningChange?: (value: CodexChatReasoning) => void;
@@ -158,6 +168,12 @@ export function CodexFormFields({
onAutoSelectChange,
apiFormat,
onApiFormatChange,
anthropicAuthField,
onAnthropicAuthFieldChange,
impersonateClaudeCode,
onImpersonateClaudeCodeChange,
maxOutputTokens,
onMaxOutputTokensChange,
codexChatReasoning = {},
onCodexChatReasoningChange,
catalogModels = [],
@@ -177,6 +193,7 @@ export function CodexFormFields({
// 思考能力随 Chat 格式显示(仅 Chat Completions 转换路径用得上);模型映射常驻
//(填了才生成 catalog)。两者都已与「路由接管」概念解耦。
const isChatFormat = apiFormat === "openai_chat";
const isAnthropicFormat = apiFormat === "anthropic";
const canEditCatalog = Boolean(onCatalogModelsChange);
const canEditReasoning = Boolean(onCodexChatReasoningChange);
const supportsThinking =
@@ -194,8 +211,10 @@ export function CodexFormFields({
hasRequestOverrides ||
catalogModels.length > 0 ||
apiFormat === "openai_responses" ||
isAnthropicFormat ||
supportsThinking ||
supportsEffort;
supportsEffort ||
!!maxOutputTokens;
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
// 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
@@ -449,15 +468,118 @@ export function CodexFormFields({
defaultValue: "Responses(原生)",
})}
</SelectItem>
<SelectItem value="anthropic">
{t("codexConfig.upstreamFormatAnthropic", {
defaultValue: "Anthropic Messages(需开启路由)",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.upstreamFormatHint", {
defaultValue:
"供应商原生是 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat(需开启路由接管才能转换为 Chat Completions。",
"供应商原生是 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat;供应商只提供原生 Anthropic Messages 协议就选 Anthropic Messages。Chat 与 Anthropic Messages 均需开启路由接管才能转换为 Responses。",
})}
</p>
</div>
{isAnthropicFormat && (
<div className="space-y-1.5">
<FormLabel htmlFor="codex-anthropic-auth-field">
{t("codexConfig.anthropicAuthFieldLabel", {
defaultValue: "认证字段",
})}
</FormLabel>
<Select
value={anthropicAuthField}
onValueChange={(value) =>
onAnthropicAuthFieldChange(value as ClaudeApiKeyField)
}
>
<SelectTrigger
id="codex-anthropic-auth-field"
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
{t("codexConfig.anthropicAuthFieldAuthToken", {
defaultValue:
"ANTHROPIC_AUTH_TOKENAuthorization",
})}
</SelectItem>
<SelectItem value="ANTHROPIC_API_KEY">
{t("codexConfig.anthropicAuthFieldApiKey", {
defaultValue: "ANTHROPIC_API_KEYx-api-key",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.anthropicAuthFieldHint", {
defaultValue:
"选择网关接收 API Key 的请求头:ANTHROPIC_AUTH_TOKEN 发送 Authorization: BearerANTHROPIC_API_KEY 发送 x-api-key。两者只发其一。",
})}
</p>
</div>
)}
{isAnthropicFormat && (
<div className="flex items-center justify-between gap-4 border-t border-border-default pt-3">
<div className="space-y-1">
<FormLabel>
{t("codexConfig.impersonateClaudeCodeLabel", {
defaultValue: "模拟 Claude Code 客户端",
})}
</FormLabel>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.impersonateClaudeCodeHint", {
defaultValue:
"网关或其上游限制只能通过 Claude Code 使用时开启:伪装 User-Agent、anthropic-beta、x-app 请求头,并在系统提示首行注入 Claude Code 身份。",
})}
</p>
</div>
<Switch
checked={impersonateClaudeCode}
onCheckedChange={onImpersonateClaudeCodeChange}
aria-label={t("codexConfig.impersonateClaudeCodeLabel", {
defaultValue: "模拟 Claude Code 客户端",
})}
/>
</div>
)}
{isAnthropicFormat && (
<div className="space-y-1.5 border-t border-border-default pt-3">
<FormLabel htmlFor="codex-anthropic-max-output-tokens">
{t("codexConfig.maxOutputTokensLabel", {
defaultValue: "最大输出 tokens",
})}
</FormLabel>
<Input
id="codex-anthropic-max-output-tokens"
type="number"
min={1}
inputMode="numeric"
value={maxOutputTokens}
onChange={(event) =>
onMaxOutputTokensChange(
event.target.value.replace(/[^\d]/g, ""),
)
}
placeholder={t("codexConfig.maxOutputTokensPlaceholder", {
defaultValue: "留空则使用默认 8192",
})}
/>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.maxOutputTokensHint", {
defaultValue:
"Codex 不会把 model_max_output_tokens 写进请求体,默认上限 8192 容易在长回答或深度思考时被截断(stop_reason=max_tokens)。此处设置会作为 Anthropic 的 max_tokens 覆盖请求值。请勿超过该模型/网关的真实输出上限,否则可能 400。留空使用默认 8192。",
})}
</p>
</div>
)}
</div>
)}
+62 -28
View File
@@ -60,6 +60,7 @@ import {
} from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
import {
codexApiFormatFromWireApi,
extractCodexWireApi,
setCodexWireApi,
setCodexModelName as setCodexModelNameInConfig,
@@ -131,25 +132,6 @@ type PresetEntry = {
| HermesProviderPreset;
};
const codexApiFormatFromWireApi = (
wireApi: string | undefined,
): CodexApiFormat | undefined => {
switch (wireApi?.trim().toLowerCase()) {
case "chat":
case "chat_completions":
case "chat-completions":
case "openai_chat":
case "openai-chat":
return "openai_chat";
case "responses":
case "openai_responses":
case "openai-responses":
return "openai_responses";
default:
return undefined;
}
};
export const normalizeCodexCatalogModelsForSave = (
models: CodexCatalogModel[],
): CodexCatalogModel[] => {
@@ -586,19 +568,43 @@ function ProviderFormFull({
const initialCodexApiFormat: CodexApiFormat =
initialData?.meta?.apiFormat === "openai_chat"
? "openai_chat"
: initialData?.meta?.apiFormat === "openai_responses"
? "openai_responses"
: (codexApiFormatFromWireApi(
extractCodexWireApi(
typeof initialData?.settingsConfig?.config === "string"
? initialData.settingsConfig.config
: "",
),
) ?? "openai_responses");
: initialData?.meta?.apiFormat === "anthropic"
? "anthropic"
: initialData?.meta?.apiFormat === "openai_responses"
? "openai_responses"
: (codexApiFormatFromWireApi(
extractCodexWireApi(
typeof initialData?.settingsConfig?.config === "string"
? initialData.settingsConfig.config
: "",
),
) ?? "openai_responses");
const [localCodexApiFormat, setLocalCodexApiFormat] =
useState<CodexApiFormat>(initialCodexApiFormat);
// Auth-field choice for the Anthropic Messages upstream (defaults to the Bearer form)
const initialCodexAnthropicAuthField: ClaudeApiKeyField =
initialData?.meta?.apiKeyField === "ANTHROPIC_API_KEY"
? "ANTHROPIC_API_KEY"
: "ANTHROPIC_AUTH_TOKEN";
const [localCodexAnthropicAuthField, setLocalCodexAnthropicAuthField] =
useState<ClaudeApiKeyField>(initialCodexAnthropicAuthField);
// Emulate the Claude Code client: off by default, enabled only when the user explicitly turns it on (true)
const [localCodexImpersonateClaudeCode, setLocalCodexImpersonateClaudeCode] =
useState<boolean>(initialData?.meta?.impersonateClaudeCode === true);
// Codex → Anthropic output ceiling override (empty string = use the 8192 default).
// Kept as a string so the numeric input can be cleared; parsed on save.
const [localCodexMaxOutputTokens, setLocalCodexMaxOutputTokens] =
useState<string>(
typeof initialData?.meta?.maxOutputTokens === "number" &&
initialData.meta.maxOutputTokens > 0
? String(initialData.meta.maxOutputTokens)
: "",
);
const { configError: codexConfigError, debouncedValidate } =
useCodexTomlValidation();
@@ -1502,6 +1508,28 @@ function ProviderFormFull({
category !== "official" &&
localApiKeyField !== "ANTHROPIC_AUTH_TOKEN"
? localApiKeyField
: appId === "codex" &&
category !== "official" &&
localCodexApiFormat === "anthropic" &&
localCodexAnthropicAuthField !== "ANTHROPIC_AUTH_TOKEN"
? localCodexAnthropicAuthField
: undefined,
// Off by default; persist true only for codex+anthropic when the user explicitly enables it
impersonateClaudeCode:
appId === "codex" &&
category !== "official" &&
localCodexApiFormat === "anthropic" &&
localCodexImpersonateClaudeCode
? true
: undefined,
// Persist only for codex+anthropic when a positive value was entered
maxOutputTokens:
appId === "codex" &&
category !== "official" &&
localCodexApiFormat === "anthropic" &&
localCodexMaxOutputTokens.trim() !== "" &&
Number(localCodexMaxOutputTokens) > 0
? Number(localCodexMaxOutputTokens)
: undefined,
isFullUrl:
supportsFullUrl && category !== "official" && localIsFullUrl
@@ -2142,6 +2170,12 @@ function ProviderFormFull({
onAutoSelectChange={setEndpointAutoSelect}
apiFormat={localCodexApiFormat}
onApiFormatChange={handleCodexApiFormatChange}
anthropicAuthField={localCodexAnthropicAuthField}
onAnthropicAuthFieldChange={setLocalCodexAnthropicAuthField}
impersonateClaudeCode={localCodexImpersonateClaudeCode}
onImpersonateClaudeCodeChange={setLocalCodexImpersonateClaudeCode}
maxOutputTokens={localCodexMaxOutputTokens}
onMaxOutputTokensChange={setLocalCodexMaxOutputTokens}
codexChatReasoning={codexChatReasoning}
onCodexChatReasoningChange={setCodexChatReasoning}
catalogModels={codexCatalogModels}