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
+474 -14
View File
@@ -1122,6 +1122,14 @@ impl RequestForwarder {
== Some("github_copilot")
|| base_url.contains("githubcopilot.com");
// Codex upstream conversion mode — computed early because the [1m]-suffix strip
// below must be skipped on the Anthropic path (the marker has to survive to
// catalog matching and to the transform's own strip+beta detection).
let codex_responses_to_chat = matches!(app_type, AppType::Codex)
&& super::providers::should_convert_codex_responses_to_chat(provider, endpoint);
let codex_responses_to_anthropic = matches!(app_type, AppType::Codex)
&& super::providers::should_convert_codex_responses_to_anthropic(provider, endpoint);
// 应用模型映射(独立于格式转换)
// Claude Desktop proxy 模式必须先把 Desktop 可见的 claude-* route
// 映射成真实上游模型名,并且未知 route 要直接报错,不能使用默认模型兜底。
@@ -1142,7 +1150,11 @@ impl RequestForwarder {
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
self.apply_copilot_live_model_resolution(provider, &mut mapped_body)
.await;
} else {
} else if !codex_responses_to_anthropic {
// Skip on the Codex→Anthropic path: stripping [1m] here would break both the
// model-catalog match (apply_codex_upstream_model) and the transform's own
// strip+`context-1m` beta detection. The marker is stripped later, on the
// final anthropic_body.
mapped_body =
super::model_mapper::strip_one_m_suffix_for_upstream_from_body(mapped_body);
}
@@ -1300,10 +1312,22 @@ impl RequestForwarder {
Some(api_format) => super::providers::claude_api_format_needs_transform(api_format),
None => adapter.needs_transform(provider),
};
let codex_responses_to_chat = matches!(app_type, AppType::Codex)
&& super::providers::should_convert_codex_responses_to_chat(provider, endpoint);
// Codex → Anthropic: Claude Code emulation is off by default and only
// enabled when the user explicitly turns it on in the UI, so requests can
// pass a gateway's "Claude Code only" fingerprint check (User-Agent /
// anthropic-beta / x-app / system prompt first line). Defaulting to off
// avoids leaking the Claude Code fingerprint and identity prompt to
// general-purpose gateways.
let codex_impersonate_claude_code = codex_responses_to_anthropic
&& provider
.meta
.as_ref()
.and_then(|meta| meta.impersonate_claude_code)
== Some(true);
let (effective_endpoint, passthrough_query) = if codex_responses_to_chat {
rewrite_codex_responses_endpoint_to_chat(endpoint)
} else if codex_responses_to_anthropic {
rewrite_codex_responses_endpoint_to_anthropic(endpoint)
} else if needs_transform && adapter.name() == "Claude" {
let api_format = resolved_claude_api_format
.as_deref()
@@ -1318,11 +1342,16 @@ impl RequestForwarder {
)
};
let codex_chat_base_is_full_endpoint = codex_responses_to_chat
&& base_url
.trim_end_matches('/')
.to_ascii_lowercase()
.ends_with("/chat/completions");
let codex_chat_base_is_full_endpoint =
codex_responses_to_chat && base_url_is_full_endpoint(&base_url, "/chat/completions");
// Defensive fallback mirroring `codex_chat_base_is_full_endpoint`: if a user pastes
// a base URL already ending in the Anthropic `/v1/messages` endpoint but leaves the
// "full URL" switch off, treat it as a full endpoint so we don't double-append
// `/v1/messages` (→ `.../v1/messages/v1/messages`, a non-retryable 400). Matches the
// exact endpoint suffix, so prefixed gateways like `.../api/v1/messages` are covered.
let codex_anthropic_base_is_full_endpoint =
codex_responses_to_anthropic && base_url_is_full_endpoint(&base_url, "/v1/messages");
let url = if matches!(resolved_claude_api_format.as_deref(), Some("gemini_native")) {
super::gemini_url::resolve_gemini_native_url(
@@ -1330,7 +1359,10 @@ impl RequestForwarder {
&effective_endpoint,
is_full_url,
)
} else if is_full_url || codex_chat_base_is_full_endpoint {
} else if is_full_url
|| codex_chat_base_is_full_endpoint
|| codex_anthropic_base_is_full_endpoint
{
append_query_to_full_url(&base_url, passthrough_query.as_deref())
} else {
adapter.build_url(&base_url, &effective_endpoint)
@@ -1345,6 +1377,10 @@ impl RequestForwarder {
.filter(|m| !m.is_empty())
.map(str::to_string);
// Codex→Anthropic: when the model name carries the [1m] marker, strip the
// suffix and add the context-1m beta header.
let mut codex_anthropic_one_m = false;
// 转换请求体(如果需要)
let mut request_body = if codex_responses_to_chat {
let mut mapped_body = mapped_body;
@@ -1364,6 +1400,67 @@ impl RequestForwarder {
mapped_body,
reasoning_config.as_ref(),
)?
} else if codex_responses_to_anthropic {
let mut mapped_body = mapped_body;
super::providers::apply_codex_upstream_model(provider, &mut mapped_body);
// Per-provider output ceiling override. Codex does not forward its
// `model_max_output_tokens` in the request body, so honor the value
// configured on the provider here — it takes precedence over any
// request-supplied `max_output_tokens` and over the default below.
// Injecting it into the body (rather than overriding after transform)
// lets the thinking-budget clamp size its headroom against the real
// ceiling too. Kept per-provider to avoid a global large default that
// would 400 on low-output-ceiling gateways.
if let Some(max_out) = provider
.meta
.as_ref()
.and_then(|meta| meta.max_output_tokens)
.filter(|v| *v > 0)
{
mapped_body["max_output_tokens"] = Value::from(max_out);
}
// Anthropic requires max_tokens; fall back to this default only when the
// Codex request omits max_output_tokens (rare — Codex normally sends it).
// Kept conservative so a low-output-ceiling model or relay does not hard-400
// on the fallback (a too-high default 400s and is non-retryable); 8192 is
// accepted by every current Claude model and virtually all gateways. The
// transform clamps any thinking budget below this value.
const DEFAULT_CODEX_ANTHROPIC_MAX_TOKENS: u64 = 8192;
let mut anthropic_body =
super::providers::transform_codex_anthropic::responses_request_to_anthropic(
mapped_body,
DEFAULT_CODEX_ANTHROPIC_MAX_TOKENS,
)?;
// Handle the 1M-context marker [1m]: strip the model-name suffix (the
// gateway doesn't recognize it) and set the flag so the beta header is
// added. apply_codex_upstream_model may have just written back a model
// name carrying [1m] from the provider config, so strip it once more on
// the final body here.
if let Some(model) = anthropic_body.get("model").and_then(|v| v.as_str()) {
let stripped = super::model_mapper::strip_one_m_suffix_for_upstream(model);
if stripped != model {
codex_anthropic_one_m = true;
anthropic_body["model"] = Value::String(stripped.to_string());
}
}
if codex_impersonate_claude_code {
prepend_claude_code_system_prompt(&mut anthropic_body);
}
// Enable Anthropic prompt caching (no beta header required). Reuse the
// configured TTL rather than silently forcing 5m on this conversion path.
// otherwise system/tools/history are re-sent at full price every round,
// inflating cost and first-token latency. The injector handles the
// string→array `system` conversion and the 4-breakpoint cap.
super::cache_injector::inject(
&mut anthropic_body,
&super::types::OptimizerConfig {
enabled: true,
thinking_optimizer: false,
cache_injection: true,
cache_ttl: self.optimizer_config.cache_ttl.clone(),
},
);
anthropic_body
} else if needs_transform {
if adapter.name() == "Claude" {
let api_format = resolved_claude_api_format
@@ -1420,8 +1517,10 @@ impl RequestForwarder {
);
let request_is_streaming =
is_streaming_request(&effective_endpoint, &filtered_body, headers);
let force_identity_encoding =
needs_transform || codex_responses_to_chat || request_is_streaming;
let force_identity_encoding = needs_transform
|| codex_responses_to_chat
|| codex_responses_to_anthropic
|| request_is_streaming;
// Codex OAuth 需要注入的 ChatGPT-Account-Id(在动态 token 获取期间填充)
let mut codex_oauth_account_id: Option<String> = None;
@@ -1563,6 +1662,13 @@ impl RequestForwarder {
.as_ref()
.and_then(|meta| meta.custom_user_agent_header().ok().flatten())
};
// Codex→Anthropic emulation: when there is no custom UA, override Codex's
// codex_cli_rs UA with the Claude Code UA.
let custom_user_agent = if custom_user_agent.is_none() && codex_impersonate_claude_code {
Some(http::HeaderValue::from_static(CLAUDE_CODE_USER_AGENT))
} else {
custom_user_agent
};
// --- Copilot 优化器:动态 header 注入 ---
if let Some((ref classification, ref det_request_id, ref interaction_id)) =
@@ -1648,6 +1754,17 @@ impl RequestForwarder {
} else {
CLAUDE_CODE_BETA.to_string()
})
} else if codex_impersonate_claude_code || codex_anthropic_one_m {
// Codex→Anthropic: emulation injects the claude-code marker; a [1m]
// model injects the context-1m marker.
let mut betas: Vec<&str> = Vec::new();
if codex_impersonate_claude_code {
betas.push("claude-code-20250219");
}
if codex_anthropic_one_m {
betas.push("context-1m-2025-08-07");
}
Some(betas.join(","))
} else {
None
};
@@ -1658,6 +1775,7 @@ impl RequestForwarder {
let mut ordered_headers = http::HeaderMap::new();
let mut saw_auth = false;
let mut saw_accept_encoding = false;
let mut saw_accept = false;
let mut saw_user_agent = false;
let mut saw_anthropic_beta = false;
let mut saw_anthropic_version = false;
@@ -1723,6 +1841,37 @@ impl RequestForwarder {
continue;
}
// --- x-app — during Codex→Anthropic emulation, `cli` is injected uniformly below ---
if codex_impersonate_claude_code && key_str.eq_ignore_ascii_case("x-app") {
continue;
}
// --- Codex/OpenAI fingerprint headers — never leak to an Anthropic upstream ---
// These are client/session identifiers from the incoming Codex request,
// not Anthropic protocol headers. Forwarding them both leaks identity and
// can defeat strict gateway fingerprint checks.
// The full set lives in `is_codex_client_fingerprint_header` so it stays in one
// place. (HeaderName is lowercased by the http crate, so a direct match is safe.)
if codex_responses_to_anthropic && is_codex_client_fingerprint_header(key_str) {
continue;
}
// --- accept — force application/json on the Codex→Anthropic path ---
// The Codex CLI sends `Accept: text/event-stream`, whereas a native
// Anthropic client sends `application/json` (streaming is driven by
// the body's stream:true). Strict Anthropic gateways return 406 Not
// Acceptable for an event-stream Accept, so normalize it here.
if codex_responses_to_anthropic && key_str.eq_ignore_ascii_case("accept") {
if !saw_accept {
saw_accept = true;
ordered_headers.append(
http::header::ACCEPT,
http::HeaderValue::from_static("application/json"),
);
}
continue;
}
// --- accept-encoding — transform / SSE 路径强制 identity,其余保留原值 ---
if key_str.eq_ignore_ascii_case("accept-encoding") {
if !saw_accept_encoding {
@@ -1801,6 +1950,19 @@ impl RequestForwarder {
);
}
// On the Codex→Anthropic path, add application/json when Accept is missing (matching a native Anthropic client).
if codex_responses_to_anthropic && !saw_accept {
ordered_headers.append(
http::header::ACCEPT,
http::HeaderValue::from_static("application/json"),
);
}
// Codex→Anthropic emulation: inject Claude Code's x-app: cli
if codex_impersonate_claude_code {
ordered_headers.append("x-app", http::HeaderValue::from_static("cli"));
}
if !saw_user_agent {
if let Some(ref ua) = custom_user_agent {
ordered_headers.append(http::header::USER_AGENT, ua.clone());
@@ -1816,8 +1978,13 @@ impl RequestForwarder {
}
}
// anthropic-version:仅在缺失时补充默认值
if should_send_anthropic_headers && !saw_anthropic_version {
// anthropic-version: add the default only when it is missing.
// The Codex→Anthropic path also needs this header. Note this is independent
// of anthropic-beta: the Claude Code-specific beta is only sent when
// impersonation is on (handled above); on the plain Codex→Anthropic path
// (impersonation off) anthropic-version is still required but no beta is sent.
if (should_send_anthropic_headers || codex_responses_to_anthropic) && !saw_anthropic_version
{
ordered_headers.append(
"anthropic-version",
http::HeaderValue::from_static("2023-06-01"),
@@ -1960,9 +2127,18 @@ impl RequestForwarder {
let status = response.status();
if status.is_success() {
let response = self
let mut response = self
.prepare_success_response_for_failover(response, request_is_streaming)
.await?;
// Streaming requests normally return SSE. If a compatible gateway
// explicitly returns JSON instead, buffer and validate it inside the retry
// loop as well so a 2xx Anthropic error envelope can still fail over. Do
// not buffer unknown content types: some gateways omit the SSE header.
if codex_responses_to_anthropic && (!request_is_streaming || response.is_json()) {
response = self
.validate_codex_anthropic_success_response(response)
.await?;
}
Ok((response, resolved_claude_api_format, outbound_model))
} else {
let status_code = status.as_u16();
@@ -2020,6 +2196,34 @@ impl RequestForwarder {
Ok(ProxyResponse::buffered(status, headers, body))
}
/// Some Anthropic-compatible gateways return an Anthropic error envelope with
/// HTTP 2xx. Validate it inside the retry loop so the request can fail over to
/// the next provider; the response transformer runs too late for that.
async fn validate_codex_anthropic_success_response(
&self,
response: ProxyResponse,
) -> Result<ProxyResponse, ProxyError> {
let status = response.status();
let headers = response.headers().clone();
let encoding = get_content_encoding(&headers);
let raw = response.bytes().await?;
let decoded = match encoding {
Some(encoding) => match decompress_body(&encoding, &raw) {
Ok(Some(decompressed)) => decompressed,
_ => raw.to_vec(),
},
None => raw.to_vec(),
};
if let Some(message) = codex_anthropic_error_envelope_message(&decoded) {
return Err(ProxyError::TransformError(format!(
"Anthropic upstream returned a 2xx error envelope: {message}"
)));
}
Ok(ProxyResponse::buffered(status, headers, raw))
}
async fn prime_streaming_response(
&self,
response: ProxyResponse,
@@ -2355,6 +2559,111 @@ fn rewrite_codex_responses_endpoint_to_chat(endpoint: &str) -> (String, Option<S
(rewritten, passthrough_query)
}
/// Claude Code client fingerprint (used for Codex→Anthropic emulation to pass a
/// gateway's "Claude Code only" check).
const CLAUDE_CODE_USER_AGENT: &str = "claude-cli/1.0.119 (external, cli)";
const CLAUDE_CODE_SYSTEM_IDENTITY: &str =
"You are Claude Code, Anthropic's official CLI for Claude.";
/// Insert the Claude Code identity as the first line before the `system` field in
/// the Anthropic request body.
///
/// Anthropic subscription/OAuth plans require the first system block to be exactly
/// this identity line. After conversion `system` is a string (from Codex
/// instructions); normalize it into an array here: [identity line, original system...].
fn prepend_claude_code_system_prompt(body: &mut Value) {
let identity = serde_json::json!({ "type": "text", "text": CLAUDE_CODE_SYSTEM_IDENTITY });
let mut blocks: Vec<Value> = vec![identity];
match body.get("system") {
Some(Value::String(existing)) if !existing.is_empty() => {
blocks.push(serde_json::json!({ "type": "text", "text": existing }));
}
Some(Value::Array(existing)) => {
// Idempotent: skip re-injection if the first block is already the identity line.
if existing
.first()
.and_then(|b| b.get("text"))
.and_then(|t| t.as_str())
== Some(CLAUDE_CODE_SYSTEM_IDENTITY)
{
return;
}
blocks.extend(existing.iter().cloned());
}
_ => {}
}
body["system"] = Value::Array(blocks);
}
/// Headers a native Claude Code client never sends but the Codex/OpenAI CLI (and its
/// stainless SDK layer) do. Dropped for every Codex→Anthropic request so the upstream sees a
/// clean Anthropic client fingerprint. Centralized here so the set stays in one place and future
/// additions can't miss a code path. `key_str` is already lowercased by the http crate.
/// Whether `base_url` already ends in `endpoint_suffix` (e.g. `/v1/messages` or
/// `/chat/completions`), ignoring surrounding whitespace, any `?query`/`#fragment`, and a
/// trailing slash. Used to avoid double-appending the endpoint when a user pastes a full
/// URL but leaves the "full URL" switch off (`.../v1/messages` → `.../v1/messages/v1/messages`,
/// a non-retryable 400). `endpoint_suffix` must be lowercase.
fn base_url_is_full_endpoint(base_url: &str, endpoint_suffix: &str) -> bool {
let trimmed = base_url.trim();
// Match against the path only: a `?query`/`#fragment` on a full endpoint URL must not
// hide the suffix (`.../v1/messages?beta=true` still ends in the endpoint).
let path = match trimmed.split_once(['?', '#']) {
Some((head, _)) => head,
None => trimmed,
};
path.trim_end_matches('/')
.to_ascii_lowercase()
.ends_with(endpoint_suffix)
}
fn is_codex_client_fingerprint_header(key_str: &str) -> bool {
matches!(
key_str,
"originator"
| "session_id"
| "session-id"
| "thread-id"
| "conversation_id"
| "chatgpt-account-id"
| "x-openai-subagent"
| "x-client-request-id"
| "openai-beta"
| "openai-organization"
| "openai-project"
) || key_str.starts_with("x-stainless-")
|| key_str.starts_with("x-codex-")
}
fn codex_anthropic_error_envelope_message(body: &[u8]) -> Option<String> {
let value: Value = serde_json::from_slice(body).ok()?;
if value.get("type").and_then(Value::as_str) != Some("error") && value.get("error").is_none() {
return None;
}
let error = value.get("error").unwrap_or(&value);
let error_type = error.get("type").and_then(Value::as_str).unwrap_or("error");
let message = error
.get("message")
.and_then(Value::as_str)
.or_else(|| error.as_str())
.map(str::to_string)
.unwrap_or_else(|| error.to_string());
Some(format!("{error_type}: {message}"))
}
/// Rewrite Codex's `/responses` (and variants) to Anthropic's `/v1/messages`, preserving the query.
fn rewrite_codex_responses_endpoint_to_anthropic(endpoint: &str) -> (String, Option<String>) {
let (_path, query) = split_endpoint_and_query(endpoint);
let passthrough_query = query.map(ToString::to_string);
let target_path = "/v1/messages";
let rewritten = match passthrough_query.as_deref() {
Some(query) if !query.is_empty() => format!("{target_path}?{query}"),
_ => target_path.to_string(),
};
(rewritten, passthrough_query)
}
fn rewrite_claude_transform_endpoint(
endpoint: &str,
api_format: &str,
@@ -3347,6 +3656,157 @@ mod tests {
assert_eq!(passthrough_query.as_deref(), Some("foo=bar"));
}
#[test]
fn prepend_claude_code_system_prompt_from_string() {
let mut body = json!({ "system": "You are a Codex agent." });
prepend_claude_code_system_prompt(&mut body);
let system = body["system"].as_array().unwrap();
assert_eq!(system[0]["text"], CLAUDE_CODE_SYSTEM_IDENTITY);
assert_eq!(system[1]["text"], "You are a Codex agent.");
}
#[test]
fn prepend_claude_code_system_prompt_when_absent() {
let mut body = json!({});
prepend_claude_code_system_prompt(&mut body);
let system = body["system"].as_array().unwrap();
assert_eq!(system.len(), 1);
assert_eq!(system[0]["text"], CLAUDE_CODE_SYSTEM_IDENTITY);
}
#[test]
fn prepend_claude_code_system_prompt_is_idempotent() {
let mut body = json!({ "system": "orig" });
prepend_claude_code_system_prompt(&mut body);
prepend_claude_code_system_prompt(&mut body);
let system = body["system"].as_array().unwrap();
assert_eq!(system.len(), 2);
assert_eq!(system[0]["text"], CLAUDE_CODE_SYSTEM_IDENTITY);
assert_eq!(system[1]["text"], "orig");
}
#[test]
fn rewrite_codex_responses_endpoint_to_anthropic_preserves_query() {
let (endpoint, passthrough_query) =
rewrite_codex_responses_endpoint_to_anthropic("/responses?x=1");
assert_eq!(endpoint, "/v1/messages?x=1");
assert_eq!(passthrough_query.as_deref(), Some("x=1"));
let (endpoint, _) = rewrite_codex_responses_endpoint_to_anthropic("/v1/responses");
assert_eq!(endpoint, "/v1/messages");
}
#[test]
fn codex_anthropic_full_endpoint_guard_avoids_double_messages() {
// On the Codex→Anthropic path a base URL already ending in `/v1/messages` (switch
// off) must be treated as a full endpoint by the real `base_url_is_full_endpoint`.
// Without the guard, build_url would concatenate the pasted endpoint with the
// rewritten `/v1/messages` target, producing a broken double suffix.
use super::super::providers::ProviderAdapter;
let doubled = super::super::providers::CodexAdapter::new()
.build_url("https://host.example/v1/messages", "/v1/messages");
assert_eq!(doubled, "https://host.example/v1/messages/v1/messages");
// With the guard, the pasted URL is used verbatim (plus preserved query). Includes
// query/fragment/whitespace suffixes, which must not hide the endpoint (fix: a base
// like `.../v1/messages?beta=true` previously evaded the suffix check).
for base in [
"https://host.example/v1/messages",
"https://host.example/v1/messages/",
"https://host.example/api/v1/messages", // prefixed gateway
"https://host.example/v1/messages?beta=true",
"https://host.example/v1/messages/?beta=true",
"https://host.example/v1/messages#frag",
" https://host.example/v1/messages ",
] {
assert!(
base_url_is_full_endpoint(base, "/v1/messages"),
"expected full-endpoint match: {base:?}"
);
}
assert_eq!(
append_query_to_full_url("https://host.example/v1/messages", Some("x=1")),
"https://host.example/v1/messages?x=1"
);
// A base URL that already carries its own query is preserved verbatim (no double
// `/v1/messages`, query kept).
assert_eq!(
append_query_to_full_url("https://host.example/v1/messages?beta=true", None),
"https://host.example/v1/messages?beta=true"
);
// A non-endpoint base (origin/prefix) must NOT match, so build_url still appends.
assert!(!base_url_is_full_endpoint(
"https://host.example",
"/v1/messages"
));
assert!(!base_url_is_full_endpoint(
"https://host.example/v1",
"/v1/messages"
));
// The shared helper also backs the Chat path's `/chat/completions` guard.
assert!(base_url_is_full_endpoint(
"https://host.example/v1/chat/completions?api-version=2024",
"/chat/completions"
));
}
#[test]
fn codex_client_fingerprint_headers_are_dropped_for_anthropic_upstreams() {
// Codex/OpenAI fingerprints a native Claude Code client never sends → must drop.
for header in [
"originator",
"session_id",
"session-id",
"thread-id",
"conversation_id",
"chatgpt-account-id",
"x-openai-subagent",
"x-client-request-id",
"x-codex-window-id",
"openai-beta",
"openai-organization",
"openai-project",
"x-stainless-lang",
"x-stainless-runtime",
"x-codex-turn-id",
] {
assert!(
is_codex_client_fingerprint_header(header),
"expected {header} to be dropped while impersonating Claude Code"
);
}
// Headers a real Claude Code client sends (or that the forwarder rebuilds) must
// NOT be caught by the denylist.
for header in [
"anthropic-version",
"anthropic-beta",
"user-agent",
"accept",
"content-type",
"x-app",
] {
assert!(
!is_codex_client_fingerprint_header(header),
"{header} must be preserved while impersonating Claude Code"
);
}
}
#[test]
fn codex_anthropic_2xx_error_envelope_is_detected_for_failover() {
let body = br#"{"type":"error","error":{"type":"overloaded_error","message":"busy"}}"#;
assert_eq!(
codex_anthropic_error_envelope_message(body).as_deref(),
Some("overloaded_error: busy")
);
assert!(
codex_anthropic_error_envelope_message(br#"{"type":"message","content":[]}"#).is_none()
);
}
#[test]
fn rewrite_codex_responses_compact_endpoint_to_chat_preserves_query() {
let (endpoint, passthrough_query) =