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
+300 -1
View File
@@ -84,6 +84,72 @@ pub fn should_convert_codex_responses_to_chat(provider: &Provider, endpoint: &st
) && codex_provider_uses_chat_completions(provider)
}
/// Whether this Codex provider's real upstream speaks the native Anthropic
/// Messages protocol (`/v1/messages`). The local Codex client always talks to CC
/// Switch through the Responses API, so CC Switch bridges Responses ⇄ Anthropic.
///
/// Determined solely from explicit config (apiFormat / wire_api); no base_url
/// guessing — Anthropic gateway addresses vary widely and guessing easily misfires.
pub fn codex_provider_uses_anthropic(provider: &Provider) -> bool {
if let Some(api_format) = provider
.meta
.as_ref()
.and_then(|meta| meta.api_format.as_deref())
.or_else(|| {
provider
.settings_config
.get("api_format")
.and_then(|v| v.as_str())
})
.or_else(|| {
provider
.settings_config
.get("apiFormat")
.and_then(|v| v.as_str())
})
{
return is_anthropic_wire_api(api_format);
}
provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.and_then(extract_codex_wire_api_from_toml)
.map(|wire_api| is_anthropic_wire_api(&wire_api))
.unwrap_or(false)
}
pub fn should_convert_codex_responses_to_anthropic(provider: &Provider, endpoint: &str) -> bool {
let path = endpoint
.split_once('?')
.map_or(endpoint, |(path, _query)| path);
matches!(
path,
"/responses" | "/v1/responses" | "/responses/compact" | "/v1/responses/compact"
) && codex_provider_uses_anthropic(provider)
}
/// Resolve the model-catalog tool profile for a Codex provider using the SAME
/// Anthropic detection as the proxy router ([`codex_provider_uses_anthropic`]), so the
/// generated catalog never disagrees with the routed transform. A provider whose
/// Anthropic upstream is declared only via settings `apiFormat` or TOML `wire_api`
/// (not `meta.api_format`) would otherwise get a `ProxyChat` catalog and emit the
/// freeform `apply_patch` tool that the Anthropic transform then silently drops.
/// Non-Anthropic providers keep the existing `meta.api_format` classification.
pub fn resolve_codex_catalog_tool_profile(
provider: &Provider,
) -> crate::codex_config::CodexCatalogToolProfile {
use crate::codex_config::CodexCatalogToolProfile;
if codex_provider_uses_anthropic(provider) {
return CodexCatalogToolProfile::Anthropic;
}
CodexCatalogToolProfile::from_api_format(
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
)
}
/// Extract the real upstream model configured for a Codex provider.
pub fn codex_provider_upstream_model(provider: &Provider) -> Option<String> {
provider
@@ -129,7 +195,13 @@ pub fn apply_codex_chat_upstream_model(
if !codex_provider_uses_chat_completions(provider) {
return None;
}
apply_codex_upstream_model(provider, body)
}
/// Same model-substitution logic as `apply_codex_chat_upstream_model`, but without
/// the chat gating check. Reused by the anthropic conversion path (the forwarder has
/// already confirmed this provider uses anthropic).
pub fn apply_codex_upstream_model(provider: &Provider, body: &mut JsonValue) -> Option<String> {
let catalog_model_ids = codex_provider_catalog_model_ids(provider);
if let Some(request_model) = body
.get("model")
@@ -345,6 +417,13 @@ fn is_chat_wire_api(value: &str) -> bool {
)
}
fn is_anthropic_wire_api(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"anthropic" | "anthropic_messages" | "anthropic-messages" | "claude" | "messages"
)
}
fn is_chat_completions_url(value: &str) -> bool {
value
.trim_end_matches('/')
@@ -527,8 +606,28 @@ impl ProviderAdapter for CodexAdapter {
}
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
// Anthropic upstream: the auth field is chosen by the user in the UI (meta.apiKeyField).
// ANTHROPIC_API_KEY → x-api-key (AuthStrategy::Anthropic)
// ANTHROPIC_AUTH_TOKEN → Authorization: Bearer (default, AuthStrategy::Bearer)
// The two are mutually exclusive to avoid a 401 from the gateway receiving
// both auth headers at once. All other Codex upstreams stay pure Bearer.
let strategy = if codex_provider_uses_anthropic(provider) {
let uses_x_api_key = provider
.meta
.as_ref()
.and_then(|meta| meta.api_key_field.as_deref())
.map(|field| field.eq_ignore_ascii_case("ANTHROPIC_API_KEY"))
.unwrap_or(false);
if uses_x_api_key {
AuthStrategy::Anthropic
} else {
AuthStrategy::Bearer
}
} else {
AuthStrategy::Bearer
};
self.extract_key(provider)
.map(|key| AuthInfo::new(key, AuthStrategy::Bearer))
.map(|key| AuthInfo::new(key, strategy))
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
@@ -569,6 +668,15 @@ impl ProviderAdapter for CodexAdapter {
) -> Result<Vec<(http::HeaderName, http::HeaderValue)>, ProxyError> {
use super::adapter::auth_header_value;
let bearer = format!("Bearer {}", auth.api_key);
// Anthropic gateway: send only x-api-key (anthropic-version is filled in by
// the forwarder). Mutually exclusive with Bearer to avoid a 401 from the
// gateway receiving both auth headers at once.
if auth.strategy == AuthStrategy::Anthropic {
return Ok(vec![(
http::HeaderName::from_static("x-api-key"),
auth_header_value(&auth.api_key)?,
)]);
}
Ok(vec![(
http::HeaderName::from_static("authorization"),
auth_header_value(&bearer)?,
@@ -662,6 +770,197 @@ experimental_bearer_token = "sk-config-key"
assert_eq!(url, "https://api.openai.com/v1/responses");
}
// ==================== anthropic upstream detection ====================
#[test]
fn test_uses_anthropic_from_settings_api_format() {
let provider = create_provider(json!({ "apiFormat": "anthropic" }));
assert!(codex_provider_uses_anthropic(&provider));
let provider = create_provider(json!({ "api_format": "anthropic_messages" }));
assert!(codex_provider_uses_anthropic(&provider));
}
#[test]
fn test_uses_anthropic_from_meta_api_format() {
let mut provider = create_provider(json!({}));
provider.meta = Some(crate::provider::ProviderMeta {
api_format: Some("anthropic".to_string()),
..Default::default()
});
assert!(codex_provider_uses_anthropic(&provider));
}
#[test]
fn test_uses_anthropic_from_toml_wire_api() {
let provider = create_provider(json!({
"config": r#"model_provider = "custom"
[model_providers.custom]
wire_api = "anthropic"
"#
}));
assert!(codex_provider_uses_anthropic(&provider));
}
#[test]
fn test_anthropic_false_for_chat_and_responses() {
let chat = create_provider(json!({ "apiFormat": "openai_chat" }));
assert!(!codex_provider_uses_anthropic(&chat));
let responses = create_provider(json!({ "apiFormat": "openai_responses" }));
assert!(!codex_provider_uses_anthropic(&responses));
}
#[test]
fn test_anthropic_and_chat_are_mutually_exclusive() {
let anth = create_provider(json!({ "apiFormat": "anthropic" }));
assert!(codex_provider_uses_anthropic(&anth));
assert!(!codex_provider_uses_chat_completions(&anth));
let chat = create_provider(json!({ "apiFormat": "openai_chat" }));
assert!(codex_provider_uses_chat_completions(&chat));
assert!(!codex_provider_uses_anthropic(&chat));
}
#[test]
fn test_should_convert_responses_to_anthropic_path_guard() {
let provider = create_provider(json!({ "apiFormat": "anthropic" }));
assert!(should_convert_codex_responses_to_anthropic(
&provider,
"/responses"
));
assert!(should_convert_codex_responses_to_anthropic(
&provider,
"/v1/responses/compact"
));
assert!(should_convert_codex_responses_to_anthropic(
&provider,
"/responses?x=1"
));
assert!(!should_convert_codex_responses_to_anthropic(
&provider,
"/chat/completions"
));
}
#[test]
fn test_resolve_catalog_profile_matches_router() {
use crate::codex_config::CodexCatalogToolProfile;
// Anthropic declared only via TOML wire_api (no meta.api_format) must still
// resolve to the Anthropic catalog profile — this is the routing/catalog
// divergence that let apply_patch leak through.
let toml_anthropic = create_provider(json!({
"config": r#"model_provider = "custom"
[model_providers.custom]
wire_api = "anthropic"
"#
}));
assert_eq!(
resolve_codex_catalog_tool_profile(&toml_anthropic),
CodexCatalogToolProfile::Anthropic
);
// Anthropic via settings apiFormat.
let settings_anthropic = create_provider(json!({ "apiFormat": "anthropic" }));
assert_eq!(
resolve_codex_catalog_tool_profile(&settings_anthropic),
CodexCatalogToolProfile::Anthropic
);
// Native openai_responses (meta) → NativeResponses; chat → ProxyChat.
let mut native = create_provider(json!({}));
native.meta = Some(crate::provider::ProviderMeta {
api_format: Some("openai_responses".to_string()),
..Default::default()
});
assert_eq!(
resolve_codex_catalog_tool_profile(&native),
CodexCatalogToolProfile::NativeResponses
);
let chat = create_provider(json!({ "apiFormat": "openai_chat" }));
assert_eq!(
resolve_codex_catalog_tool_profile(&chat),
CodexCatalogToolProfile::ProxyChat
);
}
#[test]
fn test_apply_codex_upstream_model_preserves_one_m_catalog_model() {
// Regression for the [1m] path: a request model carrying the [1m] marker must
// match its catalog entry and be preserved (not overridden by the provider
// default) so the transform can later strip [1m] and emit the context-1m beta.
// This only works because the forwarder no longer strips [1m] before this call
// on the Anthropic path.
let provider = create_provider(json!({
"config": r#"model_provider = "custom"
model = "claude-opus-4-1"
[model_providers.custom]
wire_api = "anthropic"
"#,
"modelCatalog": {
"models": [
{ "model": "claude-opus-4-1[1m]" }
]
}
}));
let mut body = json!({ "model": "claude-opus-4-1[1m]", "input": "hi" });
let result = apply_codex_upstream_model(&provider, &mut body);
assert_eq!(result.as_deref(), Some("claude-opus-4-1[1m]"));
assert_eq!(
body.get("model").and_then(|v| v.as_str()),
Some("claude-opus-4-1[1m]")
);
}
#[test]
fn test_anthropic_auth_defaults_to_bearer() {
// No meta.apiKeyField (defaults to ANTHROPIC_AUTH_TOKEN) → Authorization: Bearer only
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"apiFormat": "anthropic",
"auth": { "OPENAI_API_KEY": "sk-anthropic-key-123" }
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.strategy, AuthStrategy::Bearer);
let headers = adapter.get_auth_headers(&auth).unwrap();
let names: Vec<String> = headers
.iter()
.map(|(name, _)| name.as_str().to_string())
.collect();
assert_eq!(names, vec!["authorization".to_string()]);
}
#[test]
fn test_anthropic_auth_x_api_key_when_selected() {
// meta.apiKeyField = ANTHROPIC_API_KEY → x-api-key only
let adapter = CodexAdapter::new();
let mut provider = create_provider(json!({
"apiFormat": "anthropic",
"auth": { "OPENAI_API_KEY": "sk-anthropic-key-123" }
}));
provider.meta = Some(crate::provider::ProviderMeta {
api_format: Some("anthropic".to_string()),
api_key_field: Some("ANTHROPIC_API_KEY".to_string()),
..Default::default()
});
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
let headers = adapter.get_auth_headers(&auth).unwrap();
let names: Vec<String> = headers
.iter()
.map(|(name, _)| name.as_str().to_string())
.collect();
assert_eq!(names, vec!["x-api-key".to_string()]);
}
#[test]
fn test_build_url_origin_adds_v1() {
let adapter = CodexAdapter::new();