From 99e11e0851972e0ef7307be9c328a85b8371531e Mon Sep 17 00:00:00 2001 From: Yeeyzy Date: Fri, 10 Jul 2026 23:06:30 +0800 Subject: [PATCH] feat(codex): support native Anthropic Messages protocol as upstream (#5071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- src-tauri/src/codex_config.rs | 102 +- src-tauri/src/provider.rs | 44 + src-tauri/src/proxy/cache_injector.rs | 55 +- src-tauri/src/proxy/forwarder.rs | 488 +++- src-tauri/src/proxy/handlers.rs | 285 +- src-tauri/src/proxy/hyper_client.rs | 39 + src-tauri/src/proxy/providers/codex.rs | 301 +- .../proxy/providers/codex_responses_sse.rs | 399 +++ src-tauri/src/proxy/providers/mod.rs | 8 +- .../providers/streaming_codex_anthropic.rs | 1166 ++++++++ .../proxy/providers/streaming_codex_chat.rs | 309 +-- .../providers/transform_codex_anthropic.rs | 2443 +++++++++++++++++ .../proxy/providers/transform_codex_chat.rs | 6 +- src-tauri/src/proxy/thinking_optimizer.rs | 52 +- src-tauri/src/services/config.rs | 4 +- src-tauri/src/services/provider/live.rs | 11 +- src-tauri/src/services/proxy.rs | 14 +- src/components/providers/ProviderCard.tsx | 10 +- .../providers/forms/CodexFormFields.tsx | 126 +- .../providers/forms/ProviderForm.tsx | 90 +- src/hooks/useProviderActions.ts | 18 + src/i18n/locales/en.json | 12 +- src/i18n/locales/ja.json | 12 +- src/i18n/locales/zh-TW.json | 12 +- src/i18n/locales/zh.json | 12 +- src/types.ts | 11 +- src/utils/providerConfigUtils.test.ts | 26 + src/utils/providerConfigUtils.ts | 27 + tests/hooks/useProviderActions.test.tsx | 22 + 29 files changed, 5733 insertions(+), 371 deletions(-) create mode 100644 src-tauri/src/proxy/providers/codex_responses_sse.rs create mode 100644 src-tauri/src/proxy/providers/streaming_codex_anthropic.rs create mode 100644 src-tauri/src/proxy/providers/transform_codex_anthropic.rs diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 6f4f3aac2..b6b899a54 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -108,14 +108,27 @@ const CODEX_MODEL_CATALOG_TEMPLATE_SLUG: &str = "gpt-5.5"; pub enum CodexCatalogToolProfile { ProxyChat, NativeResponses, + /// Codex talks (through cc-switch's proxy) to a native Anthropic Messages + /// gateway. Like `NativeResponses` it must suppress Codex's freeform custom + /// tools — the Responses→Anthropic transform keeps only `function` tools. + /// Additionally the Codex `web_search` hosted tool is unusable on this path + /// (the transform drops it), so it is always disabled — see + /// `prepare_codex_config_text_with_model_catalog`. + Anthropic, } impl CodexCatalogToolProfile { /// Pick the catalog tool profile from a provider's `apiFormat` meta value. - /// Native (direct) Responses providers must suppress the custom apply_patch - /// tool; everything else keeps the proxy-chat behavior. + /// + /// Prefer [`crate::proxy::providers::codex::resolve_codex_catalog_tool_profile`], + /// which also honors settings-level `apiFormat` and the TOML `wire_api` (matching + /// the proxy router). This string-only mapping is the fallback for non-Anthropic + /// cases. pub fn from_api_format(api_format: Option<&str>) -> Self { match api_format { + Some("anthropic") => CodexCatalogToolProfile::Anthropic, + // Native (direct) Responses gateways reject Codex's freeform custom + // tools (apply_patch, etc.); strip them via the NativeResponses profile. Some("openai_responses") => CodexCatalogToolProfile::NativeResponses, _ => CodexCatalogToolProfile::ProxyChat, } @@ -440,10 +453,10 @@ fn codex_catalog_model_entry( entry_obj.insert("availability_nux".to_string(), Value::Null); entry_obj.insert("upgrade".to_string(), Value::Null); - if profile == CodexCatalogToolProfile::NativeResponses { - // Native `/responses` gateways reject Codex's freeform `apply_patch` - // (type=="custom") tool. Strip any key that would make Codex emit a - // custom/freeform tool, and rely on shell_type="shell_command" for + if profile != CodexCatalogToolProfile::ProxyChat { + // Native `/responses` and Anthropic gateways reject / drop Codex's freeform + // `apply_patch` (type=="custom") tool. Strip any key that would make Codex + // emit a custom/freeform tool, and rely on shell_type="shell_command" for // edits. Defensive even though the native template is already clean // (guards against template drift / an accidental gpt-5.5 clone). // @@ -870,7 +883,9 @@ fn codex_model_catalog_from_settings( // no cache dependency); proxy-chat providers keep cloning Codex's gpt-5.5 // entry so the proxy can rewrite custom<->function tools as before. let template = match profile { - CodexCatalogToolProfile::NativeResponses => load_codex_native_responses_template(), + CodexCatalogToolProfile::NativeResponses | CodexCatalogToolProfile::Anthropic => { + load_codex_native_responses_template() + } CodexCatalogToolProfile::ProxyChat => load_codex_model_catalog_template()?, }; Ok(Some(codex_model_catalog_from_specs( @@ -954,14 +969,25 @@ pub fn prepare_codex_config_text_with_model_catalog( // (MiMo/LongCat/MiniMax by host or model brand; Qwen3-Coder by model). // Everything else — relays, DouBao, web-search-capable Qwen models, // unknown providers — keeps Codex's default. - let disable_web_search = profile == CodexCatalogToolProfile::NativeResponses - && codex_native_gateway_rejects_web_search(&config_text); + let disable_web_search = match profile { + // The Responses→Anthropic transform silently drops the Codex web_search + // hosted tool, so always disable it here rather than present a dead tool. + CodexCatalogToolProfile::Anthropic => true, + CodexCatalogToolProfile::NativeResponses => { + codex_native_gateway_rejects_web_search(&config_text) + } + CodexCatalogToolProfile::ProxyChat => false, + }; let config_text = set_codex_native_web_search_field(&config_text, disable_web_search)?; write_json_file(&catalog_path, &catalog)?; Ok(config_text) } else { let config_text = set_codex_model_catalog_json_field(config_text, None)?; - set_codex_native_web_search_field(&config_text, false) + // Even without a generated catalog, the Responses→Anthropic transform drops the + // Codex web_search hosted tool, so keep the invariant that an Anthropic provider + // never presents it as a dead tool. + let disable_web_search = profile == CodexCatalogToolProfile::Anthropic; + set_codex_native_web_search_field(&config_text, disable_web_search) } } @@ -1739,6 +1765,26 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn catalog_tool_profile_from_api_format() { + assert_eq!( + CodexCatalogToolProfile::from_api_format(Some("anthropic")), + CodexCatalogToolProfile::Anthropic + ); + assert_eq!( + CodexCatalogToolProfile::from_api_format(Some("openai_responses")), + CodexCatalogToolProfile::NativeResponses + ); + assert_eq!( + CodexCatalogToolProfile::from_api_format(Some("openai_chat")), + CodexCatalogToolProfile::ProxyChat + ); + assert_eq!( + CodexCatalogToolProfile::from_api_format(None), + CodexCatalogToolProfile::ProxyChat + ); + } + #[test] fn unified_session_bucket_injects_for_empty_official_config() { let injected = inject_codex_unified_session_bucket("").expect("inject"); @@ -2672,6 +2718,42 @@ web_search = "disabled" ); } + #[test] + fn anthropic_profile_disables_web_search_without_catalog() { + // Regression: even when no model catalog is generated (empty/absent + // modelCatalog), an Anthropic provider must still disable web_search — the + // Responses→Anthropic transform drops the hosted tool, so leaving it on + // exposes a dead tool. The None-catalog branch previously always left it on. + let config = "model = \"claude-sonnet-4-6\"\n"; + let settings = serde_json::json!({}); + + let anthropic = prepare_codex_config_text_with_model_catalog( + &settings, + config, + CodexCatalogToolProfile::Anthropic, + ) + .unwrap(); + let parsed: toml::Value = toml::from_str(&anthropic).unwrap(); + assert_eq!( + parsed.get("web_search").and_then(|v| v.as_str()), + Some("disabled"), + "Anthropic profile must disable web_search even with no catalog" + ); + + // ProxyChat on the same no-catalog path must NOT add a disable line. + let proxy = prepare_codex_config_text_with_model_catalog( + &settings, + config, + CodexCatalogToolProfile::ProxyChat, + ) + .unwrap(); + let parsed: toml::Value = toml::from_str(&proxy).unwrap(); + assert!( + parsed.get("web_search").is_none(), + "ProxyChat profile must not disable web_search on the no-catalog path" + ); + } + #[test] fn web_search_blacklist_disables_only_known_reject_gateways() { let cfg = |model: &str, base_url: &str| { diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 71d965af8..2645e21bc 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -486,6 +486,26 @@ pub struct ProviderMeta { /// Codex Responses -> Chat Completions reasoning capability metadata. #[serde(rename = "codexChatReasoning", skip_serializing_if = "Option::is_none")] pub codex_chat_reasoning: Option, + /// Codex → Anthropic path: whether to emulate the Claude Code client + /// (User-Agent / anthropic-beta / x-app + injecting the Claude Code system + /// prompt first line). Disabled by default; only an explicit `true` enables it. + #[serde( + rename = "impersonateClaudeCode", + skip_serializing_if = "Option::is_none" + )] + pub impersonate_claude_code: Option, + /// Codex → Anthropic path: override the Anthropic `max_tokens` (output ceiling). + /// + /// Codex does not forward its `model_max_output_tokens` in the Responses + /// request body, so without this the path falls back to a conservative + /// default (8192), which truncates long or thinking-heavy responses + /// (`stop_reason=max_tokens`). When set (>0), this value is injected as the + /// request's `max_output_tokens` before conversion, taking precedence over + /// both any request-supplied value and the default. Kept per-provider on + /// purpose: a global large default would hard-400 on low-output-ceiling + /// models/gateways (and that error is non-retryable). + #[serde(rename = "maxOutputTokens", skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, /// Custom User-Agent for local proxy routing. #[serde(rename = "customUserAgent", skip_serializing_if = "Option::is_none")] pub custom_user_agent: Option, @@ -993,6 +1013,30 @@ mod tests { assert!(value.get("pricingModelSource").is_none()); } + #[test] + fn provider_meta_roundtrips_max_output_tokens() { + let meta = ProviderMeta { + max_output_tokens: Some(64000), + ..ProviderMeta::default() + }; + + let value = serde_json::to_value(&meta).expect("serialize ProviderMeta"); + assert_eq!( + value.get("maxOutputTokens").and_then(|v| v.as_u64()), + Some(64000) + ); + assert!(value.get("max_output_tokens").is_none()); + + let parsed: ProviderMeta = serde_json::from_value(value).expect("deserialize ProviderMeta"); + assert_eq!(parsed.max_output_tokens, Some(64000)); + } + + #[test] + fn provider_meta_omits_max_output_tokens_when_none() { + let value = serde_json::to_value(ProviderMeta::default()).expect("serialize ProviderMeta"); + assert!(value.get("maxOutputTokens").is_none()); + } + #[test] fn provider_meta_roundtrips_local_proxy_request_overrides() { let meta = ProviderMeta { diff --git a/src-tauri/src/proxy/cache_injector.rs b/src-tauri/src/proxy/cache_injector.rs index e3274d526..e2b0464ad 100644 --- a/src-tauri/src/proxy/cache_injector.rs +++ b/src-tauri/src/proxy/cache_injector.rs @@ -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()); + } } diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index d11e52901..ab6ab134e 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -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 = 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 { + 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 = 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 { + 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) { + 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) = diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 35c3062ee..9cca3d80a 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -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, + codex_tool_context: transform_codex_chat::CodexToolContext, +) -> Result { + 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::)); + 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> + Send + 'static, + ctx: &RequestContext, + state: &ProxyState, + status: StatusCode, + connection_guard: Option, +) -> Result { + 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 形式,错误响应若仍保留 diff --git a/src-tauri/src/proxy/hyper_client.rs b/src-tauri/src/proxy/hyper_client.rs index 097542a92..63ff0f998 100644 --- a/src-tauri/src/proxy/hyper_client.rs +++ b/src-tauri/src/proxy/hyper_client.rs @@ -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 { match self { @@ -737,3 +752,27 @@ impl tokio::io::AsyncWrite for WriteFilter { 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()); + } +} diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs index 35ada150a..cf714bbb2 100644 --- a/src-tauri/src/proxy/providers/codex.rs +++ b/src-tauri/src/proxy/providers/codex.rs @@ -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 { 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 { 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 { + // 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, 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 = 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 = 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(); diff --git a/src-tauri/src/proxy/providers/codex_responses_sse.rs b/src-tauri/src/proxy/providers/codex_responses_sse.rs new file mode 100644 index 000000000..d347e8a49 --- /dev/null +++ b/src-tauri/src/proxy/providers/codex_responses_sse.rs @@ -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, 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, 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 { + 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}\"")); + } +} diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 7f3d8e1ac..83535c538 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -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; diff --git a/src-tauri/src/proxy/providers/streaming_codex_anthropic.rs b/src-tauri/src/proxy/providers/streaming_codex_anthropic.rs new file mode 100644 index 000000000..8c3870fc4 --- /dev/null +++ b/src-tauri/src/proxy/providers/streaming_codex_anthropic.rs @@ -0,0 +1,1166 @@ +//! Anthropic Messages SSE → OpenAI Responses SSE conversion. +//! +//! The opposite direction of `streaming_responses.rs` (Responses SSE → Anthropic SSE): +//! here the Codex client speaks Responses, while the upstream gateway speaks the native +//! Anthropic Messages protocol. The Responses events emitted here have the same shape as +//! those in `streaming_codex_chat.rs` (Chat → Responses); the Codex client only recognizes +//! this set of events. + +use super::codex_responses_sse as sse; +use super::transform_codex_anthropic::{ + build_responses_usage_from_anthropic, map_anthropic_stop_reason_to_status, + responses_reasoning_item_from_anthropic_block, +}; +#[cfg(test)] +use super::transform_codex_anthropic::{ + decode_anthropic_thinking_block, ANTHROPIC_THINKING_ENCRYPTED_PREFIX, +}; +use super::transform_codex_chat::{ + response_tool_call_item_from_chat_name, response_tool_call_item_id_from_chat_name, + CodexToolContext, +}; +use super::transform_responses::sanitize_anthropic_tool_use_input_json; +use crate::proxy::json_canonical::canonicalize_tool_arguments_str; +use crate::proxy::sse::{strip_sse_field, take_sse_block}; +use bytes::Bytes; +use futures::stream::{Stream, StreamExt}; +use serde_json::{json, Map, Value}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BlockKind { + Text, + Tool, + Thinking, +} + +#[derive(Debug)] +struct BlockState { + kind: BlockKind, + output_index: u32, + item_id: String, + call_id: String, + name: String, + accum: String, + /// For `tool_use`: the `input` carried on `content_block_start` (compact JSON), + /// used as a fallback when the gateway sends the full input in the start event and + /// emits no `input_json_delta`. Empty otherwise. + start_input: String, + source_block: Value, + has_visible_summary: bool, + done: bool, +} + +struct AnthropicToResponsesState { + response_started: bool, + completed: bool, + response_id: String, + model: String, + next_output_index: u32, + blocks: BTreeMap, + output_items: Vec<(u32, Value)>, + anthropic_usage: Map, + stop_reason: Option, + tool_context: CodexToolContext, +} + +impl Default for AnthropicToResponsesState { + fn default() -> Self { + Self { + response_started: false, + completed: false, + response_id: "resp_ccswitch".to_string(), + model: String::new(), + next_output_index: 0, + blocks: BTreeMap::new(), + output_items: Vec::new(), + anthropic_usage: Map::new(), + stop_reason: None, + tool_context: CodexToolContext::default(), + } + } +} + +impl AnthropicToResponsesState { + fn with_tool_context(tool_context: CodexToolContext) -> Self { + Self { + tool_context, + ..Self::default() + } + } + + fn next_output_index(&mut self) -> u32 { + let index = self.next_output_index; + self.next_output_index += 1; + index + } + + fn responses_usage(&self) -> Value { + if self.anthropic_usage.is_empty() { + return json!({ + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "output_tokens_details": { "reasoning_tokens": 0 } + }); + } + build_responses_usage_from_anthropic(Some(&Value::Object(self.anthropic_usage.clone()))) + } + + fn base_response(&self, status: &str, output: Vec) -> Value { + json!({ + "id": self.response_id, + "object": "response", + "created_at": 0, + "status": status, + "model": self.model, + "output": output, + "usage": self.responses_usage() + }) + } + + fn merge_usage(&mut self, usage: &Value) { + if let Some(obj) = usage.as_object() { + for (key, value) in obj { + if value.is_null() { + continue; + } + self.anthropic_usage.insert(key.clone(), value.clone()); + } + } + } + + fn ensure_response_started(&mut self) -> Vec { + if self.response_started { + return Vec::new(); + } + self.response_started = true; + let response = self.base_response("in_progress", Vec::new()); + vec![ + sse::response_created(&response), + sse::response_in_progress(&response), + ] + } + + fn handle_message_start(&mut self, data: &Value) -> Vec { + if let Some(message) = data.get("message") { + if let Some(id) = message.get("id").and_then(|v| v.as_str()) { + self.response_id = if id.starts_with("resp_") { + id.to_string() + } else { + format!("resp_{id}") + }; + } + if let Some(model) = message.get("model").and_then(|v| v.as_str()) { + if !model.is_empty() { + self.model = model.to_string(); + } + } + if let Some(usage) = message.get("usage") { + self.merge_usage(usage); + } + } + self.ensure_response_started() + } + + fn handle_content_block_start(&mut self, data: &Value) -> Vec { + let mut events = self.ensure_response_started(); + let Some(index) = data.get("index").and_then(|v| v.as_u64()) else { + return events; + }; + let block = data.get("content_block").unwrap_or(&Value::Null); + let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + match block_type { + "text" => { + let output_index = self.next_output_index(); + let item_id = format!("{}_msg_{output_index}", self.response_id); + events.push(sse::message_item_added(output_index, &item_id)); + events.push(sse::message_content_part_added(output_index, &item_id)); + self.blocks.insert( + index, + BlockState { + kind: BlockKind::Text, + output_index, + item_id, + call_id: String::new(), + name: String::new(), + accum: block + .get("text") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + start_input: String::new(), + source_block: block.clone(), + has_visible_summary: false, + done: false, + }, + ); + } + "tool_use" => { + let output_index = self.next_output_index(); + let call_id = block.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let name = block.get("name").and_then(|v| v.as_str()).unwrap_or(""); + // Some gateways put the full tool input on content_block_start and emit + // no input_json_delta; capture it as a fallback (see close_block). + let start_input = block + .get("input") + .filter(|v| v.as_object().map(|o| !o.is_empty()).unwrap_or(false)) + .map(|v| v.to_string()) + .unwrap_or_default(); + let item_id = + response_tool_call_item_id_from_chat_name(call_id, name, &self.tool_context); + let item = response_tool_call_item_from_chat_name( + &item_id, + "in_progress", + call_id, + name, + "", + None, + &self.tool_context, + ); + events.push(sse::output_item_added(output_index, &item)); + self.blocks.insert( + index, + BlockState { + kind: BlockKind::Tool, + output_index, + item_id, + call_id: call_id.to_string(), + name: name.to_string(), + accum: String::new(), + start_input, + source_block: block.clone(), + has_visible_summary: false, + done: false, + }, + ); + } + "thinking" | "redacted_thinking" => { + let output_index = self.next_output_index(); + let item_id = format!("rs_{}_{output_index}", self.response_id); + events.push(sse::reasoning_item_added(output_index, &item_id)); + let has_visible_summary = block_type == "thinking"; + if has_visible_summary { + events.push(sse::reasoning_summary_part_added(output_index, &item_id)); + } + self.blocks.insert( + index, + BlockState { + kind: BlockKind::Thinking, + output_index, + item_id, + call_id: String::new(), + name: String::new(), + accum: block + .get("thinking") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + start_input: String::new(), + source_block: block.clone(), + has_visible_summary, + done: false, + }, + ); + } + _ => {} + } + + events + } + + fn handle_content_block_delta(&mut self, data: &Value) -> Vec { + let Some(index) = data.get("index").and_then(|v| v.as_u64()) else { + return Vec::new(); + }; + let delta = data.get("delta").unwrap_or(&Value::Null); + let delta_type = delta.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + let Some(block) = self.blocks.get_mut(&index) else { + return Vec::new(); + }; + let output_index = block.output_index; + let item_id = block.item_id.clone(); + + match delta_type { + "text_delta" => { + let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or(""); + block.accum.push_str(text); + vec![sse::output_text_delta(output_index, &item_id, text)] + } + "input_json_delta" => { + let partial = delta + .get("partial_json") + .and_then(|t| t.as_str()) + .unwrap_or(""); + block.accum.push_str(partial); + // The Read tool needs to be sanitized at close time, to avoid emitting pages:"" deltas mid-stream + if block.name == "Read" || self.tool_context.is_custom_tool_chat_name(&block.name) { + return Vec::new(); + } + vec![sse::function_call_arguments_delta( + output_index, + &item_id, + partial, + )] + } + "thinking_delta" => { + let text = delta.get("thinking").and_then(|t| t.as_str()).unwrap_or(""); + block.accum.push_str(text); + block.source_block["thinking"] = json!(block.accum); + vec![sse::reasoning_summary_text_delta( + output_index, + &item_id, + text, + )] + } + "signature_delta" => { + if let Some(signature) = delta.get("signature").and_then(Value::as_str) { + block.source_block["signature"] = json!(signature); + } + Vec::new() + } + _ => Vec::new(), + } + } + + fn handle_content_block_stop(&mut self, data: &Value) -> Vec { + let Some(index) = data.get("index").and_then(|v| v.as_u64()) else { + return Vec::new(); + }; + self.close_block(index) + } + + fn close_block(&mut self, index: u64) -> Vec { + let Some(block) = self.blocks.get_mut(&index) else { + return Vec::new(); + }; + if block.done { + return Vec::new(); + } + block.done = true; + let output_index = block.output_index; + let item_id = block.item_id.clone(); + let kind = block.kind; + let text = block.accum.clone(); + let call_id = block.call_id.clone(); + let name = block.name.clone(); + let source_block = block.source_block.clone(); + let has_visible_summary = block.has_visible_summary; + + match kind { + BlockKind::Text => { + let (events, item) = sse::message_close(output_index, &item_id, &text); + self.output_items.push((output_index, item)); + events + } + BlockKind::Tool => { + // Prefer streamed input_json_delta; fall back to the input carried on + // content_block_start when the gateway emitted no deltas (mirrors the + // non-streaming aggregator's precedence in transform_codex_anthropic). + let raw_input = if text.trim().is_empty() { + block.start_input.clone() + } else { + text + }; + let arguments = if raw_input.trim().is_empty() { + "{}".to_string() + } else if name == "Read" { + sanitize_anthropic_tool_use_input_json("Read", &raw_input) + } else { + canonicalize_tool_arguments_str(&raw_input) + }; + let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&name); + let item = response_tool_call_item_from_chat_name( + &item_id, + "completed", + &call_id, + &name, + &arguments, + None, + &self.tool_context, + ); + let mut events = Vec::new(); + if is_custom_tool { + let input = item.get("input").and_then(Value::as_str).unwrap_or(""); + events.push(sse::custom_tool_call_input_done( + output_index, + &item_id, + input, + )); + } else { + events.push(sse::function_call_arguments_done( + output_index, + &item_id, + &arguments, + )); + } + events.push(sse::output_item_done(output_index, &item)); + self.output_items.push((output_index, item)); + events + } + BlockKind::Thinking => { + let mut source_block = source_block; + if source_block.get("type").and_then(Value::as_str) == Some("thinking") { + source_block["thinking"] = json!(text); + } + let Some(item) = + responses_reasoning_item_from_anthropic_block(&item_id, &source_block) + else { + return Vec::new(); + }; + let events = sse::reasoning_close_with_item( + output_index, + &item_id, + &text, + &item, + has_visible_summary, + ); + self.output_items.push((output_index, item)); + events + } + } + } + + fn handle_message_delta(&mut self, data: &Value) -> Vec { + if let Some(reason) = data.pointer("/delta/stop_reason").and_then(|v| v.as_str()) { + self.stop_reason = Some(reason.to_string()); + } + if let Some(usage) = data.get("usage") { + self.merge_usage(usage); + } + Vec::new() + } + + /// Whether any partial output was produced (completed items, buffered text, or a + /// started tool call). Used to distinguish a truncated-with-output stream (report + /// incomplete) from one that produced nothing (report failed). + fn has_substantive_output(&self) -> bool { + !self.output_items.is_empty() + || self.blocks.values().any(|b| { + !b.accum.trim().is_empty() + || !b.call_id.trim().is_empty() + || !b.name.trim().is_empty() + }) + } + + fn finalize(&mut self) -> Vec { + if self.completed { + return Vec::new(); + } + let mut events = self.ensure_response_started(); + + // Close out any blocks that are still open + let open: Vec = self + .blocks + .iter() + .filter(|(_, b)| !b.done) + .map(|(index, _)| *index) + .collect(); + for index in open { + events.extend(self.close_block(index)); + } + + let (status, incomplete_reason) = + map_anthropic_stop_reason_to_status(self.stop_reason.as_deref()); + + let mut output = self.output_items.clone(); + output.sort_by_key(|(output_index, _)| *output_index); + let output: Vec = output.into_iter().map(|(_, item)| item).collect(); + + let mut response = self.base_response(status, output); + if let Some(reason) = incomplete_reason { + response["incomplete_details"] = json!({ "reason": reason }); + } + + events.push(sse::response_completed(&response)); + self.completed = true; + events + } + + fn failed_event(&mut self, message: String, error_type: Option) -> Option { + if self.completed { + return None; + } + self.completed = true; + let mut error = json!({ "message": message }); + if let Some(error_type) = error_type.filter(|value| !value.is_empty()) { + error["type"] = json!(error_type); + } + let mut output = self.output_items.clone(); + output.sort_by_key(|(output_index, _)| *output_index); + let output: Vec = output.into_iter().map(|(_, item)| item).collect(); + let mut response = self.base_response("failed", output); + response["error"] = error; + Some(sse::response_failed(&response)) + } +} + +fn extract_anthropic_sse_error(value: &Value) -> (String, Option) { + let error = value.get("error").unwrap_or(value); + let message = error + .as_str() + .map(ToString::to_string) + .or_else(|| { + error + .get("message") + .and_then(|v| v.as_str()) + .map(ToString::to_string) + }) + .unwrap_or_else(|| error.to_string()); + let error_type = error + .get("type") + .and_then(|v| v.as_str()) + .map(ToString::to_string); + (message, error_type) +} + +fn process_anthropic_sse_block( + state: &mut AnthropicToResponsesState, + block: &str, +) -> (Vec, bool) { + if block.trim().is_empty() { + return (Vec::new(), false); + } + let mut event_name: Option = None; + let mut data_parts: Vec = Vec::new(); + for line in block.lines() { + if let Some(event) = strip_sse_field(line, "event") { + event_name = Some(event.trim().to_string()); + } + if let Some(data) = strip_sse_field(line, "data") { + data_parts.push(data.to_string()); + } + } + if data_parts.is_empty() { + return (Vec::new(), false); + } + let Ok(data) = serde_json::from_str::(&data_parts.join("\n")) else { + return (Vec::new(), false); + }; + let msg_type = data + .get("type") + .and_then(Value::as_str) + .map(str::to_string) + .or(event_name) + .unwrap_or_default(); + + let events = match msg_type.as_str() { + "message_start" => state.handle_message_start(&data), + "content_block_start" => state.handle_content_block_start(&data), + "content_block_delta" => state.handle_content_block_delta(&data), + "content_block_stop" => state.handle_content_block_stop(&data), + "message_delta" => state.handle_message_delta(&data), + "message_stop" => state.finalize(), + "error" => { + let (message, error_type) = extract_anthropic_sse_error(&data); + return ( + state + .failed_event(message, error_type) + .into_iter() + .collect(), + true, + ); + } + _ => Vec::new(), + }; + (events, false) +} + +fn json_document_candidate(input: &str) -> Option<&str> { + let trimmed = input.trim_start_matches(|ch: char| ch.is_whitespace() || ch == '\u{feff}'); + matches!(trimmed.as_bytes().first(), Some(b'{') | Some(b'[')).then_some(trimmed) +} + +/// Convert a complete non-streaming Anthropic message (or error envelope) into +/// the same Responses SSE lifecycle emitted by the live stream converter. Some +/// compatible gateways ignore `stream:true` and return JSON with HTTP 200. +pub(crate) fn responses_sse_events_from_anthropic_message( + body: &Value, + tool_context: CodexToolContext, +) -> Vec { + let mut state = AnthropicToResponsesState::with_tool_context(tool_context); + if body.get("type").and_then(Value::as_str) == Some("error") || body.get("error").is_some() { + let (message, error_type) = extract_anthropic_sse_error(body); + return state + .failed_event(message, error_type) + .into_iter() + .collect(); + } + + let mut message_start = body.clone(); + message_start["content"] = json!([]); + let mut events = state.handle_message_start(&json!({ + "type": "message_start", + "message": message_start + })); + + if let Some(content) = body.get("content").and_then(Value::as_array) { + for (index, block) in content.iter().enumerate() { + let block_type = block.get("type").and_then(Value::as_str).unwrap_or(""); + let mut start_block = block.clone(); + match block_type { + "text" => start_block["text"] = json!(""), + "thinking" => start_block["thinking"] = json!(""), + _ => {} + } + events.extend(state.handle_content_block_start(&json!({ + "type": "content_block_start", + "index": index, + "content_block": start_block + }))); + + match block_type { + "text" => { + if let Some(text) = block.get("text").and_then(Value::as_str) { + events.extend(state.handle_content_block_delta(&json!({ + "type": "content_block_delta", + "index": index, + "delta": { "type": "text_delta", "text": text } + }))); + } + } + "thinking" => { + if let Some(thinking) = block.get("thinking").and_then(Value::as_str) { + events.extend(state.handle_content_block_delta(&json!({ + "type": "content_block_delta", + "index": index, + "delta": { "type": "thinking_delta", "thinking": thinking } + }))); + } + } + _ => {} + } + + events.extend(state.handle_content_block_stop(&json!({ + "type": "content_block_stop", + "index": index + }))); + } + } + + events.extend(state.handle_message_delta(&json!({ + "type": "message_delta", + "delta": { "stop_reason": body.get("stop_reason").cloned().unwrap_or(Value::Null) } + }))); + events.extend(state.finalize()); + events +} + +/// Convert the upstream Anthropic Messages SSE into the Responses SSE that Codex expects. +#[allow(dead_code)] +pub fn create_responses_sse_stream_from_anthropic( + stream: impl Stream> + Send + 'static, +) -> impl Stream> + Send { + create_responses_sse_stream_from_anthropic_with_context(stream, CodexToolContext::default()) +} + +pub(crate) fn create_responses_sse_stream_from_anthropic_with_context< + E: std::error::Error + Send + 'static, +>( + stream: impl Stream> + Send + 'static, + tool_context: CodexToolContext, +) -> impl Stream> + Send { + async_stream::stream! { + let mut buffer = String::new(); + let mut utf8_remainder: Vec = Vec::new(); + let mut state = AnthropicToResponsesState::with_tool_context(tool_context); + let mut stream_failed = false; + + tokio::pin!(stream); + + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => { + crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes); + + // A few compatible gateways ignore stream:true and return one + // JSON document. Hold that body intact (including pretty-printed + // blank lines) until EOF instead of discarding it as SSE blocks. + if json_document_candidate(&buffer).is_none() { + while let Some(block) = take_sse_block(&mut buffer) { + let (events, failed) = process_anthropic_sse_block(&mut state, &block); + for event in events { + yield Ok(event); + } + if failed { + stream_failed = true; + break; + } + } + } + + if stream_failed { + break; + } + } + Err(e) => { + if let Some(event) = state.failed_event( + format!("Stream error: {e}"), + Some("stream_error".to_string()), + ) { + yield Ok(event); + } + stream_failed = true; + break; + } + } + } + + // Process a final event even when the upstream omitted the trailing blank + // line. This is common with buffering reverse proxies and must not discard + // the last delta or terminal message_stop. + if !stream_failed && !buffer.trim().is_empty() { + if !state.response_started { + if let Some(candidate) = json_document_candidate(&buffer) { + if let Ok(body) = serde_json::from_str::(candidate) { + for event in responses_sse_events_from_anthropic_message( + &body, + state.tool_context.clone(), + ) { + yield Ok(event); + } + state.completed = true; + } + } + } + if !state.completed { + let (events, failed) = process_anthropic_sse_block(&mut state, &buffer); + for event in events { + yield Ok(event); + } + stream_failed = failed; + } + } + + if !stream_failed && !state.completed { + if state.stop_reason.is_some() { + // message_delta (stop_reason + final usage) arrived but the stream ended + // before message_stop; the turn is semantically complete, finalize normally. + for event in state.finalize() { + yield Ok(event); + } + } else if state.has_substantive_output() { + // Upstream truncated mid-stream (e.g. a proxy closed the connection without + // an I/O error) after emitting partial output. Report it as incomplete so + // Codex does not accept the truncated output as a normal completion. + state.stop_reason = Some("max_tokens".to_string()); + for event in state.finalize() { + yield Ok(event); + } + } else { + // Stream ended before any terminal signal or output: surface a failure. + if let Some(event) = state.failed_event( + "Upstream Anthropic stream ended before message_stop".to_string(), + Some("stream_truncated".to_string()), + ) { + yield Ok(event); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::stream; + + async fn run(input: &str) -> String { + let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from( + input.as_bytes().to_vec(), + ))]); + let converted = create_responses_sse_stream_from_anthropic(upstream); + let chunks: Vec<_> = converted.collect().await; + chunks + .into_iter() + .map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string()) + .collect::() + } + + async fn run_with_context(input: &str, context: CodexToolContext) -> String { + let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from( + input.as_bytes().to_vec(), + ))]); + create_responses_sse_stream_from_anthropic_with_context(upstream, context) + .collect::>() + .await + .into_iter() + .map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string()) + .collect() + } + + fn render_message_events(body: &Value) -> String { + responses_sse_events_from_anthropic_message(body, CodexToolContext::default()) + .into_iter() + .map(|chunk| String::from_utf8_lossy(&chunk).to_string()) + .collect() + } + + #[test] + fn test_json_error_envelope_preserves_upstream_error() { + let merged = render_message_events(&json!({ + "type": "error", + "error": { "type": "authentication_error", "message": "bad key" } + })); + assert!(merged.contains("event: response.failed")); + assert!(merged.contains("authentication_error")); + assert!(merged.contains("bad key")); + assert!(!merged.contains("stream_truncated")); + } + + #[test] + fn test_json_message_becomes_complete_responses_stream() { + let merged = render_message_events(&json!({ + "id": "msg_json", + "type": "message", + "role": "assistant", + "model": "claude", + "content": [{ "type": "text", "text": "Hello" }], + "stop_reason": "end_turn", + "usage": { "input_tokens": 4, "output_tokens": 2 } + })); + assert!(merged.contains("event: response.created")); + assert!(merged.contains("event: response.output_text.delta")); + assert!(merged.contains("\"delta\":\"Hello\"")); + assert!(merged.contains("event: response.completed")); + assert!(merged.contains("\"status\":\"completed\"")); + assert!(!merged.contains("event: response.failed")); + } + + #[tokio::test] + async fn test_raw_json_error_body_is_not_reported_as_truncated_sse() { + let merged = run(concat!( + "{\n", + " \"type\": \"error\",\n\n", + " \"error\": {\"type\":\"overloaded_error\",\"message\":\"busy\"}\n", + "}" + )) + .await; + assert!(merged.contains("event: response.failed")); + assert!(merged.contains("overloaded_error")); + assert!(merged.contains("busy")); + assert!(!merged.contains("stream_truncated")); + } + + #[tokio::test] + async fn test_raw_json_message_body_becomes_responses_stream() { + let merged = run( + r#"{"id":"msg_json","type":"message","role":"assistant","model":"claude","content":[{"type":"text","text":"Hello"}],"stop_reason":"end_turn","usage":{"input_tokens":4,"output_tokens":2}}"#, + ) + .await; + assert!(merged.contains("event: response.output_text.delta")); + assert!(merged.contains("\"delta\":\"Hello\"")); + assert!(merged.contains("event: response.completed")); + assert!(!merged.contains("stream_truncated")); + } + + #[tokio::test] + async fn test_text_stream() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"model\":\"claude\",\"usage\":{\"input_tokens\":12,\"output_tokens\":0}}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":3}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("event: response.created")); + assert!(merged.contains("\"id\":\"resp_msg_1\"")); + assert!(merged.contains("\"model\":\"claude\"")); + assert!(merged.contains("event: response.output_text.delta")); + assert!(merged.contains("\"delta\":\"Hello\"")); + assert!(merged.contains("event: response.completed")); + assert!(merged.contains("\"status\":\"completed\"")); + assert!(merged.contains("\"input_tokens\":12")); + assert!(merged.contains("\"output_tokens\":3")); + } + + #[tokio::test] + async fn test_truncated_stream_with_output_reports_incomplete() { + // Upstream closes after partial text but before message_delta/message_stop. + // The partial output must be reported as incomplete, not a normal completion. + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_t1\",\"model\":\"claude\",\"usage\":{\"input_tokens\":4}}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("\"delta\":\"partial\"")); + assert!(merged.contains("event: response.completed")); + // The top-level response is incomplete (message output items keep their own + // "completed" status, but the response status must not be "completed"). + assert!(merged.contains("\"status\":\"incomplete\"")); + assert!(merged.contains("\"reason\":\"max_output_tokens\"")); + } + + #[tokio::test] + async fn test_truncated_stream_without_output_reports_failed() { + // Upstream closes before producing any output or terminal signal: report failed. + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_t2\",\"model\":\"claude\"}}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("event: response.failed")); + assert!(merged.contains("stream_truncated")); + assert!(!merged.contains("event: response.completed")); + } + + #[tokio::test] + async fn test_stop_reason_without_message_stop_completes() { + // message_delta carried the stop_reason and final usage, but the stream ended + // before message_stop; the turn is complete and should finalize normally. + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_t3\",\"model\":\"claude\",\"usage\":{\"input_tokens\":4}}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"done\"}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":2}}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("event: response.completed")); + assert!(merged.contains("\"status\":\"completed\"")); + assert!(!merged.contains("event: response.failed")); + } + + #[tokio::test] + async fn test_tool_use_stream() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_2\",\"model\":\"claude\",\"usage\":{\"input_tokens\":5}}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"get_weather\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\\\"Tokyo\\\"}\"}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":7}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("\"type\":\"function_call\"")); + assert!(merged.contains("\"call_id\":\"toolu_1\"")); + assert!(merged.contains("\"name\":\"get_weather\"")); + assert!(merged.contains("event: response.function_call_arguments.delta")); + assert!(merged.contains("event: response.function_call_arguments.done")); + assert!(merged.contains("\"status\":\"completed\"")); + } + + #[tokio::test] + async fn test_tool_use_input_only_in_start_event() { + // Some gateways carry the full tool input on content_block_start and emit no + // input_json_delta. The arguments must still be populated (previously empty) — + // matching the non-streaming aggregator's behavior. + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_si\",\"model\":\"claude\",\"usage\":{\"input_tokens\":5}}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_i\",\"name\":\"get_weather\",\"input\":{\"city\":\"Tokyo\"}}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":3}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("\"name\":\"get_weather\"")); + assert!(merged.contains("event: response.function_call_arguments.done")); + // The tool arguments came from the start event, not from any delta. + assert!(merged.contains("Tokyo")); + assert!(merged.contains("\"status\":\"completed\"")); + } + + #[tokio::test] + async fn test_thinking_stream() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_3\",\"model\":\"claude\"}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"hmm\"}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("\"type\":\"reasoning\"")); + assert!(merged.contains("event: response.reasoning_summary_text.delta")); + assert!(merged.contains("\"delta\":\"hmm\"")); + assert!(merged.contains(ANTHROPIC_THINKING_ENCRYPTED_PREFIX)); + } + + #[tokio::test] + async fn test_thinking_signature_is_preserved() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_sig\",\"model\":\"claude\"}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"hmm\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig_abc\"}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let merged = run(input).await; + let encoded_start = merged.find(ANTHROPIC_THINKING_ENCRYPTED_PREFIX).unwrap(); + let encoded = merged[encoded_start..].split('"').next().unwrap(); + let block = decode_anthropic_thinking_block(encoded).unwrap(); + assert_eq!(block["signature"], "sig_abc"); + assert_eq!(block["thinking"], "hmm"); + } + + #[tokio::test] + async fn test_max_tokens_incomplete() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_4\",\"model\":\"claude\"}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"max_tokens\"}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("\"status\":\"incomplete\"")); + assert!(merged.contains("\"reason\":\"max_output_tokens\"")); + } + + #[tokio::test] + async fn test_read_tool_drops_empty_pages() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_5\",\"model\":\"claude\"}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_r\",\"name\":\"Read\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"file_path\\\":\\\"/tmp/x\\\",\\\"pages\\\":\\\"\\\"}\"}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("/tmp/x")); + assert!(!merged.contains("pages")); + } + + #[tokio::test] + async fn test_empty_read_input_finishes_as_empty_object() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_read\",\"model\":\"claude\"}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_r\",\"name\":\"Read\",\"input\":{}}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("\"arguments\":\"{}\"")); + } + + #[tokio::test] + async fn test_namespace_tool_stream_restores_namespace() { + let context = super::super::transform_codex_chat::build_codex_tool_context_from_request( + &json!({ + "tools": [{ + "type": "namespace", + "name": "mcp_files", + "tools": [{"type": "function", "name": "read", "parameters": {"type": "object"}}] + }] + }), + ); + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_ns\",\"model\":\"claude\"}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"mcp_files__read\",\"input\":{}}}\n\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let merged = run_with_context(input, context).await; + assert!(merged.contains("\"namespace\":\"mcp_files\"")); + assert!(merged.contains("\"name\":\"read\"")); + } + + #[tokio::test] + async fn test_final_event_without_blank_line_is_processed() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_tail\"}}\n\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"tail\"}}" + ); + let merged = run(input).await; + assert!(merged.contains("\"delta\":\"tail\"")); + assert!(merged.contains("\"status\":\"incomplete\"")); + } + + #[tokio::test] + async fn test_error_after_message_stop_does_not_emit_second_terminal_event() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_terminal\"}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n", + "event: error\n", + "data: {\"type\":\"error\",\"error\":{\"message\":\"late\"}}\n\n" + ); + let merged = run(input).await; + assert_eq!(merged.matches("event: response.completed").count(), 1); + assert_eq!(merged.matches("event: response.failed").count(), 0); + } + + #[tokio::test] + async fn test_error_event_becomes_failed() { + let input = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_6\",\"model\":\"claude\"}}\n\n", + "event: error\n", + "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"boom\"}}\n\n" + ); + let merged = run(input).await; + assert!(merged.contains("event: response.failed")); + assert!(merged.contains("boom")); + } +} diff --git a/src-tauri/src/proxy/providers/streaming_codex_chat.rs b/src-tauri/src/proxy/providers/streaming_codex_chat.rs index a5326ab13..66f456f57 100644 --- a/src-tauri/src/proxy/providers/streaming_codex_chat.rs +++ b/src-tauri/src/proxy/providers/streaming_codex_chat.rs @@ -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 { @@ -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 { @@ -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) { (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::*; diff --git a/src-tauri/src/proxy/providers/transform_codex_anthropic.rs b/src-tauri/src/proxy/providers/transform_codex_anthropic.rs new file mode 100644 index 000000000..4fdac6b79 --- /dev/null +++ b/src-tauri/src/proxy/providers/transform_codex_anthropic.rs @@ -0,0 +1,2443 @@ +//! OpenAI Responses ↔ Anthropic Messages format conversion module (used when the Codex upstream is an Anthropic gateway) +//! +//! Scenario: The Codex CLI only speaks the OpenAI Responses protocol, while the +//! upstream AI gateway only offers the native Anthropic Messages protocol +//! (`/v1/messages`). This module converts the Responses request sent by Codex +//! into an Anthropic request, then converts the Anthropic response back into a +//! Responses response. +//! +//! The direction is exactly the mirror of `transform_responses.rs`: +//! - `transform_responses.rs`: Anthropic request → Responses request, Responses response → Anthropic response +//! - this module: Responses request → Anthropic request, Anthropic response → Responses response + +use super::transform_codex_chat::{ + build_codex_tool_context_from_request, response_tool_call_item_from_chat_name, + response_tool_call_item_id_from_chat_name, CodexToolContext, +}; +use super::transform_responses::sanitize_anthropic_tool_use_input; +use crate::proxy::error::ProxyError; +use crate::proxy::json_canonical::canonical_json_string; +use crate::proxy::sse::{strip_sse_field, take_sse_block}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, HashSet}; + +pub(crate) const ANTHROPIC_THINKING_ENCRYPTED_PREFIX: &str = "ccswitch-anthropic-thinking-v1:"; +const TOOL_SEARCH_PROXY_NAME: &str = "tool_search"; + +/// Maps Codex's reasoning.effort to the token budget for Anthropic thinking. +/// +/// Returning `None` indicates an unrecognized effort value—in that case extended +/// thinking should not be enabled (to avoid accidentally swallowing +/// temperature/top_p), keeping normal sampling. +pub(crate) fn effort_to_thinking_budget(effort: &str) -> Option { + match effort.trim().to_ascii_lowercase().as_str() { + "minimal" | "low" => Some(2048), + "medium" => Some(8192), + "high" => Some(16384), + "xhigh" | "max" => Some(24576), + _ => None, + } +} + +fn codex_effort_to_anthropic(effort: &str) -> Option<&'static str> { + match effort.trim().to_ascii_lowercase().as_str() { + "minimal" | "low" => Some("low"), + "medium" => Some("medium"), + "high" => Some("high"), + "xhigh" | "max" => Some("max"), + _ => None, + } +} + +fn reasoning_explicitly_disabled(effort: Option<&str>) -> bool { + matches!( + effort + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("none" | "off" | "disabled") + ) +} + +/// Preserve an Anthropic signed thinking/redacted-thinking block inside the opaque +/// Responses `reasoning.encrypted_content` field so Codex replays it on the next +/// tool-result request. The prefix keeps unrelated providers' ciphertext isolated. +pub(crate) fn encode_anthropic_thinking_block(block: &Value) -> Option { + match block.get("type").and_then(|value| value.as_str()) { + Some("thinking" | "redacted_thinking") => {} + _ => return None, + } + let bytes = serde_json::to_vec(block).ok()?; + Some(format!( + "{ANTHROPIC_THINKING_ENCRYPTED_PREFIX}{}", + URL_SAFE_NO_PAD.encode(bytes) + )) +} + +pub(crate) fn decode_anthropic_thinking_block(encrypted_content: &str) -> Option { + let encoded = encrypted_content.strip_prefix(ANTHROPIC_THINKING_ENCRYPTED_PREFIX)?; + let bytes = URL_SAFE_NO_PAD.decode(encoded).ok()?; + let block: Value = serde_json::from_slice(&bytes).ok()?; + match block.get("type").and_then(|value| value.as_str()) { + Some("thinking" | "redacted_thinking") => Some(block), + _ => None, + } +} + +pub(crate) fn responses_reasoning_item_from_anthropic_block( + item_id: &str, + block: &Value, +) -> Option { + let encrypted_content = encode_anthropic_thinking_block(block)?; + let summary = block + .get("thinking") + .and_then(|value| value.as_str()) + .filter(|text| !text.is_empty()) + .map(|text| vec![json!({ "type": "summary_text", "text": text })]) + .unwrap_or_default(); + Some(json!({ + "id": item_id, + "type": "reasoning", + "summary": summary, + "encrypted_content": encrypted_content + })) +} + +/// Anthropic's stop_reason → Responses' (status, incomplete_details.reason) +pub(crate) fn map_anthropic_stop_reason_to_status( + stop_reason: Option<&str>, +) -> (&'static str, Option<&'static str>) { + match stop_reason { + Some("max_tokens") => ("incomplete", Some("max_output_tokens")), + // Safety refusal: report as incomplete to avoid Codex treating it as a normally-completed empty reply. + Some("refusal") => ("incomplete", Some("content_filter")), + Some("model_context_window_exceeded") => ("incomplete", Some("max_output_tokens")), + // pause_turn is unreachable on this path (Codex requests do not declare Anthropic server-side tools); + // if it does occur, log a warning and treat it as completed. + Some("pause_turn") => { + log::warn!("[Codex] Received unexpected Anthropic stop_reason=pause_turn, treating it as completed"); + ("completed", None) + } + _ => ("completed", None), + } +} + +/// Builds Responses usage from Anthropic usage. +/// +/// Anthropic's `input_tokens` is the "cache-excluded" fresh input; OpenAI/Responses' +/// `input_tokens` includes cache hits. To keep downstream metering correct, this +/// adds them (symmetric to the subtraction done for the Claude side in +/// `transform_responses`): +/// input_tokens = input + cache_read +/// input_tokens_details.cached_tokens = cache_read +/// +/// Note: **do not** fold `cache_creation` into `input_tokens`. The Codex billing +/// calculator (usage/calculator.rs) only subtracts `cache_read` for codex +/// (`billable = input - cache_read`), and separately lists cache-creation cost via +/// `cache_creation_input_tokens`; if creation were also added into +/// `input_tokens`, it would be double-charged at both the input price and the +/// cache-creation price. +pub(crate) fn build_responses_usage_from_anthropic(usage: Option<&Value>) -> Value { + let u = match usage { + Some(v) if v.is_object() => v, + _ => { + return json!({ + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "output_tokens_details": { "reasoning_tokens": 0 } + }) + } + }; + + let fresh_input = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0); + let output = u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0); + let reasoning = u + .pointer("/output_tokens_details/thinking_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let cache_read = u + .get("cache_read_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let cache_creation = u + .get("cache_creation_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + let input_tokens = fresh_input.saturating_add(cache_read); + let total_tokens = input_tokens + .saturating_add(cache_creation) + .saturating_add(output); + + let mut result = json!({ + "input_tokens": input_tokens, + "output_tokens": output, + "total_tokens": total_tokens, + "output_tokens_details": { "reasoning_tokens": reasoning } + }); + if cache_read > 0 { + result["input_tokens_details"] = json!({ "cached_tokens": cache_read }); + } + // Explicitly pass through cache_creation so the downstream usage parser (from_codex_response) attributes billing correctly. + if cache_creation > 0 { + result["cache_creation_input_tokens"] = json!(cache_creation); + } + result +} + +/// OpenAI Responses request → Anthropic Messages request +/// +/// `default_max_tokens`: injected when the Responses body has no +/// `max_output_tokens` (Anthropic's `max_tokens` is required; missing it yields a 400). +pub fn responses_request_to_anthropic( + body: Value, + default_max_tokens: u64, +) -> Result { + let mut result = json!({}); + let tool_context = build_codex_tool_context_from_request(&body); + let model = body + .get("model") + .and_then(|value| value.as_str()) + .unwrap_or(""); + + // Pass model through (the upstream model has already been applied by the forwarder) + if let Some(model) = body.get("model").and_then(|m| m.as_str()) { + result["model"] = json!(model); + } + + // instructions → system + if let Some(instructions) = body.get("instructions").and_then(|v| v.as_str()) { + if !instructions.is_empty() { + result["system"] = json!(instructions); + } + } + + // input → messages + let mut messages = match body.get("input") { + Some(Value::Array(items)) => convert_input_to_messages(items, &tool_context)?, + Some(Value::String(text)) if is_meaningful_text(text) => vec![json!({ + "role": "user", + "content": [{ "type": "text", "text": text }] + })], + _ => Vec::new(), + }; + // Anthropic /v1/messages requires messages to be non-empty and the first to be user. + // Normalize the history (compacted/resumed sessions may start with + // assistant/function_call, or input may be entirely reasoning and thus empty after being dropped). + // Drop incomplete tool turns first (they would otherwise 400), then guarantee a leading user. + drop_incomplete_tool_turns(&mut messages); + drop_empty_messages(&mut messages); + ensure_leading_user_message(&mut messages); + if messages.is_empty() { + return Err(ProxyError::InvalidRequest( + "cannot convert Codex request: empty messages".to_string(), + )); + } + trim_trailing_assistant_text(&mut messages); + drop_empty_messages(&mut messages); + if messages.is_empty() { + return Err(ProxyError::InvalidRequest( + "cannot convert Codex request: empty messages".to_string(), + )); + } + let thinking_history_is_valid = trailing_turn_supports_thinking(&messages); + result["messages"] = json!(messages); + + let reasoning_effort = body + .pointer("/reasoning/effort") + .and_then(|value| value.as_str()); + let adaptive_model = crate::proxy::thinking_optimizer::uses_adaptive_thinking(model); + let adaptive_by_default = crate::proxy::thinking_optimizer::adaptive_thinking_is_default(model); + let cannot_disable_thinking = + crate::proxy::thinking_optimizer::thinking_cannot_be_disabled(model); + + // max_output_tokens → max_tokens (required) + let max_tokens = body + .get("max_output_tokens") + .and_then(|v| v.as_u64()) + .filter(|v| *v > 0) + .unwrap_or(default_max_tokens); + let mut thinking_enabled = false; + let mut thinking_budget = reasoning_effort + .and_then(effort_to_thinking_budget) + .unwrap_or(0); + let explicitly_disabled = reasoning_explicitly_disabled(reasoning_effort); + let adaptive_should_think = adaptive_model + && (adaptive_by_default + || reasoning_effort + .and_then(codex_effort_to_anthropic) + .is_some()); + + if !thinking_history_is_valid { + if cannot_disable_thinking { + return Err(ProxyError::InvalidRequest( + "Anthropic model requires thinking, but the tool history has no signed thinking block to replay" + .to_string(), + )); + } + if adaptive_should_think { + result["thinking"] = json!({ "type": "disabled" }); + } + } else if adaptive_should_think && (!explicitly_disabled || cannot_disable_thinking) { + thinking_enabled = true; + result["thinking"] = json!({ "type": "adaptive" }); + if let Some(effort) = reasoning_effort.and_then(codex_effort_to_anthropic) { + result["output_config"] = json!({ "effort": effort }); + } else if explicitly_disabled && cannot_disable_thinking { + // Fable/Mythos cannot turn thinking off. `low` is the closest safe + // representation of Codex's explicit `none` request. + result["output_config"] = json!({ "effort": "low" }); + } + } else if explicitly_disabled { + result["thinking"] = json!({ "type": "disabled" }); + } else if thinking_budget > 0 { + thinking_enabled = true; + // Anthropic requires max_tokens > budget_tokens and budget >= 1024. Reserve + // headroom for the visible answer: cap the thinking budget at half of max_tokens + // so a large derived budget (e.g. 24576 for xhigh) can't consume nearly all of a + // modest max_tokens and leave ~1 output token (an effectively empty completion). + // Do not raise the caller's max_tokens (it may exceed the model's output ceiling + // and 400). If the remaining budget is below Anthropic's 1024 floor, disable + // thinking and restore normal sampling. + let ceiling = max_tokens / 2; + thinking_budget = thinking_budget.min(ceiling); + if thinking_budget < 1024 { + thinking_enabled = false; + } + } + result["max_tokens"] = json!(max_tokens); + + if thinking_enabled && !adaptive_model { + result["thinking"] = json!({ + "type": "enabled", + "budget_tokens": thinking_budget + }); + } + + if !thinking_enabled { + if let Some(v) = body.get("temperature") { + result["temperature"] = v.clone(); + } + if let Some(v) = body.get("top_p") { + result["top_p"] = v.clone(); + } + } + + if let Some(v) = body.get("stream") { + result["stream"] = v.clone(); + } + + // Reuse the Codex tool context so function, namespace, custom, tool_search, and + // dynamically loaded tools all receive stable flat names upstream. + let anth_tools: Vec = tool_context + .chat_tools() + .iter() + .filter_map(chat_tool_to_anthropic_tool) + .collect(); + let has_tools = !anth_tools.is_empty(); + if has_tools { + result["tools"] = json!(anth_tools); + } + + // Only forward tool_choice when tools survived the filter. Anthropic 400s on a + // tool_choice with no tools ("tool_choice may only be specified while providing + // tools"), and that 400 is non-retryable — so a request whose only tools were + // unsupported hosted tools (for example web_search) must drop tool_choice too. + if has_tools { + if let Some(tc) = body.get("tool_choice") { + let mapped = map_tool_choice_to_anthropic(tc, &tool_context); + let forced = matches!( + mapped.get("type").and_then(|value| value.as_str()), + Some("any" | "tool") + ); + if thinking_enabled && forced { + if cannot_disable_thinking { + return Err(ProxyError::InvalidRequest( + "Anthropic model requires adaptive thinking and cannot honor a forced tool_choice" + .to_string(), + )); + } + // Anthropic rejects forced tools while thinking is enabled. Preserve + // the caller's explicit tool constraint and disable thinking for this + // request instead of silently weakening `required`/named selection. + result["thinking"] = json!({ "type": "disabled" }); + result.as_object_mut().unwrap().remove("output_config"); + if let Some(value) = body.get("temperature") { + result["temperature"] = value.clone(); + } + if let Some(value) = body.get("top_p") { + result["top_p"] = value.clone(); + } + } + result["tool_choice"] = mapped; + } + + if body.get("parallel_tool_calls").and_then(Value::as_bool) == Some(false) { + if result.get("tool_choice").is_none() { + result["tool_choice"] = json!({ "type": "auto" }); + } + if let Some(tool_choice) = result.get_mut("tool_choice").and_then(Value::as_object_mut) + { + tool_choice.insert("disable_parallel_tool_use".to_string(), json!(true)); + } + } + } + + Ok(result) +} + +fn chat_tool_to_anthropic_tool(chat_tool: &Value) -> Option { + let function = chat_tool.get("function")?; + let name = function + .get("name") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let mut tool = json!({ + "name": name, + "input_schema": function + .get("parameters") + .cloned() + .filter(|value| value.as_object().is_some_and(|object| !object.is_empty())) + .unwrap_or_else(|| json!({ "type": "object", "properties": {} })) + }); + if let Some(description) = function.get("description").and_then(|value| value.as_str()) { + tool["description"] = json!(description); + } + if let Some(strict) = function.get("strict").and_then(|value| value.as_bool()) { + tool["strict"] = json!(strict); + } + Some(tool) +} + +/// tool_choice: Responses → Anthropic (the reverse of `map_tool_choice_to_responses`) +fn map_tool_choice_to_anthropic(tool_choice: &Value, tool_context: &CodexToolContext) -> Value { + match tool_choice { + Value::String(s) => match s.as_str() { + "required" => json!({ "type": "any" }), + "auto" => json!({ "type": "auto" }), + "none" => json!({ "type": "none" }), + _ => json!({ "type": "auto" }), + }, + Value::Object(obj) => match obj.get("type").and_then(|t| t.as_str()) { + Some("function") => { + let name = obj.get("name").and_then(|n| n.as_str()).unwrap_or(""); + let namespace = obj.get("namespace").and_then(|value| value.as_str()); + let upstream_name = tool_context.chat_name_for_response_function(name, namespace); + json!({ "type": "tool", "name": upstream_name }) + } + Some("custom") => json!({ + "type": "tool", + "name": obj.get("name").and_then(|value| value.as_str()).unwrap_or("") + }), + Some("tool_search") => { + json!({ "type": "tool", "name": TOOL_SEARCH_PROXY_NAME }) + } + // Other object shapes (allowed_tools / hosted-tool selectors, etc.) are + // not recognized by Anthropic; downgrade to auto to avoid passing OpenAI's + // raw structure through and causing a 400. + _ => json!({ "type": "auto" }), + }, + _ => json!({ "type": "auto" }), + } +} + +/// Re-nests the flat Responses input[] back into Anthropic messages. +/// +/// - input_text/output_text → text block of the corresponding role +/// - input_image → image block +/// - function_call → assistant's tool_use block (merged with the preceding assistant text into the same message) +/// - function_call_output → user's tool_result block (consecutive ones merged into the same user message) +/// - Anthropic-origin reasoning.encrypted_content → restored signed thinking block +fn convert_input_to_messages( + items: &[Value], + tool_context: &CodexToolContext, +) -> Result, ProxyError> { + let mut messages: Vec = Vec::new(); + + for item in items { + match item.get("type").and_then(|t| t.as_str()) { + Some("function_call") => { + let call_id = item + .get("call_id") + .and_then(|v| v.as_str()) + .or_else(|| item.get("id").and_then(|v| v.as_str())) + .unwrap_or(""); + let name = item.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let namespace = item.get("namespace").and_then(|value| value.as_str()); + let upstream_name = tool_context.chat_name_for_response_function(name, namespace); + let args_str = item.get("arguments").and_then(|v| v.as_str()).unwrap_or(""); + let input: Value = if args_str.trim().is_empty() { + json!({}) + } else { + serde_json::from_str(args_str).unwrap_or(json!({})) + }; + let input = sanitize_anthropic_tool_use_input(name, input); + push_block( + &mut messages, + "assistant", + json!({ + "type": "tool_use", + "id": call_id, + "name": upstream_name, + "input": input + }), + ); + } + Some("custom_tool_call") => { + let call_id = item + .get("call_id") + .and_then(|value| value.as_str()) + .or_else(|| item.get("id").and_then(|value| value.as_str())) + .unwrap_or(""); + let name = item + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let input = item.get("input").cloned().unwrap_or_else(|| json!("")); + push_block( + &mut messages, + "assistant", + json!({ + "type": "tool_use", + "id": call_id, + "name": name, + "input": { "input": input } + }), + ); + } + Some("tool_search_call") => { + let call_id = item + .get("call_id") + .and_then(|value| value.as_str()) + .or_else(|| item.get("id").and_then(|value| value.as_str())) + .unwrap_or(""); + let input = item + .get("arguments") + .cloned() + .filter(Value::is_object) + .unwrap_or_else(|| json!({})); + push_block( + &mut messages, + "assistant", + json!({ + "type": "tool_use", + "id": call_id, + "name": TOOL_SEARCH_PROXY_NAME, + "input": input + }), + ); + } + Some("function_call_output" | "custom_tool_call_output" | "tool_search_output") => { + let call_id = item.get("call_id").and_then(|v| v.as_str()).unwrap_or(""); + let output = tool_result_content_from_responses_item(item); + push_tool_result_block( + &mut messages, + json!({ + "type": "tool_result", + "tool_use_id": call_id, + "content": output + }), + ); + } + Some("input_text") => { + if let Some(text) = item + .get("text") + .and_then(Value::as_str) + .filter(|text| is_meaningful_text(text)) + { + push_block( + &mut messages, + "user", + json!({ "type": "text", "text": text }), + ); + } + } + Some("input_image") => { + if let Some(block) = image_block_from_input_image(item) { + push_block(&mut messages, "user", block); + } + } + Some("reasoning") => { + if let Some(block) = item + .get("encrypted_content") + .and_then(|value| value.as_str()) + .and_then(decode_anthropic_thinking_block) + { + push_assistant_thinking_block(&mut messages, block); + } + } + // message item or an item carrying a role + _ => { + let role = item.get("role").and_then(|r| r.as_str()).unwrap_or("user"); + let anth_role = if role == "assistant" { + "assistant" + } else { + "user" + }; + match item.get("content") { + Some(Value::String(text)) if is_meaningful_text(text) => { + push_block( + &mut messages, + anth_role, + json!({ "type": "text", "text": text }), + ); + } + Some(Value::Array(parts)) => { + for part in parts { + let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or(""); + match part_type { + "input_text" | "output_text" => { + if let Some(text) = part + .get("text") + .and_then(|t| t.as_str()) + .filter(|t| is_meaningful_text(t)) + { + push_block( + &mut messages, + anth_role, + json!({ "type": "text", "text": text }), + ); + } + } + "refusal" => { + if let Some(text) = part + .get("refusal") + .and_then(|t| t.as_str()) + .filter(|t| is_meaningful_text(t)) + { + push_block( + &mut messages, + anth_role, + json!({ "type": "text", "text": text }), + ); + } + } + "input_image" => { + if let Some(block) = image_block_from_input_image(part) { + push_block(&mut messages, anth_role, block); + } + } + _ => {} + } + } + } + _ => {} + } + } + } + } + + Ok(messages) +} + +fn tool_result_content_from_responses_item(item: &Value) -> Value { + match item.get("output") { + Some(Value::String(text)) => json!(text), + Some(Value::Array(parts)) => { + let content: Vec = parts + .iter() + .filter_map(|part| match part.get("type").and_then(Value::as_str) { + Some("input_text" | "output_text") => part + .get("text") + .and_then(Value::as_str) + .map(|text| json!({ "type": "text", "text": text })), + Some("input_image") => image_block_from_input_image(part), + _ => None, + }) + .collect(); + if content.is_empty() { + json!(canonical_json_string(&Value::Array(parts.clone()))) + } else { + Value::Array(content) + } + } + Some(value) => json!(canonical_json_string(value)), + None => json!(canonical_json_string(item)), + } +} + +/// Ensures the first message is a user: compacted/resumed sessions may start with +/// assistant or function_call, but Anthropic requires the first to be user, else 400. +/// An empty array is not handled (the caller decides whether to error). +fn ensure_leading_user_message(messages: &mut Vec) { + let leads_with_user = messages + .first() + .and_then(|m| m.get("role")) + .and_then(|r| r.as_str()) + == Some("user"); + if !messages.is_empty() && !leads_with_user { + messages.insert( + 0, + json!({ + "role": "user", + "content": [{ "type": "text", "text": "(continuing the conversation)" }] + }), + ); + } +} + +/// Removes compacted/resumed tool turns that no longer form a complete adjacent +/// assistant `tool_use` → user `tool_result` pair. Anthropic requires every tool +/// call in an assistant turn to be answered together in the immediately following +/// user turn. Dropping the whole incomplete assistant turn also avoids modifying a +/// subset of a signed thinking/tool-use response. +fn drop_incomplete_tool_turns(messages: &mut Vec) { + let original = std::mem::take(messages); + let mut sanitized = Vec::with_capacity(original.len()); + let mut index = 0; + + while index < original.len() { + let message = &original[index]; + let is_assistant = message.get("role").and_then(Value::as_str) == Some("assistant"); + let tool_use_ids = if is_assistant { + message_block_ids(message, "tool_use", "id") + } else { + Vec::new() + }; + + if !tool_use_ids.is_empty() { + let paired_user = original + .get(index + 1) + .filter(|next| next.get("role").and_then(Value::as_str) == Some("user")); + let tool_result_ids = paired_user + .map(|user| message_block_ids(user, "tool_result", "tool_use_id")) + .unwrap_or_default(); + let unique_tool_uses: HashSet<&str> = tool_use_ids.iter().copied().collect(); + let unique_tool_results: HashSet<&str> = tool_result_ids.iter().copied().collect(); + let complete = tool_use_ids.iter().all(|id| !id.is_empty()) + && tool_result_ids.iter().all(|id| !id.is_empty()) + && unique_tool_uses.len() == tool_use_ids.len() + && unique_tool_results.len() == tool_result_ids.len() + && unique_tool_uses == unique_tool_results; + + if complete { + sanitized.push(message.clone()); + sanitized.push(paired_user.unwrap().clone()); + } else if let Some(user) = paired_user { + let mut user = user.clone(); + drop_tool_result_blocks(&mut user); + if message_has_content(&user) { + sanitized.push(user); + } + } + + index += if paired_user.is_some() { 2 } else { 1 }; + continue; + } + + let mut message = message.clone(); + if message.get("role").and_then(Value::as_str) == Some("user") { + // A user message not consumed as the adjacent half of a complete tool + // pair cannot legally retain any tool_result blocks. + drop_tool_result_blocks(&mut message); + } + if message_has_content(&message) { + sanitized.push(message); + } + index += 1; + } + + *messages = sanitized; +} + +fn message_block_ids<'a>(message: &'a Value, block_type: &str, id_field: &str) -> Vec<&'a str> { + message + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|block| block.get("type").and_then(Value::as_str) == Some(block_type)) + .map(|block| block.get(id_field).and_then(Value::as_str).unwrap_or("")) + .collect() +} + +fn drop_tool_result_blocks(message: &mut Value) { + if let Some(content) = message.get_mut("content").and_then(Value::as_array_mut) { + content.retain(|block| block.get("type").and_then(Value::as_str) != Some("tool_result")); + } +} + +fn message_has_content(message: &Value) -> bool { + message + .get("content") + .and_then(Value::as_array) + .map(|content| !content.is_empty()) + .unwrap_or(true) +} + +/// A fresh user prompt can start a new thinking turn. A tool-result continuation +/// may keep thinking enabled only when the preceding assistant turn includes the +/// signed thinking/redacted-thinking block that Anthropic requires callers to replay. +fn trailing_turn_supports_thinking(messages: &[Value]) -> bool { + let Some(last) = messages.last() else { + return false; + }; + if last.get("role").and_then(Value::as_str) != Some("user") { + return false; + } + let mut tool_result_ids = Vec::new(); + if let Some(blocks) = last.get("content").and_then(Value::as_array) { + for block in blocks { + if block.get("type").and_then(Value::as_str) != Some("tool_result") { + continue; + } + let Some(id) = block + .get("tool_use_id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + else { + return false; + }; + tool_result_ids.push(id); + } + } + if tool_result_ids.is_empty() { + return true; + } + + // A tool-result turn answers the immediately preceding assistant tool-use turn. + // Looking any farther back can pick up an unrelated signed thinking block and + // incorrectly re-enable thinking for an unsigned tool call. + let Some(paired_assistant) = messages.get(messages.len().saturating_sub(2)) else { + return false; + }; + if paired_assistant.get("role").and_then(Value::as_str) != Some("assistant") { + return false; + } + let Some(blocks) = paired_assistant.get("content").and_then(Value::as_array) else { + return false; + }; + let has_signed_thinking = blocks.iter().any(|block| { + matches!( + block.get("type").and_then(Value::as_str), + Some("thinking" | "redacted_thinking") + ) + }); + if !has_signed_thinking { + return false; + } + + let paired_tool_use_ids: HashSet<&str> = blocks + .iter() + .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use")) + .filter_map(|block| block.get("id").and_then(Value::as_str)) + .collect(); + tool_result_ids + .iter() + .all(|id| paired_tool_use_ids.contains(id)) +} + +/// Removes whitespace-only assistant prefills and trims trailing whitespace from a +/// real prefill. Anthropic rejects an assistant prefill whose final text ends in +/// whitespace, and Codex may replay an empty assistant text beside a tool call. +fn trim_trailing_assistant_text(messages: &mut [Value]) { + let Some(last) = messages.last_mut() else { + return; + }; + if last.get("role").and_then(Value::as_str) != Some("assistant") { + return; + } + let Some(blocks) = last.get_mut("content").and_then(Value::as_array_mut) else { + return; + }; + let Some(block) = blocks.last_mut() else { + return; + }; + if block.get("type").and_then(Value::as_str) != Some("text") { + return; + } + let Some(text) = block.get("text").and_then(Value::as_str) else { + return; + }; + let trimmed = text.trim_end(); + if trimmed.is_empty() { + blocks.pop(); + } else if trimmed.len() != text.len() { + block["text"] = json!(trimmed); + } +} + +/// Anthropic 400s on a text content block whose text is empty or whitespace-only. +/// Such blocks arise when a prior Responses turn recorded an empty +/// input_text/output_text (e.g. an empty assistant text emitted alongside a +/// tool_use); replaying it verbatim would fail the next follow-up request. +fn is_meaningful_text(text: &str) -> bool { + !text.trim().is_empty() +} + +/// Removes messages whose content array ended up empty (e.g. a turn that carried +/// only empty text that was filtered out). Anthropic 400s on empty content. +fn drop_empty_messages(messages: &mut Vec) { + messages.retain(|msg| { + msg.get("content") + .and_then(|c| c.as_array()) + .map(|arr| !arr.is_empty()) + .unwrap_or(true) + }); +} + +/// Appends a content block to messages: merge if the last message has the same role, otherwise create a new message. +fn push_block(messages: &mut Vec, role: &str, block: Value) { + if let Some(last) = messages.last_mut() { + if last.get("role").and_then(|r| r.as_str()) == Some(role) { + if let Some(arr) = last.get_mut("content").and_then(|c| c.as_array_mut()) { + arr.push(block); + return; + } + } + } + messages.push(json!({ + "role": role, + "content": [block] + })); +} + +/// Appends a tool result to a user turn while preserving Anthropic's required +/// ordering: every tool_result block must precede any text or image blocks. +fn push_tool_result_block(messages: &mut Vec, block: Value) { + if let Some(last) = messages.last_mut() { + if last.get("role").and_then(Value::as_str) == Some("user") { + if let Some(content) = last.get_mut("content").and_then(Value::as_array_mut) { + let insert_at = content + .iter() + .position(|item| { + item.get("type").and_then(Value::as_str) != Some("tool_result") + }) + .unwrap_or(content.len()); + content.insert(insert_at, block); + return; + } + } + } + messages.push(json!({ + "role": "user", + "content": [block] + })); +} + +fn push_assistant_thinking_block(messages: &mut Vec, block: Value) { + if let Some(last) = messages.last_mut() { + if last.get("role").and_then(Value::as_str) == Some("assistant") { + if let Some(content) = last.get_mut("content").and_then(Value::as_array_mut) { + let index = content + .iter() + .take_while(|item| { + matches!( + item.get("type").and_then(Value::as_str), + Some("thinking" | "redacted_thinking") + ) + }) + .count(); + content.insert(index, block); + return; + } + } + } + push_block(messages, "assistant", block); +} + +/// Responses' input_image → Anthropic image block. +fn image_block_from_input_image(part: &Value) -> Option { + let url = part.get("image_url").and_then(|v| { + v.as_str() + .map(str::to_string) + .or_else(|| v.get("url").and_then(|u| u.as_str()).map(str::to_string)) + })?; + + if let Some(rest) = url.strip_prefix("data:") { + // data:;base64, + let (meta, data) = rest.split_once(',')?; + let media_type = meta.split(';').next().unwrap_or("image/png"); + Some(json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": data + } + })) + } else if url.starts_with("http://") || url.starts_with("https://") { + Some(json!({ + "type": "image", + "source": { "type": "url", "url": url } + })) + } else { + None + } +} + +/// Anthropic Messages response → OpenAI Responses response (non-streaming) +#[allow(dead_code)] +pub fn anthropic_response_to_responses(body: Value) -> Result { + anthropic_response_to_responses_with_context(body, &CodexToolContext::default()) +} + +pub(crate) fn anthropic_response_to_responses_with_context( + body: Value, + tool_context: &CodexToolContext, +) -> Result { + if body.get("type").and_then(Value::as_str) == Some("error") || body.get("error").is_some() { + let error = body.get("error").unwrap_or(&body); + let message = error + .get("message") + .and_then(Value::as_str) + .or_else(|| error.as_str()) + .unwrap_or("Anthropic upstream returned an error envelope"); + let error_type = error.get("type").and_then(Value::as_str).unwrap_or("error"); + return Err(ProxyError::TransformError(format!( + "Anthropic upstream {error_type}: {message}" + ))); + } + + let id = body.get("id").and_then(|i| i.as_str()).unwrap_or(""); + let response_id = if id.is_empty() { + "resp_ccswitch".to_string() + } else if id.starts_with("resp_") { + id.to_string() + } else { + format!("resp_{id}") + }; + let model = body.get("model").and_then(|m| m.as_str()).unwrap_or(""); + + let mut output: Vec = Vec::new(); + let mut text_parts: Vec = Vec::new(); + + let flush_text = |output: &mut Vec, text_parts: &mut Vec| { + if !text_parts.is_empty() { + let idx = output.len(); + output.push(json!({ + "id": format!("{response_id}_msg_{idx}"), + "type": "message", + "status": "completed", + "role": "assistant", + "content": std::mem::take(text_parts) + })); + } + }; + + if let Some(blocks) = body.get("content").and_then(|c| c.as_array()) { + for block in blocks { + match block.get("type").and_then(|t| t.as_str()).unwrap_or("") { + "text" => { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + text_parts.push(json!({ + "type": "output_text", + "text": text, + "annotations": [] + })); + } + } + "tool_use" => { + flush_text(&mut output, &mut text_parts); + let call_id = block.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let name = block.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let input = block.get("input").cloned().unwrap_or(json!({})); + let input = sanitize_anthropic_tool_use_input(name, input); + let item_id = + response_tool_call_item_id_from_chat_name(call_id, name, tool_context); + output.push(response_tool_call_item_from_chat_name( + &item_id, + "completed", + call_id, + name, + &canonical_json_string(&input), + None, + tool_context, + )); + } + "thinking" | "redacted_thinking" => { + flush_text(&mut output, &mut text_parts); + let idx = output.len(); + if let Some(item) = responses_reasoning_item_from_anthropic_block( + &format!("rs_{response_id}_{idx}"), + block, + ) { + output.push(item); + } + } + _ => {} + } + } + } + flush_text(&mut output, &mut text_parts); + + let (status, incomplete_reason) = + map_anthropic_stop_reason_to_status(body.get("stop_reason").and_then(|s| s.as_str())); + let usage = build_responses_usage_from_anthropic(body.get("usage")); + + let mut result = json!({ + "id": response_id, + "object": "response", + "created_at": 0, + "status": status, + "model": model, + "output": output, + "usage": usage + }); + if let Some(reason) = incomplete_reason { + result["incomplete_details"] = json!({ "reason": reason }); + } + + Ok(result) +} + +/// Aggregates an Anthropic Messages **SSE stream** (with no Content-Type marker) +/// back into a single Anthropic non-streaming message JSON. +/// +/// Used as a fallback: the upstream returned an SSE body for a `stream:false` +/// request but without the `text/event-stream` header (symmetric to the +/// `body_looks_like_sse` fallback on the chat / claude side, see #2234). The +/// aggregated message can be handed directly to [`anthropic_response_to_responses`]. +/// +/// It also tolerates the last event missing a trailing blank line (truncated +/// stream): after looping over complete event blocks, it processes the residual +/// buffer as the last event. +pub fn anthropic_sse_to_message_value(body: &str) -> Result { + let mut message: Option = None; + // Collect blocks by content index along with the partial_json accumulator for their tool_use. + let mut blocks: BTreeMap = BTreeMap::new(); + let mut json_accum: BTreeMap = BTreeMap::new(); + let mut stop_reason: Option = None; + let mut delta_output_tokens: Option = None; + let mut saw_message_stop = false; + + let mut buffer = body.to_string(); + let process_block = |block: &str, + message: &mut Option, + blocks: &mut BTreeMap, + json_accum: &mut BTreeMap, + stop_reason: &mut Option, + delta_output_tokens: &mut Option, + saw_message_stop: &mut bool| + -> Result<(), ProxyError> { + let mut data = String::new(); + for line in block.lines() { + if let Some(chunk) = strip_sse_field(line, "data") { + if !data.is_empty() { + data.push('\n'); + } + data.push_str(chunk); + } + } + if data.trim().is_empty() || data.trim() == "[DONE]" { + return Ok(()); + } + let value: Value = match serde_json::from_str(data.trim()) { + Ok(v) => v, + Err(_) => return Ok(()), // Skip events that cannot be parsed (ping, etc.) + }; + match value.get("type").and_then(|t| t.as_str()).unwrap_or("") { + "message_start" => { + if let Some(msg) = value.get("message") { + *message = Some(msg.clone()); + } + } + "content_block_start" => { + if let Some(index) = value.get("index").and_then(|v| v.as_u64()) { + let block = value.get("content_block").cloned().unwrap_or(json!({})); + blocks.insert(index, block); + json_accum.entry(index).or_default(); + } + } + "content_block_delta" => { + if let Some(index) = value.get("index").and_then(|v| v.as_u64()) { + let delta = value.get("delta").cloned().unwrap_or(json!({})); + match delta.get("type").and_then(|t| t.as_str()).unwrap_or("") { + "text_delta" => { + if let Some(text) = delta.get("text").and_then(|t| t.as_str()) { + append_str_field( + blocks.entry(index).or_insert(json!({})), + "text", + text, + ); + } + } + "thinking_delta" => { + if let Some(text) = delta.get("thinking").and_then(|t| t.as_str()) { + append_str_field( + blocks.entry(index).or_insert(json!({})), + "thinking", + text, + ); + } + } + "signature_delta" => { + if let Some(sig) = delta.get("signature").and_then(|t| t.as_str()) { + blocks.entry(index).or_insert(json!({}))["signature"] = json!(sig); + } + } + "input_json_delta" => { + if let Some(partial) = + delta.get("partial_json").and_then(|t| t.as_str()) + { + json_accum.entry(index).or_default().push_str(partial); + } + } + _ => {} + } + } + } + "content_block_stop" => { + if let Some(index) = value.get("index").and_then(|v| v.as_u64()) { + if let Some(accum) = json_accum.get(&index) { + if !accum.trim().is_empty() { + let parsed: Value = + serde_json::from_str(accum).unwrap_or_else(|_| json!({})); + if let Some(block) = blocks.get_mut(&index) { + block["input"] = parsed; + } + } + } + } + } + "message_delta" => { + if let Some(reason) = value.pointer("/delta/stop_reason").and_then(|v| v.as_str()) { + *stop_reason = Some(reason.to_string()); + } + if let Some(output) = value + .pointer("/usage/output_tokens") + .and_then(|v| v.as_u64()) + { + *delta_output_tokens = Some(output); + } + } + "message_stop" => *saw_message_stop = true, + "error" => { + let msg = value + .pointer("/error/message") + .and_then(|v| v.as_str()) + .unwrap_or("upstream anthropic SSE error"); + return Err(ProxyError::TransformError(format!( + "anthropic SSE error event: {msg}" + ))); + } + _ => {} + } + Ok(()) + }; + + while let Some(block) = take_sse_block(&mut buffer) { + process_block( + &block, + &mut message, + &mut blocks, + &mut json_accum, + &mut stop_reason, + &mut delta_output_tokens, + &mut saw_message_stop, + )?; + } + // Tolerate the last event missing a trailing blank line (truncated stream). + if !buffer.trim().is_empty() { + process_block( + &buffer.clone(), + &mut message, + &mut blocks, + &mut json_accum, + &mut stop_reason, + &mut delta_output_tokens, + &mut saw_message_stop, + )?; + } + + let mut message = message.ok_or_else(|| { + ProxyError::TransformError( + "anthropic SSE aggregation: missing message_start event".to_string(), + ) + })?; + + if !saw_message_stop && stop_reason.is_none() { + if blocks.is_empty() { + return Err(ProxyError::TransformError( + "anthropic SSE aggregation: stream ended before message_stop".to_string(), + )); + } + // Preserve partial content but make the truncation visible to Codex instead + // of returning a normal completed response. + stop_reason = Some("max_tokens".to_string()); + } + + // Merge in the content blocks (ordered by index), stop_reason, and the cumulative output_tokens. + let content: Vec = blocks.into_values().collect(); + message["content"] = json!(content); + if let Some(reason) = stop_reason { + message["stop_reason"] = json!(reason); + } + if let Some(output) = delta_output_tokens { + // message_delta's usage.output_tokens is a cumulative value, overriding the 0 from message_start. + if let Some(usage) = message.get_mut("usage").and_then(|u| u.as_object_mut()) { + usage.insert("output_tokens".to_string(), json!(output)); + } + } + + Ok(message) +} + +/// Appends content to a string field of a JSON object (creating it if absent). +fn append_str_field(block: &mut Value, field: &str, text: &str) { + let existing = block + .get(field) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + block[field] = json!(format!("{existing}{text}")); +} + +#[cfg(test)] +mod tests { + use super::*; + + // ==================== Request: Responses → Anthropic ==================== + + #[test] + fn test_request_simple_text() { + let input = json!({ + "model": "claude-3-5-sonnet", + "max_output_tokens": 1024, + "input": [ + { "role": "user", "content": [{ "type": "input_text", "text": "Hello" }] } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!(result["model"], "claude-3-5-sonnet"); + assert_eq!(result["max_tokens"], 1024); + assert_eq!(result["messages"][0]["role"], "user"); + assert_eq!(result["messages"][0]["content"][0]["type"], "text"); + assert_eq!(result["messages"][0]["content"][0]["text"], "Hello"); + } + + #[test] + fn test_request_missing_max_output_tokens_injects_default() { + let input = json!({ + "model": "claude", + "input": [{ "role": "user", "content": "Hi" }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!(result["max_tokens"], 4096); + } + + #[test] + fn test_request_instructions_to_system() { + let input = json!({ + "model": "claude", + "max_output_tokens": 100, + "instructions": "You are helpful.", + "input": [{ "role": "user", "content": "hi" }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!(result["system"], "You are helpful."); + } + + #[test] + fn test_request_no_instructions_no_system() { + let input = json!({ + "model": "claude", + "max_output_tokens": 100, + "input": [{ "role": "user", "content": "hi" }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert!(result.get("system").is_none()); + } + + #[test] + fn test_request_tools_and_filtering() { + let input = json!({ + "model": "claude", + "max_output_tokens": 100, + "input": [{ "role": "user", "content": "hi" }], + "tools": [ + { "type": "function", "name": "get_weather", "description": "d", "parameters": {"type": "object"} }, + { "type": "web_search" }, + { "type": "custom", "name": "apply_patch" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let tools = result["tools"].as_array().unwrap(); + assert_eq!(tools.len(), 2); + assert_eq!(tools[0]["name"], "get_weather"); + assert_eq!(tools[0]["input_schema"]["type"], "object"); + assert!(tools[0].get("parameters").is_none()); + assert_eq!(tools[1]["name"], "apply_patch"); + } + + #[test] + fn test_request_tool_choice_mapping() { + // A function tool must be present, else tool_choice is (correctly) dropped. + let base = |tc: Value| { + json!({ + "model": "c", "max_output_tokens": 100, + "input": [{ "role": "user", "content": "hi" }], + "tools": [{ "type": "function", "name": "x", "parameters": {"type": "object"} }], + "tool_choice": tc + }) + }; + assert_eq!( + responses_request_to_anthropic(base(json!("required")), 4096).unwrap()["tool_choice"], + json!({"type": "any"}) + ); + assert_eq!( + responses_request_to_anthropic(base(json!("auto")), 4096).unwrap()["tool_choice"], + json!({"type": "auto"}) + ); + assert_eq!( + responses_request_to_anthropic(base(json!({"type": "function", "name": "x"})), 4096) + .unwrap()["tool_choice"], + json!({"type": "tool", "name": "x"}) + ); + } + + #[test] + fn test_request_function_call_renests_into_assistant_tool_use() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "role": "assistant", "content": [{ "type": "output_text", "text": "Let me check" }] }, + { "type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"Tokyo\"}" }, + { "type": "function_call_output", "call_id": "call_1", "output": "sunny" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + // The assistant-first history is normalized: a synthetic user message is + // prepended, and the complete assistant tool turn remains intact. + assert_eq!(messages.len(), 3); + assert_eq!(messages[0]["role"], "user"); + assert_eq!(messages[1]["role"], "assistant"); + assert_eq!(messages[1]["content"][0]["type"], "text"); + assert_eq!(messages[1]["content"][1]["type"], "tool_use"); + assert_eq!(messages[1]["content"][1]["id"], "call_1"); + assert_eq!(messages[1]["content"][1]["input"]["city"], "Tokyo"); + } + + #[test] + fn test_request_function_call_outputs_merge_into_one_user_message() { + // Consecutive function_call_output items (each paired with a preceding + // function_call) merge into a single user message of tool_result blocks. + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" }, + { "type": "function_call", "call_id": "c2", "name": "t", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c1", "output": "A" }, + { "type": "function_call_output", "call_id": "c2", "output": "B" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + // Leading assistant history is normalized with a synthetic leading user. + let last = messages.last().unwrap(); + assert_eq!(last["role"], "user"); + let content = last["content"].as_array().unwrap(); + assert_eq!(content.len(), 2); + assert_eq!(content[0]["type"], "tool_result"); + assert_eq!(content[0]["tool_use_id"], "c1"); + assert_eq!(content[0]["content"], "A"); + assert_eq!(content[1]["tool_use_id"], "c2"); + } + + #[test] + fn test_request_unanswered_trailing_tool_use_is_dropped() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "role": "user", "content": "run it" }, + { "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["role"], "user"); + assert_eq!(messages[0]["content"][0]["text"], "run it"); + } + + #[test] + fn test_request_partial_parallel_tool_turn_is_dropped_as_a_unit() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "role": "user", "content": "run both" }, + { "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" }, + { "type": "function_call", "call_id": "c2", "name": "t", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c1", "output": "one" }, + { "role": "user", "content": [{ "type": "input_text", "text": "continue" }] } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + assert!(messages.iter().all(|message| { + message["content"].as_array().unwrap().iter().all(|block| { + !matches!( + block.get("type").and_then(Value::as_str), + Some("tool_use" | "tool_result") + ) + }) + })); + assert_eq!(messages.last().unwrap()["content"][0]["text"], "continue"); + } + + #[test] + fn test_request_tool_results_precede_user_text() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "role": "user", "content": "run it" }, + { "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" }, + { "role": "user", "content": [{ "type": "input_text", "text": "then explain" }] }, + { "type": "function_call_output", "call_id": "c1", "output": "ok" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let content = result["messages"].as_array().unwrap().last().unwrap()["content"] + .as_array() + .unwrap(); + assert_eq!(content[0]["type"], "tool_result"); + assert_eq!(content[0]["tool_use_id"], "c1"); + assert_eq!(content[1]["type"], "text"); + assert_eq!(content[1]["text"], "then explain"); + } + + #[test] + fn test_request_orphan_tool_result_dropped() { + // A function_call_output whose matching function_call was dropped (e.g. by + // compaction) becomes an orphan tool_result; it must be removed so Anthropic + // does not 400, while the rest of the turn is preserved. + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "type": "function_call_output", "call_id": "ghost", "output": "X" }, + { "role": "user", "content": [{ "type": "input_text", "text": "hello" }] } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["role"], "user"); + let content = messages[0]["content"].as_array().unwrap(); + // Only the text survives; the orphan tool_result is gone. + assert_eq!(content.len(), 1); + assert_eq!(content[0]["type"], "text"); + assert_eq!(content[0]["text"], "hello"); + } + + #[test] + fn test_request_empty_text_blocks_dropped() { + // An empty/whitespace-only assistant text emitted alongside a tool_use must be + // filtered out (Anthropic 400s on empty text blocks), keeping the tool_use, and + // a user turn made up solely of empty text must not leave an empty message. + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "role": "user", "content": [{ "type": "input_text", "text": "hi" }] }, + { "role": "assistant", "content": [{ "type": "output_text", "text": "" }] }, + { "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c1", "output": "ok" }, + { "role": "user", "content": [{ "type": "input_text", "text": " " }] } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + // Empty assistant text is gone; no message carries an empty text block. + for msg in messages { + for block in msg["content"].as_array().unwrap() { + if block["type"] == "text" { + assert!(!block["text"].as_str().unwrap().trim().is_empty()); + } + } + assert!(!msg["content"].as_array().unwrap().is_empty()); + } + // The whitespace-only trailing user turn collapsed into the tool_result user message. + let last = messages.last().unwrap(); + assert_eq!(last["role"], "user"); + assert_eq!(last["content"][0]["type"], "tool_result"); + } + + #[test] + fn test_request_all_orphan_tool_results_error() { + // If the entire input is orphan tool_results, dropping them empties the + // message list and conversion errors (nothing valid to send to Anthropic). + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "type": "function_call_output", "call_id": "c1", "output": "A" }, + { "type": "function_call_output", "call_id": "c2", "output": "B" } + ] + }); + assert!(responses_request_to_anthropic(input, 4096).is_err()); + } + + #[test] + fn test_request_paired_tool_result_kept() { + // A tool_result whose function_call is present survives the orphan guard. + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c1", "output": "ok" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + let last = messages.last().unwrap(); + assert_eq!(last["role"], "user"); + assert_eq!(last["content"][0]["type"], "tool_result"); + assert_eq!(last["content"][0]["tool_use_id"], "c1"); + } + + #[test] + fn test_request_empty_arguments_parses_to_empty_object() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "type": "function_call", "call_id": "c1", "name": "t", "arguments": "" }, + { "type": "function_call_output", "call_id": "c1", "output": "ok" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + // function_call at the head → a synthetic user is prepended, tool_use is in the second assistant message. + assert_eq!(result["messages"][1]["content"][0]["input"], json!({})); + } + + #[test] + fn test_request_image_data_url() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [{ + "role": "user", + "content": [ + { "type": "input_text", "text": "what?" }, + { "type": "input_image", "image_url": "data:image/png;base64,abc123" } + ] + }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let content = result["messages"][0]["content"].as_array().unwrap(); + assert_eq!(content[1]["type"], "image"); + assert_eq!(content[1]["source"]["type"], "base64"); + assert_eq!(content[1]["source"]["media_type"], "image/png"); + assert_eq!(content[1]["source"]["data"], "abc123"); + } + + #[test] + fn test_request_top_level_content_items() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "type": "input_text", "text": "what?" }, + { "type": "input_image", "image_url": "data:image/png;base64,abc123" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["role"], "user"); + let content = messages[0]["content"].as_array().unwrap(); + assert_eq!(content[0]["type"], "text"); + assert_eq!(content[0]["text"], "what?"); + assert_eq!(content[1]["type"], "image"); + assert_eq!(content[1]["source"]["type"], "base64"); + assert_eq!(content[1]["source"]["data"], "abc123"); + } + + #[test] + fn test_request_image_http_url() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [{ + "role": "user", + "content": [{ "type": "input_image", "image_url": "https://x/y.png" }] + }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let block = &result["messages"][0]["content"][0]; + assert_eq!(block["type"], "image"); + assert_eq!(block["source"]["type"], "url"); + assert_eq!(block["source"]["url"], "https://x/y.png"); + } + + #[test] + fn test_request_effort_to_thinking_and_drops_temperature() { + let input = json!({ + "model": "c", + // Well above 2× the high-effort budget so the output-headroom cap + // (max_tokens/2) does not clamp it — this test covers effort mapping. + "max_output_tokens": 40000, + "temperature": 0.7, + "top_p": 0.9, + "reasoning": { "effort": "high" }, + "input": [{ "role": "user", "content": "hi" }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!(result["thinking"]["type"], "enabled"); + assert_eq!(result["thinking"]["budget_tokens"], 16384); + assert!(result.get("temperature").is_none()); + assert!(result.get("top_p").is_none()); + } + + #[test] + fn test_adaptive_thinking_respects_model_defaults() { + let request = |model: &str| { + json!({ + "model": model, + "max_output_tokens": 4096, + "input": [{"role": "user", "content": "hi"}] + }) + }; + let sonnet = responses_request_to_anthropic(request("claude-sonnet-5"), 4096).unwrap(); + assert_eq!(sonnet["thinking"]["type"], "adaptive"); + + let opus = responses_request_to_anthropic(request("claude-opus-4.8"), 4096).unwrap(); + assert!(opus.get("thinking").is_none()); + } + + #[test] + fn test_request_unknown_effort_keeps_sampling_params() { + // An unrecognized effort should not enable thinking, nor swallow temperature/top_p. + let input = json!({ + "model": "c", + "max_output_tokens": 20000, + "temperature": 0.7, + "top_p": 0.9, + "reasoning": { "effort": "turbo" }, + "input": [{ "role": "user", "content": "hi" }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert!(result.get("thinking").is_none()); + assert_eq!(result["temperature"], 0.7); + assert_eq!(result["top_p"], 0.9); + } + + #[test] + fn test_request_tool_history_disables_thinking() { + // When tool history is present (function_call_output), do not inject thinking + // even if effort is set, and fall back to passing temperature/top_p through, + // to avoid the Anthropic 400 caused by a missing thinking block. + let input = json!({ + "model": "c", + "max_output_tokens": 20000, + "temperature": 0.5, + "reasoning": { "effort": "high" }, + "input": [ + { "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c1", "output": "ok" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert!(result.get("thinking").is_none()); + assert_eq!(result["temperature"], 0.5); + } + + #[test] + fn test_older_signed_turn_does_not_enable_thinking_for_unsigned_tool_call() { + let encrypted = encode_anthropic_thinking_block(&json!({ + "type": "thinking", + "thinking": "old reasoning", + "signature": "sig_old" + })) + .unwrap(); + let input = json!({ + "model": "claude-sonnet-5", + "max_output_tokens": 4096, + "reasoning": { "effort": "high" }, + "input": [ + { "role": "user", "content": "first question" }, + { "type": "reasoning", "encrypted_content": encrypted }, + { "role": "assistant", "content": [{ "type": "output_text", "text": "first answer" }] }, + { "role": "user", "content": "call the tool" }, + { "type": "function_call", "call_id": "c2", "name": "Read", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c2", "output": "ok" } + ] + }); + + let result = responses_request_to_anthropic(input, 4096).unwrap(); + + assert_eq!(result["thinking"]["type"], "disabled"); + let messages = result["messages"].as_array().unwrap(); + let paired_assistant = &messages[messages.len() - 2]; + assert_eq!(paired_assistant["role"], "assistant"); + assert_eq!(paired_assistant["content"][0]["type"], "tool_use"); + } + + #[test] + fn test_thinking_requires_all_parallel_tool_results_to_match_paired_turn() { + let paired = json!([ + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "reason", "signature": "sig"}, + {"type": "tool_use", "id": "c1", "name": "a", "input": {}}, + {"type": "tool_use", "id": "c2", "name": "b", "input": {}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "c1", "content": "one"}, + {"type": "tool_result", "tool_use_id": "c2", "content": "two"} + ]} + ]); + assert!(trailing_turn_supports_thinking(paired.as_array().unwrap())); + + let mismatched = json!([ + paired[0].clone(), + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "c1", "content": "one"}, + {"type": "tool_result", "tool_use_id": "c3", "content": "three"} + ]} + ]); + assert!(!trailing_turn_supports_thinking( + mismatched.as_array().unwrap() + )); + } + + #[test] + fn test_request_completed_tool_round_reenables_thinking() { + // A *completed* tool round (assistant answered after the tool_result) followed + // by a fresh user question: the trailing turn is text-only, so thinking must be + // re-enabled — unlike a whole-history scan, which would stay off forever. + let input = json!({ + "model": "c", + "max_output_tokens": 20000, + "reasoning": { "effort": "high" }, + "input": [ + { "role": "user", "content": [{ "type": "input_text", "text": "hi" }] }, + { "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c1", "output": "ok" }, + { "role": "assistant", "content": [{ "type": "output_text", "text": "done" }] }, + { "role": "user", "content": [{ "type": "input_text", "text": "next question" }] } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + // The last message is a text-only user turn → not mid tool-cycle → thinking on. + let messages = result["messages"].as_array().unwrap(); + let last = messages.last().unwrap(); + assert_eq!(last["role"], "user"); + assert_eq!(last["content"][0]["type"], "text"); + assert_eq!(result["thinking"]["type"], "enabled"); + } + + #[test] + fn test_request_custom_tool_survives_with_required_choice() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [{ "role": "user", "content": "hi" }], + "tools": [ + { "type": "web_search" }, + { "type": "custom", "name": "apply_patch" } + ], + "tool_choice": "required" + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!(result["tools"].as_array().unwrap().len(), 1); + assert_eq!(result["tools"][0]["name"], "apply_patch"); + assert_eq!(result["tool_choice"], json!({ "type": "any" })); + } + + #[test] + fn test_request_forced_tool_choice_disables_thinking_instead_of_downgrading() { + let input = json!({ + "model": "c", + "max_output_tokens": 20000, + "reasoning": { "effort": "high" }, + "tools": [{ "type": "function", "name": "x", "parameters": {"type": "object"} }], + "tool_choice": "required", + "input": [ + { "role": "user", "content": [{ "type": "input_text", "text": "hi" }] } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!(result["thinking"]["type"], "disabled"); + assert_eq!(result["tool_choice"], json!({ "type": "any" })); + } + + #[test] + fn test_request_forced_tool_choice_kept_without_thinking() { + // With no effort (thinking off), a forced tool_choice is kept as-is. + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "tools": [{ "type": "function", "name": "x", "parameters": {"type": "object"} }], + "tool_choice": "required", + "input": [{ "role": "user", "content": "hi" }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!(result["tool_choice"], json!({ "type": "any" })); + } + + #[test] + fn test_request_small_max_tokens_disables_thinking() { + // The chosen effort budget is clamped below max_tokens; after clamping it is + // < 1024 → disable thinking, fall back to sampling, and do not raise the + // caller's max_tokens (to avoid exceeding the model's output ceiling and 400). + let input = json!({ + "model": "c", + "max_output_tokens": 1000, + "temperature": 0.7, + "reasoning": { "effort": "high" }, + "input": [{ "role": "user", "content": "hi" }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert!(result.get("thinking").is_none()); + assert_eq!(result["max_tokens"], 1000); + assert_eq!(result["temperature"], 0.7); + } + + #[test] + fn test_request_thinking_budget_clamped_below_max_tokens() { + // The chosen effort budget exceeds half of max_tokens, so it is capped at + // max_tokens/2 (reserving the other half for the visible answer) while staying + // >= 1024, so thinking stays enabled. + let input = json!({ + "model": "c", + "max_output_tokens": 5000, + "reasoning": { "effort": "high" }, // budget 16384 + "input": [{ "role": "user", "content": "hi" }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!(result["thinking"]["type"], "enabled"); + assert_eq!(result["thinking"]["budget_tokens"], 2500); + assert_eq!(result["max_tokens"], 5000); + } + + #[test] + fn test_request_default_max_tokens_leaves_output_headroom() { + // Regression: on the no-max_output_tokens fallback path, a large derived thinking + // budget must not consume nearly all of the default max_tokens. With default 8192 + // and high effort (16384), the budget is capped at 8192/2 = 4096, leaving 4096 for + // the visible answer (previously it clamped to 8191, leaving ~1 output token). + let input = json!({ + "model": "c", + "reasoning": { "effort": "high" }, + "input": [{ "role": "user", "content": "hi" }] + }); + let result = responses_request_to_anthropic(input, 8192).unwrap(); + assert_eq!(result["thinking"]["type"], "enabled"); + assert_eq!(result["thinking"]["budget_tokens"], 4096); + assert_eq!(result["max_tokens"], 8192); + assert!( + result["max_tokens"].as_u64().unwrap() + - result["thinking"]["budget_tokens"].as_u64().unwrap() + >= 4096, + "at least half of max_tokens must remain for the visible answer" + ); + } + + #[test] + fn test_request_unpaired_tool_turn_is_dropped_before_thinking_gate() { + // A resumed/compacted history ending in an unpaired assistant tool_use is + // removed, leaving the original user prompt as a fresh thinking turn. + let input = json!({ + "model": "c", + "max_output_tokens": 40000, + "reasoning": { "effort": "high" }, + "input": [ + { "role": "user", "content": [{ "type": "input_text", "text": "run it" }] }, + { "type": "function_call", "call_id": "c1", "name": "sh", "arguments": "{}" } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["role"], "user"); + assert_eq!(result["thinking"]["type"], "enabled"); + } + + #[test] + fn test_request_reasoning_item_dropped() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "type": "reasoning", "id": "rs_1", "encrypted_content": "xxx" }, + { "role": "user", "content": [{ "type": "input_text", "text": "hi" }] } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["role"], "user"); + } + + #[test] + fn test_request_drops_openai_only_fields() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "store": false, + "include": ["reasoning.encrypted_content"], + "service_tier": "priority", + "parallel_tool_calls": true, + "input": [{ "role": "user", "content": "hi" }] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert!(result.get("store").is_none()); + assert!(result.get("include").is_none()); + assert!(result.get("service_tier").is_none()); + assert!(result.get("parallel_tool_calls").is_none()); + } + + // ==================== Response: Anthropic → Responses ==================== + + #[test] + fn test_response_text_end_turn() { + let input = json!({ + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude", + "content": [{ "type": "text", "text": "Hello!" }], + "stop_reason": "end_turn", + "usage": { "input_tokens": 10, "output_tokens": 5 } + }); + let result = anthropic_response_to_responses(input).unwrap(); + assert_eq!(result["id"], "resp_msg_1"); + assert_eq!(result["status"], "completed"); + assert_eq!(result["output"][0]["type"], "message"); + assert_eq!(result["output"][0]["content"][0]["type"], "output_text"); + assert_eq!(result["output"][0]["content"][0]["text"], "Hello!"); + assert_eq!(result["usage"]["input_tokens"], 10); + assert_eq!(result["usage"]["output_tokens"], 5); + assert_eq!(result["usage"]["total_tokens"], 15); + } + + #[test] + fn test_response_tool_use() { + let input = json!({ + "id": "msg_1", + "content": [{ + "type": "tool_use", + "id": "call_1", + "name": "get_weather", + "input": { "city": "Tokyo" } + }], + "stop_reason": "tool_use", + "usage": { "input_tokens": 10, "output_tokens": 15 } + }); + let result = anthropic_response_to_responses(input).unwrap(); + assert_eq!(result["status"], "completed"); + assert_eq!(result["output"][0]["type"], "function_call"); + assert_eq!(result["output"][0]["call_id"], "call_1"); + assert_eq!(result["output"][0]["name"], "get_weather"); + assert_eq!(result["output"][0]["arguments"], "{\"city\":\"Tokyo\"}"); + } + + #[test] + fn test_response_thinking_becomes_reasoning() { + let input = json!({ + "id": "msg_1", + "content": [ + { "type": "thinking", "thinking": "Let me think" }, + { "type": "text", "text": "answer" } + ], + "stop_reason": "end_turn", + "usage": { "input_tokens": 1, "output_tokens": 2 } + }); + let result = anthropic_response_to_responses(input).unwrap(); + assert_eq!(result["output"][0]["type"], "reasoning"); + assert_eq!(result["output"][0]["summary"][0]["text"], "Let me think"); + assert_eq!(result["output"][1]["type"], "message"); + } + + #[test] + fn test_response_max_tokens_incomplete() { + let input = json!({ + "id": "msg_1", + "content": [{ "type": "text", "text": "partial" }], + "stop_reason": "max_tokens", + "usage": { "input_tokens": 1, "output_tokens": 2 } + }); + let result = anthropic_response_to_responses(input).unwrap(); + assert_eq!(result["status"], "incomplete"); + assert_eq!(result["incomplete_details"]["reason"], "max_output_tokens"); + } + + #[test] + fn test_response_usage_cache_no_double_count() { + let input = json!({ + "id": "msg_1", + "content": [{ "type": "text", "text": "x" }], + "stop_reason": "end_turn", + "usage": { + "input_tokens": 20, + "output_tokens": 5, + "output_tokens_details": {"thinking_tokens": 3}, + "cache_read_input_tokens": 60, + "cache_creation_input_tokens": 20 + } + }); + let result = anthropic_response_to_responses(input).unwrap(); + // input_tokens = fresh + cache_read = 20 + 60 = 80 (excluding cache_creation). + // The Codex billing calculator only subtracts cache_read from input (→ billable=fresh=20), + // and separately lists cache-creation cost via cache_creation_input_tokens; folding creation into input would double-charge. + assert_eq!(result["usage"]["input_tokens"], 80); + assert_eq!(result["usage"]["output_tokens"], 5); + assert_eq!( + result["usage"]["output_tokens_details"]["reasoning_tokens"], + 3 + ); + // total still includes everything: 80 + cache_creation 20 + output 5 = 105 + assert_eq!(result["usage"]["total_tokens"], 105); + assert_eq!(result["usage"]["input_tokens_details"]["cached_tokens"], 60); + // cache_creation is passed through explicitly for downstream billing attribution (counted only once) + assert_eq!(result["usage"]["cache_creation_input_tokens"], 20); + } + + #[test] + fn test_response_read_tool_drops_empty_pages() { + let input = json!({ + "id": "msg_1", + "content": [{ + "type": "tool_use", + "id": "call_1", + "name": "Read", + "input": { "file_path": "/tmp/x", "pages": "" } + }], + "stop_reason": "tool_use" + }); + let result = anthropic_response_to_responses(input).unwrap(); + let args = result["output"][0]["arguments"].as_str().unwrap(); + assert!(args.contains("/tmp/x")); + assert!(!args.contains("pages")); + } + + #[test] + fn test_response_refusal_is_incomplete_content_filter() { + let input = json!({ + "id": "msg_1", + "content": [{ "type": "text", "text": "" }], + "stop_reason": "refusal", + "usage": { "input_tokens": 1, "output_tokens": 0 } + }); + let result = anthropic_response_to_responses(input).unwrap(); + assert_eq!(result["status"], "incomplete"); + assert_eq!(result["incomplete_details"]["reason"], "content_filter"); + } + + #[test] + fn test_signed_thinking_round_trips_through_responses_tool_loop() { + let converted = anthropic_response_to_responses(json!({ + "id": "msg_signed", + "model": "claude-sonnet-5", + "stop_reason": "tool_use", + "content": [ + {"type": "thinking", "thinking": "check", "signature": "sig_123"}, + {"type": "tool_use", "id": "call_1", "name": "Read", "input": {"path": "/tmp/a"}} + ] + })) + .unwrap(); + let reasoning = converted["output"][0].clone(); + assert!(reasoning["encrypted_content"] + .as_str() + .unwrap() + .starts_with(ANTHROPIC_THINKING_ENCRYPTED_PREFIX)); + + let replay = responses_request_to_anthropic( + json!({ + "model": "claude-sonnet-5", + "max_output_tokens": 4096, + "reasoning": {"effort": "high"}, + "input": [ + reasoning, + converted["output"][1].clone(), + {"type": "function_call_output", "call_id": "call_1", "output": "ok"} + ] + }), + 4096, + ) + .unwrap(); + assert_eq!(replay["thinking"]["type"], "adaptive"); + assert_eq!(replay["messages"][1]["content"][0]["type"], "thinking"); + assert_eq!(replay["messages"][1]["content"][0]["signature"], "sig_123"); + } + + #[test] + fn test_redacted_thinking_is_preserved_without_visible_summary() { + let response = anthropic_response_to_responses(json!({ + "id": "msg_redacted", + "content": [{"type": "redacted_thinking", "data": "opaque"}] + })) + .unwrap(); + assert_eq!(response["output"][0]["summary"], json!([])); + assert!(response["output"][0]["encrypted_content"].is_string()); + } + + #[test] + fn test_namespace_tool_response_restores_namespace() { + let context = build_codex_tool_context_from_request(&json!({ + "tools": [{ + "type": "namespace", + "name": "mcp_files", + "tools": [{"type": "function", "name": "read", "parameters": {"type": "object"}}] + }] + })); + let response = anthropic_response_to_responses_with_context( + json!({ + "id": "msg_ns", + "content": [{"type": "tool_use", "id": "call_1", "name": "mcp_files__read", "input": {}}] + }), + &context, + ) + .unwrap(); + assert_eq!(response["output"][0]["type"], "function_call"); + assert_eq!(response["output"][0]["name"], "read"); + assert_eq!(response["output"][0]["namespace"], "mcp_files"); + } + + #[test] + fn test_success_status_error_envelope_is_not_completed() { + let error = anthropic_response_to_responses(json!({ + "type": "error", + "error": {"type": "overloaded_error", "message": "busy"} + })) + .unwrap_err(); + assert!(error.to_string().contains("overloaded_error")); + } + + #[test] + fn test_context_window_stop_reason_is_incomplete() { + let response = anthropic_response_to_responses(json!({ + "id": "msg_ctx", + "stop_reason": "model_context_window_exceeded", + "content": [{"type": "text", "text": "partial"}] + })) + .unwrap(); + assert_eq!(response["status"], "incomplete"); + assert_eq!( + response["incomplete_details"]["reason"], + "max_output_tokens" + ); + } + + #[test] + fn test_request_parallel_tool_calls_false_disables_parallel_use() { + let response = responses_request_to_anthropic( + json!({ + "model": "c", + "input": [{"role": "user", "content": "hi"}], + "tools": [{"type": "function", "name": "x", "parameters": {"type": "object"}}], + "parallel_tool_calls": false + }), + 4096, + ) + .unwrap(); + assert_eq!(response["tool_choice"]["type"], "auto"); + assert_eq!(response["tool_choice"]["disable_parallel_tool_use"], true); + } + + #[test] + fn test_request_trims_trailing_assistant_whitespace() { + let response = responses_request_to_anthropic( + json!({ + "model": "c", + "input": [ + {"role": "user", "content": "continue"}, + {"role": "assistant", "content": [{"type": "output_text", "text": "prefix \n"}]} + ] + }), + 4096, + ) + .unwrap(); + assert_eq!(response["messages"][1]["content"][0]["text"], "prefix"); + } + + #[test] + fn test_structured_tool_output_preserves_text_and_image_blocks() { + let response = responses_request_to_anthropic( + json!({ + "model": "c", + "input": [ + {"type": "function_call", "call_id": "c1", "name": "inspect", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "c1", "output": [ + {"type": "output_text", "text": "result"}, + {"type": "input_image", "image_url": "data:image/png;base64,abc"} + ]} + ] + }), + 4096, + ) + .unwrap(); + let content = &response["messages"][2]["content"][0]["content"]; + assert_eq!(content[0]["type"], "text"); + assert_eq!(content[1]["type"], "image"); + } + + // ==================== Request normalization: non-empty & first is user ==================== + + #[test] + fn test_request_empty_messages_errors() { + // input is entirely reasoning (dropped) → messages is empty → error explicitly rather than leaving it to the upstream 400. + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "instructions": "sys", + "input": [ + { "type": "reasoning", "id": "rs_1", "encrypted_content": "xxx" } + ] + }); + assert!(responses_request_to_anthropic(input, 4096).is_err()); + } + + #[test] + fn test_request_assistant_first_gets_leading_user() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [ + { "role": "assistant", "content": [{ "type": "output_text", "text": "hi" }] } + ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let messages = result["messages"].as_array().unwrap(); + assert_eq!(messages[0]["role"], "user"); + assert_eq!(messages[1]["role"], "assistant"); + } + + // ==================== tools / tool_choice edge cases ==================== + + #[test] + fn test_request_tool_without_description_or_params() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [{ "role": "user", "content": "hi" }], + "tools": [ { "type": "function", "name": "noop" } ] + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + let tool = &result["tools"][0]; + // Do not emit an explicit null description; input_schema falls back to a valid object schema. + assert!(tool.get("description").is_none()); + assert_eq!(tool["input_schema"]["type"], "object"); + } + + #[test] + fn test_request_unknown_object_tool_choice_degrades_to_auto() { + let input = json!({ + "model": "c", + "max_output_tokens": 100, + "input": [{ "role": "user", "content": "hi" }], + "tools": [{ "type": "function", "name": "x", "parameters": {"type": "object"} }], + "tool_choice": { "type": "allowed_tools", "tools": [] } + }); + let result = responses_request_to_anthropic(input, 4096).unwrap(); + assert_eq!(result["tool_choice"], json!({ "type": "auto" })); + } + + // ==================== SSE aggregation fallback ==================== + + #[test] + fn test_anthropic_sse_aggregation_text_and_usage() { + let sse = "event: message_start\n\ +data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude\",\"content\":[],\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}\n\n\ +event: content_block_start\n\ +data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n\ +event: content_block_delta\n\ +data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n\ +event: content_block_delta\n\ +data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}\n\n\ +event: content_block_stop\n\ +data: {\"type\":\"content_block_stop\",\"index\":0}\n\n\ +event: message_delta\n\ +data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":7}}\n\n\ +event: message_stop\n\ +data: {\"type\":\"message_stop\"}\n\n"; + let msg = anthropic_sse_to_message_value(sse).unwrap(); + assert_eq!(msg["content"][0]["type"], "text"); + assert_eq!(msg["content"][0]["text"], "Hello world"); + assert_eq!(msg["stop_reason"], "end_turn"); + assert_eq!(msg["usage"]["input_tokens"], 10); + assert_eq!(msg["usage"]["output_tokens"], 7); + + // The aggregated result can be converted directly into Responses. + let resp = anthropic_response_to_responses(msg).unwrap(); + assert_eq!(resp["status"], "completed"); + assert_eq!(resp["output"][0]["content"][0]["text"], "Hello world"); + } + + #[test] + fn test_anthropic_sse_aggregation_tool_use_partial_json() { + let sse = "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"c\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n\ +data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{}}}\n\n\ +data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\"}}\n\n\ +data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"Tokyo\\\"}\"}}\n\n\ +data: {\"type\":\"content_block_stop\",\"index\":0}\n\n\ +data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":3}}\n\n"; + let msg = anthropic_sse_to_message_value(sse).unwrap(); + assert_eq!(msg["content"][0]["type"], "tool_use"); + assert_eq!(msg["content"][0]["name"], "get_weather"); + assert_eq!(msg["content"][0]["input"]["city"], "Tokyo"); + assert_eq!(msg["stop_reason"], "tool_use"); + } + + #[test] + fn test_anthropic_sse_aggregation_tool_use_input_only_in_start() { + // Parity guard with the live streaming emitter's + // `test_tool_use_input_only_in_start_event`: a gateway that carries the full tool + // `input` on content_block_start and emits NO input_json_delta must still resolve + // the same arguments (the empty-accum fallback keeps the start-carried input). + let sse = "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"c\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n\ +data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Tokyo\"}}}\n\n\ +data: {\"type\":\"content_block_stop\",\"index\":0}\n\n\ +data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":3}}\n\n"; + let msg = anthropic_sse_to_message_value(sse).unwrap(); + assert_eq!(msg["content"][0]["type"], "tool_use"); + assert_eq!(msg["content"][0]["name"], "get_weather"); + // Identical to the deltas-only case above — neither path may drop start input. + assert_eq!(msg["content"][0]["input"]["city"], "Tokyo"); + assert_eq!(msg["stop_reason"], "tool_use"); + } + + #[test] + fn test_anthropic_sse_aggregation_missing_message_start_errors() { + let sse = "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n"; + assert!(anthropic_sse_to_message_value(sse).is_err()); + } + + #[test] + fn test_anthropic_sse_aggregation_error_event_errors() { + let sse = "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"overloaded\"}}\n\n"; + assert!(anthropic_sse_to_message_value(sse).is_err()); + } + + #[test] + fn test_anthropic_sse_aggregation_tolerates_missing_trailing_blank_line() { + // The last event missing a trailing blank line (truncated stream) should still be processed. + let sse = "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"c\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n\ +data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"hi\"}}\n\n\ +data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":2}}"; + let msg = anthropic_sse_to_message_value(sse).unwrap(); + assert_eq!(msg["stop_reason"], "end_turn"); + assert_eq!(msg["usage"]["output_tokens"], 2); + } + + #[test] + fn test_anthropic_sse_aggregation_truncated_output_is_incomplete() { + let sse = "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n\ +data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"partial\"}}\n\n"; + let message = anthropic_sse_to_message_value(sse).unwrap(); + let response = anthropic_response_to_responses(message).unwrap(); + assert_eq!(response["status"], "incomplete"); + assert_eq!( + response["incomplete_details"]["reason"], + "max_output_tokens" + ); + } + + #[test] + fn test_anthropic_sse_aggregation_truncated_without_output_errors() { + let sse = + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n"; + assert!(anthropic_sse_to_message_value(sse).is_err()); + } +} diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index b8bd0e1c8..315c0a494 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -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 diff --git a/src-tauri/src/proxy/thinking_optimizer.rs b/src-tauri/src/proxy/thinking_optimizer.rs index 7bbe5d3ba..90cf892a4 100644 --- a/src-tauri/src/proxy/thinking_optimizer.rs +++ b/src-tauri/src/proxy/thinking_optimizer.rs @@ -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!({ diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index 93565d28f..e37215117 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -159,9 +159,7 @@ impl ConfigService { } let cfg_text = settings.get("config").and_then(Value::as_str); - let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format( - provider.meta.as_ref().and_then(|m| m.api_format.as_deref()), - ); + let profile = crate::proxy::providers::resolve_codex_catalog_tool_profile(provider); crate::codex_config::write_codex_provider_live_with_catalog( &provider.settings_config, diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index dde293bd8..7b3451ae9 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -803,12 +803,11 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re .ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?; let config_str = obj.get("config").and_then(|v| v.as_str()); - // Native (direct) Responses providers must suppress Codex's freeform - // apply_patch custom tool via the generated catalog; chat/proxy - // providers keep the default tool set. Keyed on provider.meta.apiFormat. - let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format( - provider.meta.as_ref().and_then(|m| m.api_format.as_deref()), - ); + // Native (direct) Responses and Anthropic providers must suppress Codex's + // freeform apply_patch custom tool via the generated catalog; chat/proxy + // providers keep the default tool set. Uses the same Anthropic detection as + // the proxy router (apiFormat meta/settings + TOML wire_api). + let profile = crate::proxy::providers::resolve_codex_catalog_tool_profile(provider); crate::codex_config::write_codex_provider_live_with_catalog( &provider.settings_config, diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 36a65c03f..72c1e8228 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -2190,9 +2190,7 @@ impl ProxyService { .get("auth") .ok_or_else(|| "Codex 供应商缺少 auth 配置".to_string())?; let config_str = effective_settings.get("config").and_then(|v| v.as_str()); - let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format( - provider.meta.as_ref().and_then(|m| m.api_format.as_deref()), - ); + let profile = crate::proxy::providers::resolve_codex_catalog_tool_profile(&provider); crate::codex_config::write_codex_provider_live_with_catalog( &effective_settings, @@ -2459,9 +2457,7 @@ impl ProxyService { .get("auth") .ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?; let config_str = config.get("config").and_then(|v| v.as_str()); - let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format( - provider.meta.as_ref().and_then(|m| m.api_format.as_deref()), - ); + let profile = crate::proxy::providers::resolve_codex_catalog_tool_profile(provider); crate::codex_config::write_codex_provider_live_with_catalog( config, @@ -2488,9 +2484,9 @@ impl ProxyService { .filter(|auth| Self::codex_auth_has_proxy_placeholder(auth)) { let config_str = config.get("config").and_then(|v| v.as_str()).unwrap_or(""); - let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format( - provider.and_then(|p| p.meta.as_ref()?.api_format.as_deref()), - ); + let profile = provider + .map(crate::proxy::providers::resolve_codex_catalog_tool_profile) + .unwrap_or(crate::codex_config::CodexCatalogToolProfile::ProxyChat); let prepared_config = crate::codex_config::prepare_codex_live_config_text_with_optional_catalog( config, config_str, profile, diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 23bd7c308..48a82a282 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -22,6 +22,7 @@ import { extractCodexBaseUrl, extractCodexExperimentalBearerToken, extractCodexWireApi, + isCodexAnthropicWireApi, isCodexChatWireApi, } from "@/utils/providerConfigUtils"; import { useProviderHealth } from "@/lib/query/failover"; @@ -220,11 +221,16 @@ export function ProviderCard({ provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH; const codexNeedsRouting = useMemo(() => { if (appId !== "codex" || provider.category === "official") return false; - if (provider.meta?.apiFormat === "openai_chat") return true; + if ( + provider.meta?.apiFormat === "openai_chat" || + provider.meta?.apiFormat === "anthropic" + ) + return true; const config = (provider.settingsConfig as Record)?.config; return ( typeof config === "string" && - isCodexChatWireApi(extractCodexWireApi(config)) + (isCodexChatWireApi(extractCodexWireApi(config)) || + isCodexAnthropicWireApi(extractCodexWireApi(config))) ); }, [ appId, diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index 3e752f73c..08184829b 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -36,6 +36,7 @@ import { CustomUserAgentField } from "./CustomUserAgentField"; import { LocalProxyRequestOverridesField } from "./LocalProxyRequestOverridesField"; import { cn } from "@/lib/utils"; import type { + ClaudeApiKeyField, CodexApiFormat, CodexCatalogModel, CodexChatReasoning, @@ -73,6 +74,15 @@ interface CodexFormFieldsProps { // Note: wire_api is always "responses" for Codex; apiFormat controls proxy-layer conversion apiFormat: CodexApiFormat; onApiFormatChange: (format: CodexApiFormat) => void; + // Auth field for the Anthropic Messages upstream (only used when apiFormat === "anthropic") + anthropicAuthField: ClaudeApiKeyField; + onAnthropicAuthFieldChange: (value: ClaudeApiKeyField) => void; + // Anthropic path: whether to emulate the Claude Code client + impersonateClaudeCode: boolean; + onImpersonateClaudeCodeChange: (value: boolean) => void; + // Anthropic path: output ceiling override (empty string = use default). Digits only. + maxOutputTokens: string; + onMaxOutputTokensChange: (value: string) => void; codexChatReasoning?: CodexChatReasoning; onCodexChatReasoningChange?: (value: CodexChatReasoning) => void; @@ -158,6 +168,12 @@ export function CodexFormFields({ onAutoSelectChange, apiFormat, onApiFormatChange, + anthropicAuthField, + onAnthropicAuthFieldChange, + impersonateClaudeCode, + onImpersonateClaudeCodeChange, + maxOutputTokens, + onMaxOutputTokensChange, codexChatReasoning = {}, onCodexChatReasoningChange, catalogModels = [], @@ -177,6 +193,7 @@ export function CodexFormFields({ // 思考能力随 Chat 格式显示(仅 Chat Completions 转换路径用得上);模型映射常驻 //(填了才生成 catalog)。两者都已与「路由接管」概念解耦。 const isChatFormat = apiFormat === "openai_chat"; + const isAnthropicFormat = apiFormat === "anthropic"; const canEditCatalog = Boolean(onCatalogModelsChange); const canEditReasoning = Boolean(onCodexChatReasoningChange); const supportsThinking = @@ -194,8 +211,10 @@ export function CodexFormFields({ hasRequestOverrides || catalogModels.length > 0 || apiFormat === "openai_responses" || + isAnthropicFormat || supportsThinking || - supportsEffort; + supportsEffort || + !!maxOutputTokens; const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue); // 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠) @@ -449,15 +468,118 @@ export function CodexFormFields({ defaultValue: "Responses(原生)", })} + + {t("codexConfig.upstreamFormatAnthropic", { + defaultValue: "Anthropic Messages(需开启路由)", + })} +

{t("codexConfig.upstreamFormatHint", { defaultValue: - "供应商原生是 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat(需开启路由接管才能转换为 Chat Completions)。", + "供应商原生是 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat;供应商只提供原生 Anthropic Messages 协议就选 Anthropic Messages。Chat 与 Anthropic Messages 均需开启路由接管才能转换为 Responses。", })}

+ + {isAnthropicFormat && ( +
+ + {t("codexConfig.anthropicAuthFieldLabel", { + defaultValue: "认证字段", + })} + + +

+ {t("codexConfig.anthropicAuthFieldHint", { + defaultValue: + "选择网关接收 API Key 的请求头:ANTHROPIC_AUTH_TOKEN 发送 Authorization: Bearer;ANTHROPIC_API_KEY 发送 x-api-key。两者只发其一。", + })} +

+
+ )} + + {isAnthropicFormat && ( +
+
+ + {t("codexConfig.impersonateClaudeCodeLabel", { + defaultValue: "模拟 Claude Code 客户端", + })} + +

+ {t("codexConfig.impersonateClaudeCodeHint", { + defaultValue: + "网关或其上游限制只能通过 Claude Code 使用时开启:伪装 User-Agent、anthropic-beta、x-app 请求头,并在系统提示首行注入 Claude Code 身份。", + })} +

+
+ +
+ )} + + {isAnthropicFormat && ( +
+ + {t("codexConfig.maxOutputTokensLabel", { + defaultValue: "最大输出 tokens", + })} + + + onMaxOutputTokensChange( + event.target.value.replace(/[^\d]/g, ""), + ) + } + placeholder={t("codexConfig.maxOutputTokensPlaceholder", { + defaultValue: "留空则使用默认 8192", + })} + /> +

+ {t("codexConfig.maxOutputTokensHint", { + defaultValue: + "Codex 不会把 model_max_output_tokens 写进请求体,默认上限 8192 容易在长回答或深度思考时被截断(stop_reason=max_tokens)。此处设置会作为 Anthropic 的 max_tokens 覆盖请求值。请勿超过该模型/网关的真实输出上限,否则可能 400。留空使用默认 8192。", + })} +

+
+ )} )} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 190ca58cd..d5192dde5 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -60,6 +60,7 @@ import { } from "@/utils/providerConfigUtils"; import { mergeProviderMeta } from "@/utils/providerMetaUtils"; import { + codexApiFormatFromWireApi, extractCodexWireApi, setCodexWireApi, setCodexModelName as setCodexModelNameInConfig, @@ -131,25 +132,6 @@ type PresetEntry = { | HermesProviderPreset; }; -const codexApiFormatFromWireApi = ( - wireApi: string | undefined, -): CodexApiFormat | undefined => { - switch (wireApi?.trim().toLowerCase()) { - case "chat": - case "chat_completions": - case "chat-completions": - case "openai_chat": - case "openai-chat": - return "openai_chat"; - case "responses": - case "openai_responses": - case "openai-responses": - return "openai_responses"; - default: - return undefined; - } -}; - export const normalizeCodexCatalogModelsForSave = ( models: CodexCatalogModel[], ): CodexCatalogModel[] => { @@ -586,19 +568,43 @@ function ProviderFormFull({ const initialCodexApiFormat: CodexApiFormat = initialData?.meta?.apiFormat === "openai_chat" ? "openai_chat" - : initialData?.meta?.apiFormat === "openai_responses" - ? "openai_responses" - : (codexApiFormatFromWireApi( - extractCodexWireApi( - typeof initialData?.settingsConfig?.config === "string" - ? initialData.settingsConfig.config - : "", - ), - ) ?? "openai_responses"); + : initialData?.meta?.apiFormat === "anthropic" + ? "anthropic" + : initialData?.meta?.apiFormat === "openai_responses" + ? "openai_responses" + : (codexApiFormatFromWireApi( + extractCodexWireApi( + typeof initialData?.settingsConfig?.config === "string" + ? initialData.settingsConfig.config + : "", + ), + ) ?? "openai_responses"); const [localCodexApiFormat, setLocalCodexApiFormat] = useState(initialCodexApiFormat); + // Auth-field choice for the Anthropic Messages upstream (defaults to the Bearer form) + const initialCodexAnthropicAuthField: ClaudeApiKeyField = + initialData?.meta?.apiKeyField === "ANTHROPIC_API_KEY" + ? "ANTHROPIC_API_KEY" + : "ANTHROPIC_AUTH_TOKEN"; + const [localCodexAnthropicAuthField, setLocalCodexAnthropicAuthField] = + useState(initialCodexAnthropicAuthField); + + // Emulate the Claude Code client: off by default, enabled only when the user explicitly turns it on (true) + const [localCodexImpersonateClaudeCode, setLocalCodexImpersonateClaudeCode] = + useState(initialData?.meta?.impersonateClaudeCode === true); + + // Codex → Anthropic output ceiling override (empty string = use the 8192 default). + // Kept as a string so the numeric input can be cleared; parsed on save. + const [localCodexMaxOutputTokens, setLocalCodexMaxOutputTokens] = + useState( + typeof initialData?.meta?.maxOutputTokens === "number" && + initialData.meta.maxOutputTokens > 0 + ? String(initialData.meta.maxOutputTokens) + : "", + ); + const { configError: codexConfigError, debouncedValidate } = useCodexTomlValidation(); @@ -1502,6 +1508,28 @@ function ProviderFormFull({ category !== "official" && localApiKeyField !== "ANTHROPIC_AUTH_TOKEN" ? localApiKeyField + : appId === "codex" && + category !== "official" && + localCodexApiFormat === "anthropic" && + localCodexAnthropicAuthField !== "ANTHROPIC_AUTH_TOKEN" + ? localCodexAnthropicAuthField + : undefined, + // Off by default; persist true only for codex+anthropic when the user explicitly enables it + impersonateClaudeCode: + appId === "codex" && + category !== "official" && + localCodexApiFormat === "anthropic" && + localCodexImpersonateClaudeCode + ? true + : undefined, + // Persist only for codex+anthropic when a positive value was entered + maxOutputTokens: + appId === "codex" && + category !== "official" && + localCodexApiFormat === "anthropic" && + localCodexMaxOutputTokens.trim() !== "" && + Number(localCodexMaxOutputTokens) > 0 + ? Number(localCodexMaxOutputTokens) : undefined, isFullUrl: supportsFullUrl && category !== "official" && localIsFullUrl @@ -2142,6 +2170,12 @@ function ProviderFormFull({ onAutoSelectChange={setEndpointAutoSelect} apiFormat={localCodexApiFormat} onApiFormatChange={handleCodexApiFormatChange} + anthropicAuthField={localCodexAnthropicAuthField} + onAnthropicAuthFieldChange={setLocalCodexAnthropicAuthField} + impersonateClaudeCode={localCodexImpersonateClaudeCode} + onImpersonateClaudeCodeChange={setLocalCodexImpersonateClaudeCode} + maxOutputTokens={localCodexMaxOutputTokens} + onMaxOutputTokensChange={setLocalCodexMaxOutputTokens} codexChatReasoning={codexChatReasoning} onCodexChatReasoningChange={setCodexChatReasoning} catalogModels={codexCatalogModels} diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 2f03eb14d..21a681b65 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -22,6 +22,7 @@ import { extractErrorMessage } from "@/utils/errorUtils"; import { openclawKeys } from "@/hooks/useOpenClaw"; import { extractCodexWireApi, + isCodexAnthropicWireApi, isCodexChatWireApi, } from "@/utils/providerConfigUtils"; @@ -165,6 +166,16 @@ export function useProviderActions( (provider.settingsConfig as Record).config, ), ))); + const isCodexAnthropicFormat = + activeApp === "codex" && + (provider.meta?.apiFormat === "anthropic" || + (typeof (provider.settingsConfig as Record)?.config === + "string" && + isCodexAnthropicWireApi( + extractCodexWireApi( + (provider.settingsConfig as Record).config, + ), + ))); // Determine why this provider requires the proxy let proxyRequiredReason: string | null = null; @@ -191,6 +202,13 @@ export function useProviderActions( proxyRequiredReason = t("notifications.proxyReasonOpenAIChat", { defaultValue: "使用 OpenAI Chat 接口格式", }); + } else if (isCodexAnthropicFormat) { + proxyRequiredReason = t( + "notifications.proxyReasonAnthropicMessages", + { + defaultValue: "使用 Anthropic Messages 接口格式", + }, + ); } else if ( activeApp === "claude-desktop" && provider.meta?.claudeDesktopMode === "proxy" diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e0e7aca32..b05f8514b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1283,9 +1283,19 @@ "modelNameHint": "Specify the model to use, will be auto-updated in config.toml", "modelName": "Model Name", "upstreamFormatLabel": "Upstream Format", - "upstreamFormatHint": "Pick Responses when your provider is natively a Responses API (direct, no format conversion); pick Chat when it uses the Chat Completions protocol (requires routing takeover to convert to Chat Completions).", + "upstreamFormatHint": "Pick Responses when your provider is natively a Responses API (direct, no format conversion); pick Chat when it uses the Chat Completions protocol; pick Anthropic Messages when it only offers the native Anthropic Messages protocol. Chat and Anthropic Messages both require routing takeover to convert to Responses.", "upstreamFormatChat": "Chat Completions (routing required)", "upstreamFormatResponses": "Responses (native)", + "upstreamFormatAnthropic": "Anthropic Messages (routing required)", + "anthropicAuthFieldLabel": "Auth field", + "anthropicAuthFieldAuthToken": "ANTHROPIC_AUTH_TOKEN (Authorization)", + "anthropicAuthFieldApiKey": "ANTHROPIC_API_KEY (x-api-key)", + "anthropicAuthFieldHint": "Choose which header carries the API key to the gateway: ANTHROPIC_AUTH_TOKEN sends Authorization: Bearer; ANTHROPIC_API_KEY sends x-api-key. Only one is sent.", + "impersonateClaudeCodeLabel": "Emulate Claude Code client", + "impersonateClaudeCodeHint": "Enable when the gateway (or its upstream) restricts usage to Claude Code: spoofs the User-Agent, anthropic-beta and x-app headers, and injects the Claude Code identity as the first system prompt line.", + "maxOutputTokensLabel": "Max output tokens", + "maxOutputTokensPlaceholder": "Leave empty to use the default 8192", + "maxOutputTokensHint": "Codex does not forward model_max_output_tokens in the request body, so the default 8192 ceiling can truncate long or thinking-heavy responses (stop_reason=max_tokens). This value overrides the request's max_tokens on the Anthropic path. Do not exceed the real output ceiling of the model/gateway or it may 400. Leave empty to use the default 8192.", "upstreamModelName": "Upstream Model Name", "upstreamModelNameHint": "For Chat format, enter the real upstream model here; routing converts Codex Responses requests to Chat Completions while keeping this model.", "modelNamePlaceholder": "e.g., gpt-5-codex", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 834d6dd0c..79a338457 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1283,9 +1283,19 @@ "modelNameHint": "使用するモデルを指定します。config.toml に自動更新されます", "modelName": "モデル名", "upstreamFormatLabel": "上流フォーマット", - "upstreamFormatHint": "プロバイダーがネイティブ Responses API なら Responses を選択(直結、フォーマット変換なし)。Chat Completions プロトコルなら Chat を選択(Chat Completions へ変換するにはルーティング引き継ぎを有効化する必要があります)。", + "upstreamFormatHint": "プロバイダーがネイティブ Responses API なら Responses を選択(直結、フォーマット変換なし)。Chat Completions プロトコルなら Chat を選択。ネイティブ Anthropic Messages プロトコルのみ提供する場合は Anthropic Messages を選択。Chat と Anthropic Messages はどちらも Responses への変換にルーティング引き継ぎの有効化が必要です。", "upstreamFormatChat": "Chat Completions(ルーティング必須)", "upstreamFormatResponses": "Responses(ネイティブ)", + "upstreamFormatAnthropic": "Anthropic Messages(ルーティング必須)", + "anthropicAuthFieldLabel": "認証フィールド", + "anthropicAuthFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(Authorization)", + "anthropicAuthFieldApiKey": "ANTHROPIC_API_KEY(x-api-key)", + "anthropicAuthFieldHint": "ゲートウェイに API キーを渡すヘッダーを選択します。ANTHROPIC_AUTH_TOKEN は Authorization: Bearer、ANTHROPIC_API_KEY は x-api-key を送信します。送信されるのはどちらか一方のみです。", + "impersonateClaudeCodeLabel": "Claude Code クライアントを模倣", + "impersonateClaudeCodeHint": "ゲートウェイ(またはその上流)が Claude Code のみに制限している場合に有効化します。User-Agent・anthropic-beta・x-app ヘッダーを偽装し、システムプロンプトの先頭行に Claude Code のアイデンティティを注入します。", + "maxOutputTokensLabel": "最大出力トークン", + "maxOutputTokensPlaceholder": "空欄の場合はデフォルトの 8192 を使用", + "maxOutputTokensHint": "Codex は model_max_output_tokens をリクエストボディに含めないため、デフォルト上限 8192 では長い回答や深い思考時に切り詰められることがあります(stop_reason=max_tokens)。この値は Anthropic 経路で max_tokens を上書きします。モデル/ゲートウェイの実際の出力上限を超えると 400 になる場合があります。空欄でデフォルトの 8192 を使用します。", "upstreamModelName": "上流モデル名", "upstreamModelNameHint": "Chat 形式ではここに実際の上流モデルを入力します。ルーティングで Codex の Responses リクエストを Chat Completions に変換し、このモデルを維持します。", "modelNamePlaceholder": "例: gpt-5-codex", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 1d3212828..9f09c1175 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1260,9 +1260,19 @@ "autoCompactLimitHint": "上下文 token 數達到此閾值時自動壓縮歷史", "autoCompactLimitPlaceholder": "例如: 90000", "upstreamFormatLabel": "上游格式", - "upstreamFormatHint": "供應商原生為 Responses API 就選 Responses(直連,不轉換格式);使用 Chat Completions 協定就選 Chat(需開啟路由接管才能轉換為 Chat Completions)。", + "upstreamFormatHint": "供應商原生為 Responses API 就選 Responses(直連,不轉換格式);使用 Chat Completions 協定就選 Chat;供應商只提供原生 Anthropic Messages 協定就選 Anthropic Messages。Chat 與 Anthropic Messages 均需開啟路由接管才能轉換為 Responses。", "upstreamFormatChat": "Chat Completions(需開啟路由)", "upstreamFormatResponses": "Responses(原生)", + "upstreamFormatAnthropic": "Anthropic Messages(需開啟路由)", + "anthropicAuthFieldLabel": "認證欄位", + "anthropicAuthFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(Authorization)", + "anthropicAuthFieldApiKey": "ANTHROPIC_API_KEY(x-api-key)", + "anthropicAuthFieldHint": "選擇閘道接收 API Key 的請求標頭:ANTHROPIC_AUTH_TOKEN 傳送 Authorization: Bearer;ANTHROPIC_API_KEY 傳送 x-api-key。兩者只傳其一。", + "impersonateClaudeCodeLabel": "模擬 Claude Code 用戶端", + "impersonateClaudeCodeHint": "閘道或其上游限制只能透過 Claude Code 使用時開啟:偽裝 User-Agent、anthropic-beta、x-app 請求標頭,並在系統提示首行注入 Claude Code 身分。", + "maxOutputTokensLabel": "最大輸出 tokens", + "maxOutputTokensPlaceholder": "留空則使用預設 8192", + "maxOutputTokensHint": "Codex 不會把 model_max_output_tokens 寫進請求體,預設上限 8192 容易在長回答或深度思考時被截斷(stop_reason=max_tokens)。此處設定會作為 Anthropic 的 max_tokens 覆蓋請求值。請勿超過該模型/閘道的真實輸出上限,否則可能 400。留空使用預設 8192。", "modelMappingTitle": "模型映射", "modelMappingHint": "產生 Codex model_catalog_json,讓 /model 指令顯示這些第三方模型名;表中條目按填寫內容原樣儲存。修改後需要重新啟動 Codex 才能重新整理模型清單。", "addCatalogModel": "新增模型", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 774bae37e..6437a7f39 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1283,9 +1283,19 @@ "modelNameHint": "指定使用的模型,将自动更新到 config.toml 中", "modelName": "模型名称", "upstreamFormatLabel": "上游格式", - "upstreamFormatHint": "供应商原生为 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat(需开启路由接管才能转换为 Chat Completions)。", + "upstreamFormatHint": "供应商原生为 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat;供应商只提供原生 Anthropic Messages 协议就选 Anthropic Messages。Chat 与 Anthropic Messages 均需开启路由接管才能转换为 Responses。", "upstreamFormatChat": "Chat Completions(需开启路由)", "upstreamFormatResponses": "Responses(原生)", + "upstreamFormatAnthropic": "Anthropic Messages(需开启路由)", + "anthropicAuthFieldLabel": "认证字段", + "anthropicAuthFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(Authorization)", + "anthropicAuthFieldApiKey": "ANTHROPIC_API_KEY(x-api-key)", + "anthropicAuthFieldHint": "选择网关接收 API Key 的请求头:ANTHROPIC_AUTH_TOKEN 发送 Authorization: Bearer;ANTHROPIC_API_KEY 发送 x-api-key。两者只发其一。", + "impersonateClaudeCodeLabel": "模拟 Claude Code 客户端", + "impersonateClaudeCodeHint": "网关或其上游限制只能通过 Claude Code 使用时开启:伪装 User-Agent、anthropic-beta、x-app 请求头,并在系统提示首行注入 Claude Code 身份。", + "maxOutputTokensLabel": "最大输出 tokens", + "maxOutputTokensPlaceholder": "留空则使用默认 8192", + "maxOutputTokensHint": "Codex 不会把 model_max_output_tokens 写进请求体,默认上限 8192 容易在长回答或深度思考时被截断(stop_reason=max_tokens)。此处设置会作为 Anthropic 的 max_tokens 覆盖请求值。请勿超过该模型/网关的真实输出上限,否则可能 400。留空使用默认 8192。", "upstreamModelName": "上游模型名称", "upstreamModelNameHint": "Chat 格式下这里填写真实上游模型;路由会把 Codex 的 Responses 请求转换为 Chat Completions 并保持该模型。", "modelNamePlaceholder": "例如: gpt-5-codex", diff --git a/src/types.ts b/src/types.ts index d8768a092..eab67ac61 100644 --- a/src/types.ts +++ b/src/types.ts @@ -224,6 +224,14 @@ export interface ProviderMeta { codexFastMode?: boolean; // Codex Responses -> Chat Completions reasoning capability metadata codexChatReasoning?: CodexChatReasoning; + // Codex → Anthropic path: emulate the Claude Code client (disabled by default; only an explicit true enables it) + impersonateClaudeCode?: boolean; + // Codex → Anthropic path: override the Anthropic max_tokens (output ceiling). + // Codex does not forward model_max_output_tokens in the request body; without + // this the path falls back to a conservative 8192 default, which can truncate + // long/thinking-heavy responses. When set (>0) it takes precedence over the + // request value and the default. + maxOutputTokens?: number; // Custom User-Agent for local proxy routing. Only applied by the local proxy. customUserAgent?: string; // Local proxy request overrides. Only applied by the local proxy after route transforms. @@ -254,7 +262,8 @@ export type ClaudeApiFormat = // Codex API 格式类型 // - "openai_responses": OpenAI Responses API 格式,直接透传 // - "openai_chat": OpenAI Chat Completions 格式,需要本地路由转换 -export type CodexApiFormat = "openai_responses" | "openai_chat"; +// - "anthropic": native Anthropic Messages format, needs local routing to convert to Responses +export type CodexApiFormat = "openai_responses" | "openai_chat" | "anthropic"; export interface CodexCatalogModel { model: string; diff --git a/src/utils/providerConfigUtils.test.ts b/src/utils/providerConfigUtils.test.ts index adda5b62b..7171f5d33 100644 --- a/src/utils/providerConfigUtils.test.ts +++ b/src/utils/providerConfigUtils.test.ts @@ -1,9 +1,35 @@ import { describe, expect, it } from "vitest"; import { + codexApiFormatFromWireApi, + isCodexAnthropicWireApi, isCodexRemoteCompactionEnabled, setCodexRemoteCompaction, } from "./providerConfigUtils"; +describe("Codex wire API helpers", () => { + it("recognizes Anthropic Messages aliases", () => { + expect(isCodexAnthropicWireApi("anthropic")).toBe(true); + expect(isCodexAnthropicWireApi("anthropic_messages")).toBe(true); + expect(isCodexAnthropicWireApi("messages")).toBe(true); + expect(isCodexAnthropicWireApi("claude")).toBe(true); + expect(isCodexAnthropicWireApi("responses")).toBe(false); + }); + + it("maps every backend-supported Anthropic alias to the form format", () => { + for (const wireApi of [ + "anthropic", + "anthropic_messages", + "anthropic-messages", + "messages", + "claude", + ]) { + expect(codexApiFormatFromWireApi(wireApi)).toBe("anthropic"); + } + expect(codexApiFormatFromWireApi("responses")).toBe("openai_responses"); + expect(codexApiFormatFromWireApi("chat_completions")).toBe("openai_chat"); + }); +}); + describe("Codex remote compaction config helpers", () => { it("enables remote compaction by naming the active custom provider OpenAI", () => { const input = `model_provider = "custom" diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index 6ae912822..b671a8ce8 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -1,6 +1,7 @@ // 供应商配置处理工具函数 import type { TemplateValueConfig } from "../config/claudeProviderPresets"; +import type { CodexApiFormat } from "@/types"; import { deepClone } from "@/utils/deepClone"; import { normalizeTomlText } from "@/utils/textNormalization"; import { parse as parseToml } from "smol-toml"; @@ -683,6 +684,32 @@ export const isCodexChatWireApi = ( ): boolean => CODEX_CHAT_WIRE_API_VALUES.has((wireApi ?? "").trim().toLowerCase()); +export const isCodexAnthropicWireApi = ( + wireApi: string | undefined | null, +): boolean => + [ + "anthropic", + "anthropic_messages", + "anthropic-messages", + "messages", + "claude", + ].includes((wireApi ?? "").trim().toLowerCase()); + +export const codexApiFormatFromWireApi = ( + wireApi: string | undefined | null, +): CodexApiFormat | undefined => { + if (isCodexChatWireApi(wireApi)) return "openai_chat"; + if (isCodexAnthropicWireApi(wireApi)) return "anthropic"; + switch ((wireApi ?? "").trim().toLowerCase()) { + case "responses": + case "openai_responses": + case "openai-responses": + return "openai_responses"; + default: + return undefined; + } +}; + // 从 Codex 的 TOML 配置文本中提取 wire_api(支持单/双引号) export const extractCodexWireApi = ( configText: string | undefined | null, diff --git a/tests/hooks/useProviderActions.test.tsx b/tests/hooks/useProviderActions.test.tsx index fa1826e98..ab0558a56 100644 --- a/tests/hooks/useProviderActions.test.tsx +++ b/tests/hooks/useProviderActions.test.tsx @@ -242,6 +242,28 @@ describe("useProviderActions", () => { expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id); }); + it("warns when switching a Codex Anthropic-format provider without proxy", async () => { + switchProviderMutateAsync.mockResolvedValueOnce(undefined); + const { wrapper } = createWrapper(); + const provider = createProvider({ + category: "custom", + meta: { apiFormat: "anthropic" }, + }); + + const { result } = renderHook(() => useProviderActions("codex", false), { + wrapper, + }); + + await act(async () => { + await result.current.switchProvider(provider); + }); + + expect(toastWarningMock).toHaveBeenCalledWith( + expect.stringContaining("Anthropic Messages"), + ); + expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id); + }); + it("should sync plugin config when switching Claude provider with integration enabled", async () => { switchProviderMutateAsync.mockResolvedValueOnce(undefined); settingsApiGetMock.mockResolvedValueOnce({