mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-02 10:21:16 +08:00
feat(codex): support native Anthropic Messages protocol as upstream (#5071)
* feat(codex): support native Anthropic Messages protocol as upstream Allow gateways that only expose the native Anthropic Messages protocol (/v1/messages) to be used by Codex: the local proxy performs bidirectional request/response/streaming conversion between Responses and Anthropic. Backend: - Add two conversion modules: transform_codex_anthropic / streaming_codex_anthropic - codex.rs: add routing detection and auth: ANTHROPIC_AUTH_TOKEN→Bearer (default), ANTHROPIC_API_KEY→x-api-key, mutually exclusive - handlers.rs: add handle_codex_anthropic_to_responses_transform - forwarder.rs: support optional Claude Code client fingerprint impersonation (User-Agent / anthropic-beta / x-app / system prompt first-line injection) and /responses→/v1/messages rewriting - codex_config: the anthropic format reuses the NativeResponses profile to strip custom tools - ProviderMeta: add impersonateClaudeCode Frontend: - CodexApiFormat: add "anthropic"; the form adds auth field selection and an impersonation toggle - Add en/ja/zh/zh-TW copy Robustness: - Downgrade when tool history / forced tool_choice conflicts with extended thinking, avoiding upstream 400s - Emit cache_creation_input_tokens in usage and use saturating_add to guard against overflow - Append a unique suffix to non-streaming/streaming output-item ids to avoid multi-segment text/thinking overwriting each other * fix(codex): harden Anthropic bridge against empty text blocks and truncated streams - Drop empty/whitespace-only text content blocks when rebuilding Anthropic messages from Responses history; Anthropic 400s on empty text blocks (e.g. an empty assistant text emitted alongside a tool_use), which broke follow-up and tool-result requests. Also drop messages left without content. - Do not report a truncated Anthropic stream as completed: when the SSE connection ends before message_stop with no stop_reason, emit an incomplete response if partial output exists, or a failed (stream_truncated) response otherwise, mirroring the chat converter's EOF handling. * fix(codex): resolve Anthropic-bridge review findings Blocking: 1. Defer stripping the [1m] long-context marker until after catalog matching and model write-back, re-stripping it on the final Codex→Anthropic body and setting a flag to emit the context-1m-2025-08-07 beta header, so the marker is no longer lost or overridden by the default model. 2. Gate Anthropic thinking on the trailing turn only (via trailing_turn_allows_thinking) instead of scanning full history, so a Codex session that resends history each round no longer permanently loses thinking after the first tool call. 3. Inject 5m ephemeral cache_control on the Codex→Anthropic body by reusing cache_injector (handling the system string→array conversion), so system/tools/history are cached instead of re-sent at full price every round. 4. Add a shared base_url_is_full_endpoint helper (normalizing whitespace/query/fragment/trailing slash) used by both the Anthropic and Chat paths, so a base URL already ending in /v1/messages is treated as a full endpoint instead of double-appending to /v1/messages/v1/messages. 5. Align the catalog tool-profile predicate with the routing predicate so resolve_codex_catalog_tool_profile returns the Anthropic profile whenever the request converts to Anthropic, preventing freeform tools like apply_patch from being silently filtered by a ProxyChat catalog. 6. Explicitly disable native web_search for the Anthropic profile (including the no-catalog branch) via set_codex_native_web_search_field, so Codex no longer treats it as available while the transform silently drops it. 7. Only forward tool_choice when tools survive filtering, dropping it otherwise, to avoid a non-retryable 400 from upstream when tool_choice is sent with no tools. 8. Lower the fallback default to max_tokens=8192 (only when max_output_tokens is omitted) and clamp the thinking budget to max_tokens/2, disabling thinking below the 1024 floor, to avoid hard 400s on low-output-ceiling models/gateways. Minor: 9. Centralize the Codex/OpenAI fingerprint-header denylist in is_codex_client_fingerprint_header so impersonating Claude Code uniformly drops originator/session_id/conversation_id/chatgpt-account-id/x-client-request-id/openai-* and the x-stainless-*/x-codex-* prefixes. 10. Retain content_block_start.input as start_input and fall back to it at block close when no input_json_delta arrived, so a gateway that carries the full tool input on the start event no longer yields empty tool arguments. 11. Extract a shared codex_responses_sse module as the single Responses SSE envelope builder that both the chat and anthropic streaming emitters delegate to, with byte-for-byte-unchanged wire output, so future event-format fixes touch one place instead of two. * fix(codex): add per-provider max_output_tokens override for Codex→Anthropic path Codex does not forward model_max_output_tokens in the request body, causing the proxy to fall back to a conservative 8192 default. This truncates long or thinking-heavy responses (stop_reason=max_tokens). - Add maxOutputTokens field to ProviderMeta (Rust + TypeScript) - Inject the value into the Anthropic request body before transform, taking precedence over request-supplied and default values - Add numeric input in Codex form fields (Anthropic format only) - Add i18n entries for label, placeholder, and hint (en/zh/zh-TW/ja) - Include roundtrip and omission unit tests for the new field * fix(codex): harden Anthropic protocol bridge * fix(codex): address Anthropic bridge review * fix(codex): preserve flattened Anthropic inputs * fix(codex): harden Anthropic recovery paths * fix(ci): satisfy Rust clippy --------- Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
@@ -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| {
|
||||
|
||||
Reference in New Issue
Block a user