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
+35 -20
View File
@@ -76,32 +76,28 @@ pub fn inject(body: &mut Value, config: &OptimizerConfig) {
}
}
// (c) 最后一条 assistant 消息的最后一个非 thinking block
// (c) 最后一条可缓存消息的最后一个非 thinking block。工具循环通常以
// user/tool_result 结束;只标 assistant 会让最新稳定前缀无法命中缓存。
if budget > 0 {
if let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) {
if let Some(assistant_msg) = messages
.iter_mut()
.rev()
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
{
if let Some(content) = assistant_msg
.get_mut("content")
.and_then(|c| c.as_array_mut())
{
// 逆序找最后一个非 thinking/redacted_thinking block
if let Some(block) = content.iter_mut().rev().find(|b| {
let bt = b.get("type").and_then(|t| t.as_str()).unwrap_or("");
bt != "thinking" && bt != "redacted_thinking"
'messages: for message in messages.iter_mut().rev() {
if let Some(content) = message.get_mut("content").and_then(|c| c.as_array_mut()) {
if let Some(block) = content.iter_mut().rev().find(|block| {
!matches!(
block.get("type").and_then(Value::as_str),
Some("thinking" | "redacted_thinking")
)
}) {
if block.get("cache_control").is_none() {
if let Some(o) = block.as_object_mut() {
o.insert(
if let Some(object) = block.as_object_mut() {
object.insert(
"cache_control".to_string(),
make_cache_control(&config.cache_ttl),
);
injected.push("msgs");
}
injected.push("msgs");
}
break 'messages;
}
}
}
@@ -206,10 +202,10 @@ mod tests {
#[test]
fn test_empty_body_no_injection() {
let mut body = json!({"model": "test", "messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]});
let original = body.clone();
inject(&mut body, &default_config());
// No tools, no system, no assistant → no injection
assert_eq!(body, original);
assert!(body["messages"][0]["content"][0]
.get("cache_control")
.is_some());
}
#[test]
@@ -374,4 +370,23 @@ mod tests {
.get("cache_control")
.is_none());
}
#[test]
fn test_injects_latest_tool_result_instead_of_older_assistant() {
let mut body = json!({
"messages": [
{"role": "assistant", "content": [{"type": "tool_use", "id": "call_1", "name": "Read", "input": {}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "call_1", "content": "done"}]}
]
});
inject(&mut body, &default_config());
assert!(body["messages"][0]["content"][0]
.get("cache_control")
.is_none());
assert!(body["messages"][1]["content"][0]
.get("cache_control")
.is_some());
}
}
+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) =
+282 -3
View File
@@ -18,12 +18,18 @@ use super::{
handler_context::RequestContext,
providers::{
codex_chat_common::extract_reasoning_field_text,
codex_chat_history::record_responses_sse_stream, get_adapter, get_claude_api_format,
codex_chat_history::record_responses_sse_stream,
get_adapter, get_claude_api_format,
streaming::create_anthropic_sse_stream,
streaming_codex_anthropic::{
create_responses_sse_stream_from_anthropic_with_context,
responses_sse_events_from_anthropic_message,
},
streaming_codex_chat::create_responses_sse_stream_from_chat_with_context,
streaming_gemini::create_anthropic_sse_stream_from_gemini,
streaming_responses::create_anthropic_sse_stream_from_responses, transform,
transform_codex_chat, transform_gemini, transform_responses,
streaming_responses::create_anthropic_sse_stream_from_responses,
transform, transform_codex_anthropic, transform_codex_chat, transform_gemini,
transform_responses,
},
response_processor::{
create_logged_passthrough_stream, process_response, read_decoded_body,
@@ -739,6 +745,18 @@ pub async fn handle_responses(
ctx.provider = result.provider;
let response = result.response;
if super::providers::should_convert_codex_responses_to_anthropic(&ctx.provider, &endpoint) {
return handle_codex_anthropic_to_responses_transform(
response,
&ctx,
&state,
is_stream,
connection_guard,
codex_tool_context,
)
.await;
}
if super::providers::should_convert_codex_responses_to_chat(&ctx.provider, &endpoint) {
return handle_codex_chat_to_responses_transform(
response,
@@ -818,6 +836,18 @@ pub async fn handle_responses_compact(
ctx.provider = result.provider;
let response = result.response;
if super::providers::should_convert_codex_responses_to_anthropic(&ctx.provider, &endpoint) {
return handle_codex_anthropic_to_responses_transform(
response,
&ctx,
&state,
is_stream,
connection_guard,
codex_tool_context,
)
.await;
}
if super::providers::should_convert_codex_responses_to_chat(&ctx.provider, &endpoint) {
return handle_codex_chat_to_responses_transform(
response,
@@ -1065,6 +1095,255 @@ async fn handle_codex_chat_to_responses_transform(
})
}
/// Response-transform handler for the Codex (Responses) ↔ Anthropic Messages gateway.
///
/// Parallel to `handle_codex_chat_to_responses_transform`: the upstream speaks
/// Anthropic Messages, and this converts the response back into the Responses form
/// Codex expects (both streaming and non-streaming). Error bodies reuse
/// `handle_codex_chat_error_response` (whose extraction logic also works for
/// Anthropic's `{"error":{type,message}}`). It does not involve codex_chat_history
/// (tool ids round-trip natively through Anthropic).
async fn handle_codex_anthropic_to_responses_transform(
response: super::hyper_client::ProxyResponse,
ctx: &RequestContext,
state: &ProxyState,
is_stream: bool,
connection_guard: Option<ActiveConnectionGuard>,
codex_tool_context: transform_codex_chat::CodexToolContext,
) -> Result<axum::response::Response, ProxyError> {
let status = response.status();
if !status.is_success() {
return handle_codex_chat_error_response(response, ctx, status).await;
}
// Preserve live streaming when the gateway marks SSE correctly or omits an
// explicit JSON media type. Explicit JSON is buffered below so 2xx error
// envelopes and gateways that ignore stream:true can be converted faithfully.
if response.is_sse() || (is_stream && !response.is_json()) {
let stream = response.bytes_stream();
let sse_stream =
create_responses_sse_stream_from_anthropic_with_context(stream, codex_tool_context);
return build_codex_anthropic_sse_response(
sse_stream,
ctx,
state,
status,
connection_guard,
);
}
let body_timeout =
if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 {
std::time::Duration::from_secs(ctx.app_config.non_streaming_timeout as u64)
} else {
std::time::Duration::ZERO
};
let (mut response_headers, status, body_bytes) =
read_decoded_body(response, ctx.tag, body_timeout).await?;
let body_str = String::from_utf8_lossy(&body_bytes);
let anthropic_response: Value = match serde_json::from_slice(&body_bytes) {
Ok(value) => value,
// Fallback sniffing symmetric to the chat / claude side (#2234): when the
// upstream returns an Anthropic SSE body with an unmarked Content-Type,
// aggregate it back into a message before continuing the conversion.
Err(_) if body_looks_like_sse(&body_str) => {
log::warn!("[Codex] Upstream returned an unmarked Anthropic SSE body, falling back to aggregation");
transform_codex_anthropic::anthropic_sse_to_message_value(&body_str).map_err(|e| {
log::error!("[Codex] Failed to aggregate Anthropic SSE body: {e}");
e
})?
}
Err(e) => {
log::error!(
"[Codex] Failed to parse Anthropic upstream response: {e}, body: {body_str}"
);
return Err(upstream_body_parse_error(
"Failed to parse upstream anthropic response",
&e,
&response_headers,
&body_str,
));
}
};
if is_stream {
let events =
responses_sse_events_from_anthropic_message(&anthropic_response, codex_tool_context);
let sse_stream = futures::stream::iter(events.into_iter().map(Ok::<Bytes, std::io::Error>));
return build_codex_anthropic_sse_response(
sse_stream,
ctx,
state,
status,
connection_guard,
);
}
let _connection_guard = connection_guard;
let responses_response =
transform_codex_anthropic::anthropic_response_to_responses_with_context(
anthropic_response,
&codex_tool_context,
)
.map_err(|e| {
log::error!("[Codex] Failed to convert Anthropic response to Responses: {e}");
e
})?;
if let Some(usage) = TokenUsage::from_codex_response_auto(&responses_response)
.filter(TokenUsage::has_billable_tokens)
{
let model = responses_response
.get("model")
.and_then(|m| m.as_str())
.filter(|m| !m.is_empty())
.map(str::to_string)
.or_else(|| ctx.outbound_model.clone())
.unwrap_or_else(|| ctx.request_model.clone());
let request_model = ctx.request_model.clone();
let outbound_model = ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone());
let app_type_str = ctx.app_type_str;
tokio::spawn({
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let session_id = ctx.session_id.clone();
let latency_ms = ctx.latency_ms();
async move {
log_usage(
&state,
&provider_id,
app_type_str,
&model,
&request_model,
&outbound_model,
usage,
latency_ms,
None,
false,
status.as_u16(),
Some(session_id),
)
.await;
}
});
}
strip_entity_headers_for_rebuilt_body(&mut response_headers);
strip_hop_by_hop_response_headers(&mut response_headers);
response_headers.remove(axum::http::header::CONTENT_TYPE);
let mut builder = axum::response::Response::builder().status(status);
for (key, value) in response_headers.iter() {
builder = builder.header(key, value);
}
builder = builder.header(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/json"),
);
let response_body = serde_json::to_vec(&responses_response).map_err(|e| {
log::error!("[Codex] Failed to serialize Responses response: {e}");
ProxyError::TransformError(format!("Failed to serialize responses response: {e}"))
})?;
builder
.body(axum::body::Body::from(response_body))
.map_err(|e| {
log::error!("[Codex] Failed to build Responses response: {e}");
ProxyError::Internal(format!("Failed to build response: {e}"))
})
}
fn build_codex_anthropic_sse_response(
sse_stream: impl futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,
ctx: &RequestContext,
state: &ProxyState,
status: StatusCode,
connection_guard: Option<ActiveConnectionGuard>,
) -> Result<axum::response::Response, ProxyError> {
let usage_collector = if usage_logging_enabled(state) {
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let request_model = ctx.request_model.clone();
let fallback_model = ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone());
let app_type_str = ctx.app_type_str;
let start_time = ctx.start_time;
let session_id = ctx.session_id.clone();
Some(SseUsageCollector::new(
start_time,
Some(codex_stream_usage_event_filter),
move |events, first_token_ms| {
let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap_or_default();
if !usage.has_billable_tokens() {
log::debug!("[Codex] Anthropic streaming response usage is all-zero or missing, skipping usage recording");
return;
}
let model = usage
.model
.clone()
.filter(|m| !m.is_empty())
.unwrap_or_else(|| fallback_model.clone());
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let request_model = request_model.clone();
let outbound_model = fallback_model.clone();
let session_id = session_id.clone();
tokio::spawn(async move {
log_usage(
&state,
&provider_id,
app_type_str,
&model,
&request_model,
&outbound_model,
usage,
latency_ms,
first_token_ms,
true,
status.as_u16(),
Some(session_id),
)
.await;
});
},
))
} else {
None
};
let logged_stream = create_logged_passthrough_stream(
sse_stream,
ctx.tag,
usage_collector,
ctx.streaming_timeout_config(),
connection_guard,
);
let mut headers = axum::http::HeaderMap::new();
headers.insert(
"Content-Type",
axum::http::HeaderValue::from_static("text/event-stream"),
);
headers.insert(
"Cache-Control",
axum::http::HeaderValue::from_static("no-cache"),
);
let body = axum::body::Body::from_stream(logged_stream);
Ok((headers, body).into_response())
}
/// 把上游 Chat Completions 的错误响应转换为 Responses API 错误形状。
///
/// 与正常响应分支配套:正常响应已经被改写成 Responses 形式,错误响应若仍保留
+39
View File
@@ -142,6 +142,21 @@ impl ProxyResponse {
.unwrap_or(false)
}
/// Check whether the response explicitly declares a JSON media type.
pub fn is_json(&self) -> bool {
self.content_type()
.map(|content_type| {
let media_type = content_type
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
media_type == "application/json" || media_type.ends_with("+json")
})
.unwrap_or(false)
}
/// Consume the response and collect the full body into `Bytes`.
pub async fn bytes(self) -> Result<Bytes, ProxyError> {
match self {
@@ -737,3 +752,27 @@ impl<S: Unpin> tokio::io::AsyncWrite for WriteFilter<S> {
std::task::Poll::Ready(Ok(()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn buffered_with_content_type(content_type: Option<&str>) -> ProxyResponse {
let mut headers = http::HeaderMap::new();
if let Some(content_type) = content_type {
headers.insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_str(content_type).unwrap(),
);
}
ProxyResponse::buffered(http::StatusCode::OK, headers, Bytes::new())
}
#[test]
fn json_content_type_detection_accepts_json_suffixes() {
assert!(buffered_with_content_type(Some("application/json; charset=utf-8")).is_json());
assert!(buffered_with_content_type(Some("application/problem+json")).is_json());
assert!(!buffered_with_content_type(Some("text/event-stream")).is_json());
assert!(!buffered_with_content_type(None).is_json());
}
}
+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();
@@ -0,0 +1,399 @@
//! Shared builders for the OpenAI Responses SSE envelope.
//!
//! The two Codex streaming converters — `streaming_codex_chat` (Chat Completions SSE →
//! Responses SSE) and `streaming_codex_anthropic` (Anthropic Messages SSE → Responses
//! SSE) — have completely different *input* state machines but must emit the identical
//! Responses event stream the Codex client understands. This module owns that output
//! envelope so the two converters cannot drift when an event's shape changes: a wire fix
//! lands here once instead of being mirrored in both files.
//!
//! Each function is pure — it takes primitives or a caller-built `item` `Value` and
//! returns the exact bytes the converters previously constructed inline. Item shapes that
//! vary per converter (including function, namespace, custom, and tool-search calls)
//! are supplied by the caller via the generic
//! `output_item_added` / `output_item_done` helpers.
use bytes::Bytes;
use serde_json::{json, Value};
/// Serialize one Responses SSE event with the standard `event:`/`data:` framing.
pub(crate) fn sse_event(event: &str, data: Value) -> Bytes {
Bytes::from(format!(
"event: {event}\ndata: {}\n\n",
serde_json::to_string(&data).unwrap_or_default()
))
}
// ---------------------------------------------------------------------------
// Response lifecycle (created / in_progress / completed / failed)
// ---------------------------------------------------------------------------
/// `response.created`, wrapping a caller-built `response` object (usage/created_at differ
/// per converter, so the caller supplies the whole object).
pub(crate) fn response_created(response: &Value) -> Bytes {
sse_event(
"response.created",
json!({ "type": "response.created", "response": response }),
)
}
/// `response.in_progress`.
pub(crate) fn response_in_progress(response: &Value) -> Bytes {
sse_event(
"response.in_progress",
json!({ "type": "response.in_progress", "response": response }),
)
}
/// `response.completed`.
pub(crate) fn response_completed(response: &Value) -> Bytes {
sse_event(
"response.completed",
json!({ "type": "response.completed", "response": response }),
)
}
/// `response.failed`.
pub(crate) fn response_failed(response: &Value) -> Bytes {
sse_event(
"response.failed",
json!({ "type": "response.failed", "response": response }),
)
}
// ---------------------------------------------------------------------------
// Generic output-item add/done (item value supplied by the caller)
// ---------------------------------------------------------------------------
/// `response.output_item.added` with a caller-built item (message / reasoning /
/// function_call / custom_tool_call).
pub(crate) fn output_item_added(output_index: u32, item: &Value) -> Bytes {
sse_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"output_index": output_index,
"item": item
}),
)
}
/// `response.output_item.done` with a caller-built item.
pub(crate) fn output_item_done(output_index: u32, item: &Value) -> Bytes {
sse_event(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"output_index": output_index,
"item": item
}),
)
}
// ---------------------------------------------------------------------------
// Assistant message (text) lifecycle
// ---------------------------------------------------------------------------
/// `response.output_item.added` for an in-progress assistant message.
pub(crate) fn message_item_added(output_index: u32, item_id: &str) -> Bytes {
output_item_added(
output_index,
&json!({
"id": item_id,
"type": "message",
"status": "in_progress",
"role": "assistant",
"content": []
}),
)
}
/// `response.content_part.added` for the (empty) output_text part of a message.
pub(crate) fn message_content_part_added(output_index: u32, item_id: &str) -> Bytes {
sse_event(
"response.content_part.added",
json!({
"type": "response.content_part.added",
"item_id": item_id,
"output_index": output_index,
"content_index": 0,
"part": { "type": "output_text", "text": "", "annotations": [] }
}),
)
}
/// `response.output_text.delta`.
pub(crate) fn output_text_delta(output_index: u32, item_id: &str, delta: &str) -> Bytes {
sse_event(
"response.output_text.delta",
json!({
"type": "response.output_text.delta",
"item_id": item_id,
"output_index": output_index,
"content_index": 0,
"delta": delta
}),
)
}
/// The completed assistant-message item value.
pub(crate) fn message_item(item_id: &str, text: &str) -> Value {
json!({
"id": item_id,
"type": "message",
"status": "completed",
"role": "assistant",
"content": [{ "type": "output_text", "text": text, "annotations": [] }]
})
}
/// Close an assistant message: emits `output_text.done` → `content_part.done` →
/// `output_item.done`, and returns the completed item so the caller can record it.
pub(crate) fn message_close(output_index: u32, item_id: &str, text: &str) -> (Vec<Bytes>, Value) {
let item = message_item(item_id, text);
let events = vec![
sse_event(
"response.output_text.done",
json!({
"type": "response.output_text.done",
"item_id": item_id,
"output_index": output_index,
"content_index": 0,
"text": text
}),
),
sse_event(
"response.content_part.done",
json!({
"type": "response.content_part.done",
"item_id": item_id,
"output_index": output_index,
"content_index": 0,
"part": { "type": "output_text", "text": text, "annotations": [] }
}),
),
output_item_done(output_index, &item),
];
(events, item)
}
// ---------------------------------------------------------------------------
// Reasoning (summary) lifecycle
// ---------------------------------------------------------------------------
/// `response.output_item.added` for an in-progress reasoning item.
pub(crate) fn reasoning_item_added(output_index: u32, item_id: &str) -> Bytes {
output_item_added(
output_index,
&json!({
"id": item_id,
"type": "reasoning",
"status": "in_progress",
"summary": []
}),
)
}
/// `response.reasoning_summary_part.added` for the (empty) summary part.
pub(crate) fn reasoning_summary_part_added(output_index: u32, item_id: &str) -> Bytes {
sse_event(
"response.reasoning_summary_part.added",
json!({
"type": "response.reasoning_summary_part.added",
"item_id": item_id,
"output_index": output_index,
"summary_index": 0,
"part": { "type": "summary_text", "text": "" }
}),
)
}
/// `response.reasoning_summary_text.delta`.
pub(crate) fn reasoning_summary_text_delta(output_index: u32, item_id: &str, delta: &str) -> Bytes {
sse_event(
"response.reasoning_summary_text.delta",
json!({
"type": "response.reasoning_summary_text.delta",
"item_id": item_id,
"output_index": output_index,
"summary_index": 0,
"delta": delta
}),
)
}
/// The completed reasoning item value (note: no `status` field, matching both converters).
pub(crate) fn reasoning_item(item_id: &str, text: &str) -> Value {
json!({
"id": item_id,
"type": "reasoning",
"summary": [{ "type": "summary_text", "text": text }]
})
}
/// Close a reasoning item: emits `reasoning_summary_text.done` →
/// `reasoning_summary_part.done` → `output_item.done`, and returns the completed item.
pub(crate) fn reasoning_close(output_index: u32, item_id: &str, text: &str) -> (Vec<Bytes>, Value) {
let item = reasoning_item(item_id, text);
let events = reasoning_close_with_item(output_index, item_id, text, &item, true);
(events, item)
}
/// Close a reasoning item whose completed shape is supplied by the converter.
/// Anthropic uses this to attach opaque signed/redacted thinking in
/// `encrypted_content` while keeping the standard Responses event lifecycle.
pub(crate) fn reasoning_close_with_item(
output_index: u32,
item_id: &str,
text: &str,
item: &Value,
has_visible_summary: bool,
) -> Vec<Bytes> {
let mut events = Vec::new();
if has_visible_summary {
events.extend([
sse_event(
"response.reasoning_summary_text.done",
json!({
"type": "response.reasoning_summary_text.done",
"item_id": item_id,
"output_index": output_index,
"summary_index": 0,
"text": text
}),
),
sse_event(
"response.reasoning_summary_part.done",
json!({
"type": "response.reasoning_summary_part.done",
"item_id": item_id,
"output_index": output_index,
"summary_index": 0,
"part": { "type": "summary_text", "text": text }
}),
),
]);
}
events.push(output_item_done(output_index, item));
events
}
// ---------------------------------------------------------------------------
// Tool-call argument streaming (item value supplied by the caller)
// ---------------------------------------------------------------------------
/// `response.function_call_arguments.delta`.
pub(crate) fn function_call_arguments_delta(
output_index: u32,
item_id: &str,
delta: &str,
) -> Bytes {
sse_event(
"response.function_call_arguments.delta",
json!({
"type": "response.function_call_arguments.delta",
"item_id": item_id,
"output_index": output_index,
"delta": delta
}),
)
}
/// `response.function_call_arguments.done`.
pub(crate) fn function_call_arguments_done(
output_index: u32,
item_id: &str,
arguments: &str,
) -> Bytes {
sse_event(
"response.function_call_arguments.done",
json!({
"type": "response.function_call_arguments.done",
"item_id": item_id,
"output_index": output_index,
"arguments": arguments
}),
)
}
/// `response.custom_tool_call_input.delta` (Chat freeform tools only).
pub(crate) fn custom_tool_call_input_delta(output_index: u32, item_id: &str, delta: &str) -> Bytes {
sse_event(
"response.custom_tool_call_input.delta",
json!({
"type": "response.custom_tool_call_input.delta",
"item_id": item_id,
"output_index": output_index,
"delta": delta
}),
)
}
/// `response.custom_tool_call_input.done` (Chat freeform tools only).
pub(crate) fn custom_tool_call_input_done(output_index: u32, item_id: &str, input: &str) -> Bytes {
sse_event(
"response.custom_tool_call_input.done",
json!({
"type": "response.custom_tool_call_input.done",
"item_id": item_id,
"output_index": output_index,
"input": input
}),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn body(bytes: &Bytes) -> String {
String::from_utf8(bytes.to_vec()).unwrap()
}
#[test]
fn sse_event_framing() {
let ev = sse_event("response.created", json!({ "a": 1 }));
assert_eq!(body(&ev), "event: response.created\ndata: {\"a\":1}\n\n");
}
#[test]
fn message_close_shapes_match_legacy() {
let (events, item) = message_close(2, "resp_1_msg", "hi");
assert_eq!(events.len(), 3);
assert!(body(&events[0]).contains("\"type\":\"response.output_text.done\""));
assert!(body(&events[0]).contains("\"text\":\"hi\""));
assert!(body(&events[1]).contains("\"type\":\"response.content_part.done\""));
assert!(body(&events[2]).contains("\"type\":\"response.output_item.done\""));
assert_eq!(item["type"], "message");
assert_eq!(item["status"], "completed");
assert_eq!(item["content"][0]["text"], "hi");
}
#[test]
fn reasoning_close_item_has_no_status() {
let (events, item) = reasoning_close(0, "rs_1", "because");
assert_eq!(events.len(), 3);
assert!(body(&events[0]).contains("\"type\":\"response.reasoning_summary_text.done\""));
assert!(body(&events[1]).contains("\"type\":\"response.reasoning_summary_part.done\""));
// The completed reasoning item intentionally carries no `status` field.
assert!(item.get("status").is_none());
assert_eq!(item["summary"][0]["text"], "because");
}
#[test]
fn message_item_added_is_in_progress() {
let ev = message_item_added(0, "m1");
let s = body(&ev);
assert!(s.contains("\"type\":\"response.output_item.added\""));
assert!(s.contains("\"status\":\"in_progress\""));
assert!(s.contains("\"role\":\"assistant\""));
}
#[test]
fn function_call_argument_events() {
assert!(body(&function_call_arguments_delta(1, "fc_x", "{\"a\":"))
.contains("\"type\":\"response.function_call_arguments.delta\""));
assert!(body(&function_call_arguments_done(1, "fc_x", "{\"a\":1}"))
.contains("\"arguments\":\"{\\\"a\\\":1}\""));
}
}
+6 -2
View File
@@ -18,6 +18,7 @@ mod codex;
pub(crate) mod codex_chat_common;
pub mod codex_chat_history;
pub mod codex_oauth_auth;
pub(crate) mod codex_responses_sse;
pub mod copilot_auth;
pub mod copilot_model_map;
mod gemini;
@@ -25,10 +26,12 @@ pub(crate) mod gemini_schema;
pub mod gemini_shadow;
pub mod models;
pub mod streaming;
pub mod streaming_codex_anthropic;
pub mod streaming_codex_chat;
pub mod streaming_gemini;
pub mod streaming_responses;
pub mod transform;
pub mod transform_codex_anthropic;
pub mod transform_codex_chat;
pub mod transform_gemini;
pub mod transform_responses;
@@ -47,8 +50,9 @@ pub use claude::{
};
pub use codex::CodexAdapter;
pub use codex::{
apply_codex_chat_upstream_model, codex_provider_upstream_model,
resolve_codex_chat_reasoning_config, should_convert_codex_responses_to_chat,
apply_codex_chat_upstream_model, apply_codex_upstream_model, codex_provider_upstream_model,
resolve_codex_catalog_tool_profile, resolve_codex_chat_reasoning_config,
should_convert_codex_responses_to_anthropic, should_convert_codex_responses_to_chat,
};
pub use gemini::GeminiAdapter;
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
//! OpenAI Chat Completions SSE → OpenAI Responses SSE conversion.
use super::codex_responses_sse as sse;
use super::{
codex_chat_common::{
extract_reasoning_field_text, split_leading_think_block, strip_leading_think_open_tag,
@@ -270,20 +271,8 @@ impl ChatToResponsesState {
let response = self.base_response("in_progress", Vec::new());
vec![
sse_event(
"response.created",
json!({
"type": "response.created",
"response": response
}),
),
sse_event(
"response.in_progress",
json!({
"type": "response.in_progress",
"response": self.base_response("in_progress", Vec::new())
}),
),
sse::response_created(&response),
sse::response_in_progress(&response),
]
}
@@ -297,45 +286,16 @@ impl ChatToResponsesState {
self.reasoning.item_id = item_id.clone();
self.reasoning.added = true;
events.push(sse_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"output_index": output_index,
"item": {
"id": item_id,
"type": "reasoning",
"status": "in_progress",
"summary": []
}
}),
));
events.push(sse_event(
"response.reasoning_summary_part.added",
json!({
"type": "response.reasoning_summary_part.added",
"item_id": self.reasoning.item_id,
"output_index": output_index,
"summary_index": 0,
"part": {
"type": "summary_text",
"text": ""
}
}),
));
events.push(sse::reasoning_item_added(output_index, &item_id));
events.push(sse::reasoning_summary_part_added(output_index, &item_id));
}
self.reasoning.text.push_str(delta);
let output_index = self.reasoning.output_index.unwrap_or(0);
events.push(sse_event(
"response.reasoning_summary_text.delta",
json!({
"type": "response.reasoning_summary_text.delta",
"item_id": self.reasoning.item_id,
"output_index": output_index,
"summary_index": 0,
"delta": delta
}),
events.push(sse::reasoning_summary_text_delta(
output_index,
&self.reasoning.item_id,
delta,
));
events
@@ -351,47 +311,16 @@ impl ChatToResponsesState {
self.text.item_id = item_id.clone();
self.text.added = true;
events.push(sse_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"output_index": output_index,
"item": {
"id": item_id,
"type": "message",
"status": "in_progress",
"role": "assistant",
"content": []
}
}),
));
events.push(sse_event(
"response.content_part.added",
json!({
"type": "response.content_part.added",
"item_id": self.text.item_id,
"output_index": output_index,
"content_index": 0,
"part": {
"type": "output_text",
"text": "",
"annotations": []
}
}),
));
events.push(sse::message_item_added(output_index, &item_id));
events.push(sse::message_content_part_added(output_index, &item_id));
}
self.text.text.push_str(delta);
let output_index = self.text.output_index.unwrap_or(0);
events.push(sse_event(
"response.output_text.delta",
json!({
"type": "response.output_text.delta",
"item_id": self.text.item_id,
"output_index": output_index,
"content_index": 0,
"delta": delta
}),
events.push(sse::output_text_delta(
output_index,
&self.text.item_id,
delta,
));
events
@@ -485,36 +414,21 @@ impl ChatToResponsesState {
&self.tool_context,
);
events.push(sse_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"output_index": assigned,
"item": item
}),
));
events.push(sse::output_item_added(assigned, &item));
if !pending_arguments.is_empty() && !is_custom_tool {
events.push(sse_event(
"response.function_call_arguments.delta",
json!({
"type": "response.function_call_arguments.delta",
"item_id": state.item_id,
"output_index": assigned,
"delta": pending_arguments
}),
events.push(sse::function_call_arguments_delta(
assigned,
&state.item_id,
&pending_arguments,
));
}
} else if !args_delta.is_empty() && !is_custom_tool {
if let Some(output_index) = output_index {
events.push(sse_event(
"response.function_call_arguments.delta",
json!({
"type": "response.function_call_arguments.delta",
"item_id": item_id,
"output_index": output_index,
"delta": args_delta
}),
events.push(sse::function_call_arguments_delta(
output_index,
&item_id,
&args_delta,
));
}
}
@@ -567,13 +481,7 @@ impl ChatToResponsesState {
response["incomplete_details"] = json!({ "reason": "max_output_tokens" });
}
events.push(sse_event(
"response.completed",
json!({
"type": "response.completed",
"response": response
}),
));
events.push(sse::response_completed(&response));
self.completed = true;
events
}
@@ -586,50 +494,10 @@ impl ChatToResponsesState {
let output_index = self.reasoning.output_index.unwrap_or(0);
let item_id = self.reasoning.item_id.clone();
let text = self.reasoning.text.clone();
let item = json!({
"id": item_id,
"type": "reasoning",
"summary": [{
"type": "summary_text",
"text": text
}]
});
self.output_items.push((output_index, item.clone()));
let (events, item) = sse::reasoning_close(output_index, &item_id, &text);
self.output_items.push((output_index, item));
self.reasoning.done = true;
vec![
sse_event(
"response.reasoning_summary_text.done",
json!({
"type": "response.reasoning_summary_text.done",
"item_id": self.reasoning.item_id,
"output_index": output_index,
"summary_index": 0,
"text": self.reasoning.text
}),
),
sse_event(
"response.reasoning_summary_part.done",
json!({
"type": "response.reasoning_summary_part.done",
"item_id": self.reasoning.item_id,
"output_index": output_index,
"summary_index": 0,
"part": {
"type": "summary_text",
"text": self.reasoning.text
}
}),
),
sse_event(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"output_index": output_index,
"item": item
}),
),
]
events
}
fn finalize_text(&mut self) -> Vec<Bytes> {
@@ -638,54 +506,12 @@ impl ChatToResponsesState {
}
let output_index = self.text.output_index.unwrap_or(0);
let item = json!({
"id": self.text.item_id,
"type": "message",
"status": "completed",
"role": "assistant",
"content": [{
"type": "output_text",
"text": self.text.text,
"annotations": []
}]
});
self.output_items.push((output_index, item.clone()));
let item_id = self.text.item_id.clone();
let text = self.text.text.clone();
let (events, item) = sse::message_close(output_index, &item_id, &text);
self.output_items.push((output_index, item));
self.text.done = true;
vec![
sse_event(
"response.output_text.done",
json!({
"type": "response.output_text.done",
"item_id": self.text.item_id,
"output_index": output_index,
"content_index": 0,
"text": self.text.text
}),
),
sse_event(
"response.content_part.done",
json!({
"type": "response.content_part.done",
"item_id": self.text.item_id,
"output_index": output_index,
"content_index": 0,
"part": {
"type": "output_text",
"text": self.text.text,
"annotations": []
}
}),
),
sse_event(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"output_index": output_index,
"item": item
}),
),
]
events
}
fn finalize_tools(&mut self) -> Vec<Bytes> {
@@ -742,14 +568,7 @@ impl ChatToResponsesState {
Some(&state.reasoning_content),
&self.tool_context,
);
add_event = Some(sse_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"output_index": assigned,
"item": item
}),
));
add_event = Some(sse::output_item_added(assigned, &item));
}
if let Some(event) = add_event {
@@ -777,44 +596,25 @@ impl ChatToResponsesState {
if is_custom_tool {
let input = custom_tool_input_from_chat_arguments(&arguments);
if !input.is_empty() {
events.push(sse_event(
"response.custom_tool_call_input.delta",
json!({
"type": "response.custom_tool_call_input.delta",
"item_id": state.item_id,
"output_index": output_index,
"delta": input.clone()
}),
events.push(sse::custom_tool_call_input_delta(
output_index,
&state.item_id,
&input,
));
}
events.push(sse_event(
"response.custom_tool_call_input.done",
json!({
"type": "response.custom_tool_call_input.done",
"item_id": state.item_id,
"output_index": output_index,
"input": input
}),
events.push(sse::custom_tool_call_input_done(
output_index,
&state.item_id,
&input,
));
} else {
events.push(sse_event(
"response.function_call_arguments.done",
json!({
"type": "response.function_call_arguments.done",
"item_id": state.item_id,
"output_index": output_index,
"arguments": arguments
}),
events.push(sse::function_call_arguments_done(
output_index,
&state.item_id,
&arguments,
));
}
events.push(sse_event(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"output_index": output_index,
"item": item
}),
));
events.push(sse::output_item_done(output_index, &item));
}
events
@@ -864,13 +664,7 @@ impl ChatToResponsesState {
let mut response = self.base_response("failed", self.completed_output_items());
response["error"] = error;
sse_event(
"response.failed",
json!({
"type": "response.failed",
"response": response
}),
)
sse::response_failed(&response)
}
}
@@ -1030,13 +824,6 @@ fn extract_chat_sse_error(value: &Value) -> (String, Option<String>) {
(message, error_type)
}
fn sse_event(event: &str, data: Value) -> Bytes {
Bytes::from(format!(
"event: {event}\ndata: {}\n\n",
serde_json::to_string(&data).unwrap_or_default()
))
}
#[cfg(test)]
mod tests {
use super::*;
File diff suppressed because it is too large Load Diff
@@ -80,7 +80,11 @@ impl CodexToolContext {
.is_some_and(|spec| matches!(&spec.kind, CodexToolKind::Custom))
}
fn chat_name_for_response_function(&self, name: &str, namespace: Option<&str>) -> String {
pub(crate) fn chat_name_for_response_function(
&self,
name: &str,
namespace: Option<&str>,
) -> String {
if let Some(namespace) = namespace.filter(|value| !value.is_empty()) {
if let Some(chat_name) = self
.namespace_name_to_chat_name
+48 -4
View File
@@ -7,7 +7,7 @@ use serde_json::{json, Value};
///
/// 三路径分发:
/// - skip: haiku 模型直接跳过
/// - adaptive: opus-4-8 / opus-4-7 / opus-4-6 / sonnet-4-6 使用 adaptive thinking
/// - adaptive: current adaptive-thinking Claude models use adaptive thinking
/// - legacy: 其他模型注入 enabled thinking + budget_tokens
pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
if !config.thinking_optimizer {
@@ -73,13 +73,42 @@ pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
}
}
fn uses_adaptive_thinking(model: &str) -> bool {
let normalized = model.replace('.', "-");
["opus-4-8", "opus-4-7", "opus-4-6", "sonnet-4-6"]
pub(crate) fn uses_adaptive_thinking(model: &str) -> bool {
let normalized = normalize_model_name(model);
[
"fable-5",
"mythos-5",
"mythos-preview",
"sonnet-5",
"opus-4-8",
"opus-4-7",
"opus-4-6",
"sonnet-4-6",
]
.iter()
.any(|needle| normalized.contains(needle))
}
/// Models where omitting `thinking` still leaves adaptive thinking enabled.
pub(crate) fn adaptive_thinking_is_default(model: &str) -> bool {
let normalized = normalize_model_name(model);
["fable-5", "mythos-5", "mythos-preview", "sonnet-5"]
.iter()
.any(|needle| normalized.contains(needle))
}
/// Models that reject `thinking: {"type":"disabled"}`.
pub(crate) fn thinking_cannot_be_disabled(model: &str) -> bool {
let normalized = normalize_model_name(model);
["fable-5", "mythos-5"]
.iter()
.any(|needle| normalized.contains(needle))
}
fn normalize_model_name(model: &str) -> String {
model.trim().to_ascii_lowercase().replace(['.', '_'], "-")
}
/// 追加 beta 标识到 anthropic_beta 数组(去重)
fn append_beta(body: &mut Value, beta: &str) {
match body.get_mut("anthropic_beta") {
@@ -139,6 +168,21 @@ mod tests {
assert!(betas.iter().any(|v| v == "context-1m-2025-08-07"));
}
#[test]
fn current_generation_models_use_adaptive_thinking() {
for model in [
"claude-sonnet-5",
"anthropic/claude-fable-5",
"claude-mythos-5",
"claude-opus-4.8",
] {
assert!(uses_adaptive_thinking(model), "model={model}");
}
assert!(adaptive_thinking_is_default("claude-sonnet-5"));
assert!(thinking_cannot_be_disabled("claude-fable-5"));
assert!(!thinking_cannot_be_disabled("claude-sonnet-5"));
}
#[test]
fn test_adaptive_opus_4_6() {
let mut body = json!({