mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +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| {
|
||||
|
||||
@@ -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<CodexChatReasoningConfig>,
|
||||
/// 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<bool>,
|
||||
/// 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<u64>,
|
||||
/// Custom User-Agent for local proxy routing.
|
||||
#[serde(rename = "customUserAgent", skip_serializing_if = "Option::is_none")]
|
||||
pub custom_user_agent: Option<String>,
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1122,6 +1122,14 @@ impl RequestForwarder {
|
||||
== Some("github_copilot")
|
||||
|| base_url.contains("githubcopilot.com");
|
||||
|
||||
// Codex upstream conversion mode — computed early because the [1m]-suffix strip
|
||||
// below must be skipped on the Anthropic path (the marker has to survive to
|
||||
// catalog matching and to the transform's own strip+beta detection).
|
||||
let codex_responses_to_chat = matches!(app_type, AppType::Codex)
|
||||
&& super::providers::should_convert_codex_responses_to_chat(provider, endpoint);
|
||||
let codex_responses_to_anthropic = matches!(app_type, AppType::Codex)
|
||||
&& super::providers::should_convert_codex_responses_to_anthropic(provider, endpoint);
|
||||
|
||||
// 应用模型映射(独立于格式转换)
|
||||
// Claude Desktop proxy 模式必须先把 Desktop 可见的 claude-* route
|
||||
// 映射成真实上游模型名,并且未知 route 要直接报错,不能使用默认模型兜底。
|
||||
@@ -1142,7 +1150,11 @@ impl RequestForwarder {
|
||||
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
|
||||
self.apply_copilot_live_model_resolution(provider, &mut mapped_body)
|
||||
.await;
|
||||
} else {
|
||||
} else if !codex_responses_to_anthropic {
|
||||
// Skip on the Codex→Anthropic path: stripping [1m] here would break both the
|
||||
// model-catalog match (apply_codex_upstream_model) and the transform's own
|
||||
// strip+`context-1m` beta detection. The marker is stripped later, on the
|
||||
// final anthropic_body.
|
||||
mapped_body =
|
||||
super::model_mapper::strip_one_m_suffix_for_upstream_from_body(mapped_body);
|
||||
}
|
||||
@@ -1300,10 +1312,22 @@ impl RequestForwarder {
|
||||
Some(api_format) => super::providers::claude_api_format_needs_transform(api_format),
|
||||
None => adapter.needs_transform(provider),
|
||||
};
|
||||
let codex_responses_to_chat = matches!(app_type, AppType::Codex)
|
||||
&& super::providers::should_convert_codex_responses_to_chat(provider, endpoint);
|
||||
// Codex → Anthropic: Claude Code emulation is off by default and only
|
||||
// enabled when the user explicitly turns it on in the UI, so requests can
|
||||
// pass a gateway's "Claude Code only" fingerprint check (User-Agent /
|
||||
// anthropic-beta / x-app / system prompt first line). Defaulting to off
|
||||
// avoids leaking the Claude Code fingerprint and identity prompt to
|
||||
// general-purpose gateways.
|
||||
let codex_impersonate_claude_code = codex_responses_to_anthropic
|
||||
&& provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.impersonate_claude_code)
|
||||
== Some(true);
|
||||
let (effective_endpoint, passthrough_query) = if codex_responses_to_chat {
|
||||
rewrite_codex_responses_endpoint_to_chat(endpoint)
|
||||
} else if codex_responses_to_anthropic {
|
||||
rewrite_codex_responses_endpoint_to_anthropic(endpoint)
|
||||
} else if needs_transform && adapter.name() == "Claude" {
|
||||
let api_format = resolved_claude_api_format
|
||||
.as_deref()
|
||||
@@ -1318,11 +1342,16 @@ impl RequestForwarder {
|
||||
)
|
||||
};
|
||||
|
||||
let codex_chat_base_is_full_endpoint = codex_responses_to_chat
|
||||
&& base_url
|
||||
.trim_end_matches('/')
|
||||
.to_ascii_lowercase()
|
||||
.ends_with("/chat/completions");
|
||||
let codex_chat_base_is_full_endpoint =
|
||||
codex_responses_to_chat && base_url_is_full_endpoint(&base_url, "/chat/completions");
|
||||
|
||||
// Defensive fallback mirroring `codex_chat_base_is_full_endpoint`: if a user pastes
|
||||
// a base URL already ending in the Anthropic `/v1/messages` endpoint but leaves the
|
||||
// "full URL" switch off, treat it as a full endpoint so we don't double-append
|
||||
// `/v1/messages` (→ `.../v1/messages/v1/messages`, a non-retryable 400). Matches the
|
||||
// exact endpoint suffix, so prefixed gateways like `.../api/v1/messages` are covered.
|
||||
let codex_anthropic_base_is_full_endpoint =
|
||||
codex_responses_to_anthropic && base_url_is_full_endpoint(&base_url, "/v1/messages");
|
||||
|
||||
let url = if matches!(resolved_claude_api_format.as_deref(), Some("gemini_native")) {
|
||||
super::gemini_url::resolve_gemini_native_url(
|
||||
@@ -1330,7 +1359,10 @@ impl RequestForwarder {
|
||||
&effective_endpoint,
|
||||
is_full_url,
|
||||
)
|
||||
} else if is_full_url || codex_chat_base_is_full_endpoint {
|
||||
} else if is_full_url
|
||||
|| codex_chat_base_is_full_endpoint
|
||||
|| codex_anthropic_base_is_full_endpoint
|
||||
{
|
||||
append_query_to_full_url(&base_url, passthrough_query.as_deref())
|
||||
} else {
|
||||
adapter.build_url(&base_url, &effective_endpoint)
|
||||
@@ -1345,6 +1377,10 @@ impl RequestForwarder {
|
||||
.filter(|m| !m.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
// Codex→Anthropic: when the model name carries the [1m] marker, strip the
|
||||
// suffix and add the context-1m beta header.
|
||||
let mut codex_anthropic_one_m = false;
|
||||
|
||||
// 转换请求体(如果需要)
|
||||
let mut request_body = if codex_responses_to_chat {
|
||||
let mut mapped_body = mapped_body;
|
||||
@@ -1364,6 +1400,67 @@ impl RequestForwarder {
|
||||
mapped_body,
|
||||
reasoning_config.as_ref(),
|
||||
)?
|
||||
} else if codex_responses_to_anthropic {
|
||||
let mut mapped_body = mapped_body;
|
||||
super::providers::apply_codex_upstream_model(provider, &mut mapped_body);
|
||||
// Per-provider output ceiling override. Codex does not forward its
|
||||
// `model_max_output_tokens` in the request body, so honor the value
|
||||
// configured on the provider here — it takes precedence over any
|
||||
// request-supplied `max_output_tokens` and over the default below.
|
||||
// Injecting it into the body (rather than overriding after transform)
|
||||
// lets the thinking-budget clamp size its headroom against the real
|
||||
// ceiling too. Kept per-provider to avoid a global large default that
|
||||
// would 400 on low-output-ceiling gateways.
|
||||
if let Some(max_out) = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.max_output_tokens)
|
||||
.filter(|v| *v > 0)
|
||||
{
|
||||
mapped_body["max_output_tokens"] = Value::from(max_out);
|
||||
}
|
||||
// Anthropic requires max_tokens; fall back to this default only when the
|
||||
// Codex request omits max_output_tokens (rare — Codex normally sends it).
|
||||
// Kept conservative so a low-output-ceiling model or relay does not hard-400
|
||||
// on the fallback (a too-high default 400s and is non-retryable); 8192 is
|
||||
// accepted by every current Claude model and virtually all gateways. The
|
||||
// transform clamps any thinking budget below this value.
|
||||
const DEFAULT_CODEX_ANTHROPIC_MAX_TOKENS: u64 = 8192;
|
||||
let mut anthropic_body =
|
||||
super::providers::transform_codex_anthropic::responses_request_to_anthropic(
|
||||
mapped_body,
|
||||
DEFAULT_CODEX_ANTHROPIC_MAX_TOKENS,
|
||||
)?;
|
||||
// Handle the 1M-context marker [1m]: strip the model-name suffix (the
|
||||
// gateway doesn't recognize it) and set the flag so the beta header is
|
||||
// added. apply_codex_upstream_model may have just written back a model
|
||||
// name carrying [1m] from the provider config, so strip it once more on
|
||||
// the final body here.
|
||||
if let Some(model) = anthropic_body.get("model").and_then(|v| v.as_str()) {
|
||||
let stripped = super::model_mapper::strip_one_m_suffix_for_upstream(model);
|
||||
if stripped != model {
|
||||
codex_anthropic_one_m = true;
|
||||
anthropic_body["model"] = Value::String(stripped.to_string());
|
||||
}
|
||||
}
|
||||
if codex_impersonate_claude_code {
|
||||
prepend_claude_code_system_prompt(&mut anthropic_body);
|
||||
}
|
||||
// Enable Anthropic prompt caching (no beta header required). Reuse the
|
||||
// configured TTL rather than silently forcing 5m on this conversion path.
|
||||
// otherwise system/tools/history are re-sent at full price every round,
|
||||
// inflating cost and first-token latency. The injector handles the
|
||||
// string→array `system` conversion and the 4-breakpoint cap.
|
||||
super::cache_injector::inject(
|
||||
&mut anthropic_body,
|
||||
&super::types::OptimizerConfig {
|
||||
enabled: true,
|
||||
thinking_optimizer: false,
|
||||
cache_injection: true,
|
||||
cache_ttl: self.optimizer_config.cache_ttl.clone(),
|
||||
},
|
||||
);
|
||||
anthropic_body
|
||||
} else if needs_transform {
|
||||
if adapter.name() == "Claude" {
|
||||
let api_format = resolved_claude_api_format
|
||||
@@ -1420,8 +1517,10 @@ impl RequestForwarder {
|
||||
);
|
||||
let request_is_streaming =
|
||||
is_streaming_request(&effective_endpoint, &filtered_body, headers);
|
||||
let force_identity_encoding =
|
||||
needs_transform || codex_responses_to_chat || request_is_streaming;
|
||||
let force_identity_encoding = needs_transform
|
||||
|| codex_responses_to_chat
|
||||
|| codex_responses_to_anthropic
|
||||
|| request_is_streaming;
|
||||
|
||||
// Codex OAuth 需要注入的 ChatGPT-Account-Id(在动态 token 获取期间填充)
|
||||
let mut codex_oauth_account_id: Option<String> = None;
|
||||
@@ -1563,6 +1662,13 @@ impl RequestForwarder {
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.custom_user_agent_header().ok().flatten())
|
||||
};
|
||||
// Codex→Anthropic emulation: when there is no custom UA, override Codex's
|
||||
// codex_cli_rs UA with the Claude Code UA.
|
||||
let custom_user_agent = if custom_user_agent.is_none() && codex_impersonate_claude_code {
|
||||
Some(http::HeaderValue::from_static(CLAUDE_CODE_USER_AGENT))
|
||||
} else {
|
||||
custom_user_agent
|
||||
};
|
||||
|
||||
// --- Copilot 优化器:动态 header 注入 ---
|
||||
if let Some((ref classification, ref det_request_id, ref interaction_id)) =
|
||||
@@ -1648,6 +1754,17 @@ impl RequestForwarder {
|
||||
} else {
|
||||
CLAUDE_CODE_BETA.to_string()
|
||||
})
|
||||
} else if codex_impersonate_claude_code || codex_anthropic_one_m {
|
||||
// Codex→Anthropic: emulation injects the claude-code marker; a [1m]
|
||||
// model injects the context-1m marker.
|
||||
let mut betas: Vec<&str> = Vec::new();
|
||||
if codex_impersonate_claude_code {
|
||||
betas.push("claude-code-20250219");
|
||||
}
|
||||
if codex_anthropic_one_m {
|
||||
betas.push("context-1m-2025-08-07");
|
||||
}
|
||||
Some(betas.join(","))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1658,6 +1775,7 @@ impl RequestForwarder {
|
||||
let mut ordered_headers = http::HeaderMap::new();
|
||||
let mut saw_auth = false;
|
||||
let mut saw_accept_encoding = false;
|
||||
let mut saw_accept = false;
|
||||
let mut saw_user_agent = false;
|
||||
let mut saw_anthropic_beta = false;
|
||||
let mut saw_anthropic_version = false;
|
||||
@@ -1723,6 +1841,37 @@ impl RequestForwarder {
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- x-app — during Codex→Anthropic emulation, `cli` is injected uniformly below ---
|
||||
if codex_impersonate_claude_code && key_str.eq_ignore_ascii_case("x-app") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Codex/OpenAI fingerprint headers — never leak to an Anthropic upstream ---
|
||||
// These are client/session identifiers from the incoming Codex request,
|
||||
// not Anthropic protocol headers. Forwarding them both leaks identity and
|
||||
// can defeat strict gateway fingerprint checks.
|
||||
// The full set lives in `is_codex_client_fingerprint_header` so it stays in one
|
||||
// place. (HeaderName is lowercased by the http crate, so a direct match is safe.)
|
||||
if codex_responses_to_anthropic && is_codex_client_fingerprint_header(key_str) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- accept — force application/json on the Codex→Anthropic path ---
|
||||
// The Codex CLI sends `Accept: text/event-stream`, whereas a native
|
||||
// Anthropic client sends `application/json` (streaming is driven by
|
||||
// the body's stream:true). Strict Anthropic gateways return 406 Not
|
||||
// Acceptable for an event-stream Accept, so normalize it here.
|
||||
if codex_responses_to_anthropic && key_str.eq_ignore_ascii_case("accept") {
|
||||
if !saw_accept {
|
||||
saw_accept = true;
|
||||
ordered_headers.append(
|
||||
http::header::ACCEPT,
|
||||
http::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- accept-encoding — transform / SSE 路径强制 identity,其余保留原值 ---
|
||||
if key_str.eq_ignore_ascii_case("accept-encoding") {
|
||||
if !saw_accept_encoding {
|
||||
@@ -1801,6 +1950,19 @@ impl RequestForwarder {
|
||||
);
|
||||
}
|
||||
|
||||
// On the Codex→Anthropic path, add application/json when Accept is missing (matching a native Anthropic client).
|
||||
if codex_responses_to_anthropic && !saw_accept {
|
||||
ordered_headers.append(
|
||||
http::header::ACCEPT,
|
||||
http::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
}
|
||||
|
||||
// Codex→Anthropic emulation: inject Claude Code's x-app: cli
|
||||
if codex_impersonate_claude_code {
|
||||
ordered_headers.append("x-app", http::HeaderValue::from_static("cli"));
|
||||
}
|
||||
|
||||
if !saw_user_agent {
|
||||
if let Some(ref ua) = custom_user_agent {
|
||||
ordered_headers.append(http::header::USER_AGENT, ua.clone());
|
||||
@@ -1816,8 +1978,13 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
// anthropic-version:仅在缺失时补充默认值
|
||||
if should_send_anthropic_headers && !saw_anthropic_version {
|
||||
// anthropic-version: add the default only when it is missing.
|
||||
// The Codex→Anthropic path also needs this header. Note this is independent
|
||||
// of anthropic-beta: the Claude Code-specific beta is only sent when
|
||||
// impersonation is on (handled above); on the plain Codex→Anthropic path
|
||||
// (impersonation off) anthropic-version is still required but no beta is sent.
|
||||
if (should_send_anthropic_headers || codex_responses_to_anthropic) && !saw_anthropic_version
|
||||
{
|
||||
ordered_headers.append(
|
||||
"anthropic-version",
|
||||
http::HeaderValue::from_static("2023-06-01"),
|
||||
@@ -1960,9 +2127,18 @@ impl RequestForwarder {
|
||||
let status = response.status();
|
||||
|
||||
if status.is_success() {
|
||||
let response = self
|
||||
let mut response = self
|
||||
.prepare_success_response_for_failover(response, request_is_streaming)
|
||||
.await?;
|
||||
// Streaming requests normally return SSE. If a compatible gateway
|
||||
// explicitly returns JSON instead, buffer and validate it inside the retry
|
||||
// loop as well so a 2xx Anthropic error envelope can still fail over. Do
|
||||
// not buffer unknown content types: some gateways omit the SSE header.
|
||||
if codex_responses_to_anthropic && (!request_is_streaming || response.is_json()) {
|
||||
response = self
|
||||
.validate_codex_anthropic_success_response(response)
|
||||
.await?;
|
||||
}
|
||||
Ok((response, resolved_claude_api_format, outbound_model))
|
||||
} else {
|
||||
let status_code = status.as_u16();
|
||||
@@ -2020,6 +2196,34 @@ impl RequestForwarder {
|
||||
Ok(ProxyResponse::buffered(status, headers, body))
|
||||
}
|
||||
|
||||
/// Some Anthropic-compatible gateways return an Anthropic error envelope with
|
||||
/// HTTP 2xx. Validate it inside the retry loop so the request can fail over to
|
||||
/// the next provider; the response transformer runs too late for that.
|
||||
async fn validate_codex_anthropic_success_response(
|
||||
&self,
|
||||
response: ProxyResponse,
|
||||
) -> Result<ProxyResponse, ProxyError> {
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let encoding = get_content_encoding(&headers);
|
||||
let raw = response.bytes().await?;
|
||||
let decoded = match encoding {
|
||||
Some(encoding) => match decompress_body(&encoding, &raw) {
|
||||
Ok(Some(decompressed)) => decompressed,
|
||||
_ => raw.to_vec(),
|
||||
},
|
||||
None => raw.to_vec(),
|
||||
};
|
||||
|
||||
if let Some(message) = codex_anthropic_error_envelope_message(&decoded) {
|
||||
return Err(ProxyError::TransformError(format!(
|
||||
"Anthropic upstream returned a 2xx error envelope: {message}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(ProxyResponse::buffered(status, headers, raw))
|
||||
}
|
||||
|
||||
async fn prime_streaming_response(
|
||||
&self,
|
||||
response: ProxyResponse,
|
||||
@@ -2355,6 +2559,111 @@ fn rewrite_codex_responses_endpoint_to_chat(endpoint: &str) -> (String, Option<S
|
||||
(rewritten, passthrough_query)
|
||||
}
|
||||
|
||||
/// Claude Code client fingerprint (used for Codex→Anthropic emulation to pass a
|
||||
/// gateway's "Claude Code only" check).
|
||||
const CLAUDE_CODE_USER_AGENT: &str = "claude-cli/1.0.119 (external, cli)";
|
||||
const CLAUDE_CODE_SYSTEM_IDENTITY: &str =
|
||||
"You are Claude Code, Anthropic's official CLI for Claude.";
|
||||
|
||||
/// Insert the Claude Code identity as the first line before the `system` field in
|
||||
/// the Anthropic request body.
|
||||
///
|
||||
/// Anthropic subscription/OAuth plans require the first system block to be exactly
|
||||
/// this identity line. After conversion `system` is a string (from Codex
|
||||
/// instructions); normalize it into an array here: [identity line, original system...].
|
||||
fn prepend_claude_code_system_prompt(body: &mut Value) {
|
||||
let identity = serde_json::json!({ "type": "text", "text": CLAUDE_CODE_SYSTEM_IDENTITY });
|
||||
let mut blocks: Vec<Value> = vec![identity];
|
||||
match body.get("system") {
|
||||
Some(Value::String(existing)) if !existing.is_empty() => {
|
||||
blocks.push(serde_json::json!({ "type": "text", "text": existing }));
|
||||
}
|
||||
Some(Value::Array(existing)) => {
|
||||
// Idempotent: skip re-injection if the first block is already the identity line.
|
||||
if existing
|
||||
.first()
|
||||
.and_then(|b| b.get("text"))
|
||||
.and_then(|t| t.as_str())
|
||||
== Some(CLAUDE_CODE_SYSTEM_IDENTITY)
|
||||
{
|
||||
return;
|
||||
}
|
||||
blocks.extend(existing.iter().cloned());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
body["system"] = Value::Array(blocks);
|
||||
}
|
||||
|
||||
/// Headers a native Claude Code client never sends but the Codex/OpenAI CLI (and its
|
||||
/// stainless SDK layer) do. Dropped for every Codex→Anthropic request so the upstream sees a
|
||||
/// clean Anthropic client fingerprint. Centralized here so the set stays in one place and future
|
||||
/// additions can't miss a code path. `key_str` is already lowercased by the http crate.
|
||||
/// Whether `base_url` already ends in `endpoint_suffix` (e.g. `/v1/messages` or
|
||||
/// `/chat/completions`), ignoring surrounding whitespace, any `?query`/`#fragment`, and a
|
||||
/// trailing slash. Used to avoid double-appending the endpoint when a user pastes a full
|
||||
/// URL but leaves the "full URL" switch off (`.../v1/messages` → `.../v1/messages/v1/messages`,
|
||||
/// a non-retryable 400). `endpoint_suffix` must be lowercase.
|
||||
fn base_url_is_full_endpoint(base_url: &str, endpoint_suffix: &str) -> bool {
|
||||
let trimmed = base_url.trim();
|
||||
// Match against the path only: a `?query`/`#fragment` on a full endpoint URL must not
|
||||
// hide the suffix (`.../v1/messages?beta=true` still ends in the endpoint).
|
||||
let path = match trimmed.split_once(['?', '#']) {
|
||||
Some((head, _)) => head,
|
||||
None => trimmed,
|
||||
};
|
||||
path.trim_end_matches('/')
|
||||
.to_ascii_lowercase()
|
||||
.ends_with(endpoint_suffix)
|
||||
}
|
||||
|
||||
fn is_codex_client_fingerprint_header(key_str: &str) -> bool {
|
||||
matches!(
|
||||
key_str,
|
||||
"originator"
|
||||
| "session_id"
|
||||
| "session-id"
|
||||
| "thread-id"
|
||||
| "conversation_id"
|
||||
| "chatgpt-account-id"
|
||||
| "x-openai-subagent"
|
||||
| "x-client-request-id"
|
||||
| "openai-beta"
|
||||
| "openai-organization"
|
||||
| "openai-project"
|
||||
) || key_str.starts_with("x-stainless-")
|
||||
|| key_str.starts_with("x-codex-")
|
||||
}
|
||||
|
||||
fn codex_anthropic_error_envelope_message(body: &[u8]) -> Option<String> {
|
||||
let value: Value = serde_json::from_slice(body).ok()?;
|
||||
if value.get("type").and_then(Value::as_str) != Some("error") && value.get("error").is_none() {
|
||||
return None;
|
||||
}
|
||||
let error = value.get("error").unwrap_or(&value);
|
||||
let error_type = error.get("type").and_then(Value::as_str).unwrap_or("error");
|
||||
let message = error
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| error.as_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| error.to_string());
|
||||
Some(format!("{error_type}: {message}"))
|
||||
}
|
||||
|
||||
/// Rewrite Codex's `/responses` (and variants) to Anthropic's `/v1/messages`, preserving the query.
|
||||
fn rewrite_codex_responses_endpoint_to_anthropic(endpoint: &str) -> (String, Option<String>) {
|
||||
let (_path, query) = split_endpoint_and_query(endpoint);
|
||||
let passthrough_query = query.map(ToString::to_string);
|
||||
let target_path = "/v1/messages";
|
||||
let rewritten = match passthrough_query.as_deref() {
|
||||
Some(query) if !query.is_empty() => format!("{target_path}?{query}"),
|
||||
_ => target_path.to_string(),
|
||||
};
|
||||
|
||||
(rewritten, passthrough_query)
|
||||
}
|
||||
|
||||
fn rewrite_claude_transform_endpoint(
|
||||
endpoint: &str,
|
||||
api_format: &str,
|
||||
@@ -3347,6 +3656,157 @@ mod tests {
|
||||
assert_eq!(passthrough_query.as_deref(), Some("foo=bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepend_claude_code_system_prompt_from_string() {
|
||||
let mut body = json!({ "system": "You are a Codex agent." });
|
||||
prepend_claude_code_system_prompt(&mut body);
|
||||
let system = body["system"].as_array().unwrap();
|
||||
assert_eq!(system[0]["text"], CLAUDE_CODE_SYSTEM_IDENTITY);
|
||||
assert_eq!(system[1]["text"], "You are a Codex agent.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepend_claude_code_system_prompt_when_absent() {
|
||||
let mut body = json!({});
|
||||
prepend_claude_code_system_prompt(&mut body);
|
||||
let system = body["system"].as_array().unwrap();
|
||||
assert_eq!(system.len(), 1);
|
||||
assert_eq!(system[0]["text"], CLAUDE_CODE_SYSTEM_IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepend_claude_code_system_prompt_is_idempotent() {
|
||||
let mut body = json!({ "system": "orig" });
|
||||
prepend_claude_code_system_prompt(&mut body);
|
||||
prepend_claude_code_system_prompt(&mut body);
|
||||
let system = body["system"].as_array().unwrap();
|
||||
assert_eq!(system.len(), 2);
|
||||
assert_eq!(system[0]["text"], CLAUDE_CODE_SYSTEM_IDENTITY);
|
||||
assert_eq!(system[1]["text"], "orig");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_codex_responses_endpoint_to_anthropic_preserves_query() {
|
||||
let (endpoint, passthrough_query) =
|
||||
rewrite_codex_responses_endpoint_to_anthropic("/responses?x=1");
|
||||
assert_eq!(endpoint, "/v1/messages?x=1");
|
||||
assert_eq!(passthrough_query.as_deref(), Some("x=1"));
|
||||
|
||||
let (endpoint, _) = rewrite_codex_responses_endpoint_to_anthropic("/v1/responses");
|
||||
assert_eq!(endpoint, "/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_anthropic_full_endpoint_guard_avoids_double_messages() {
|
||||
// On the Codex→Anthropic path a base URL already ending in `/v1/messages` (switch
|
||||
// off) must be treated as a full endpoint by the real `base_url_is_full_endpoint`.
|
||||
|
||||
// Without the guard, build_url would concatenate the pasted endpoint with the
|
||||
// rewritten `/v1/messages` target, producing a broken double suffix.
|
||||
use super::super::providers::ProviderAdapter;
|
||||
let doubled = super::super::providers::CodexAdapter::new()
|
||||
.build_url("https://host.example/v1/messages", "/v1/messages");
|
||||
assert_eq!(doubled, "https://host.example/v1/messages/v1/messages");
|
||||
|
||||
// With the guard, the pasted URL is used verbatim (plus preserved query). Includes
|
||||
// query/fragment/whitespace suffixes, which must not hide the endpoint (fix: a base
|
||||
// like `.../v1/messages?beta=true` previously evaded the suffix check).
|
||||
for base in [
|
||||
"https://host.example/v1/messages",
|
||||
"https://host.example/v1/messages/",
|
||||
"https://host.example/api/v1/messages", // prefixed gateway
|
||||
"https://host.example/v1/messages?beta=true",
|
||||
"https://host.example/v1/messages/?beta=true",
|
||||
"https://host.example/v1/messages#frag",
|
||||
" https://host.example/v1/messages ",
|
||||
] {
|
||||
assert!(
|
||||
base_url_is_full_endpoint(base, "/v1/messages"),
|
||||
"expected full-endpoint match: {base:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
append_query_to_full_url("https://host.example/v1/messages", Some("x=1")),
|
||||
"https://host.example/v1/messages?x=1"
|
||||
);
|
||||
// A base URL that already carries its own query is preserved verbatim (no double
|
||||
// `/v1/messages`, query kept).
|
||||
assert_eq!(
|
||||
append_query_to_full_url("https://host.example/v1/messages?beta=true", None),
|
||||
"https://host.example/v1/messages?beta=true"
|
||||
);
|
||||
|
||||
// A non-endpoint base (origin/prefix) must NOT match, so build_url still appends.
|
||||
assert!(!base_url_is_full_endpoint(
|
||||
"https://host.example",
|
||||
"/v1/messages"
|
||||
));
|
||||
assert!(!base_url_is_full_endpoint(
|
||||
"https://host.example/v1",
|
||||
"/v1/messages"
|
||||
));
|
||||
// The shared helper also backs the Chat path's `/chat/completions` guard.
|
||||
assert!(base_url_is_full_endpoint(
|
||||
"https://host.example/v1/chat/completions?api-version=2024",
|
||||
"/chat/completions"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_client_fingerprint_headers_are_dropped_for_anthropic_upstreams() {
|
||||
// Codex/OpenAI fingerprints a native Claude Code client never sends → must drop.
|
||||
for header in [
|
||||
"originator",
|
||||
"session_id",
|
||||
"session-id",
|
||||
"thread-id",
|
||||
"conversation_id",
|
||||
"chatgpt-account-id",
|
||||
"x-openai-subagent",
|
||||
"x-client-request-id",
|
||||
"x-codex-window-id",
|
||||
"openai-beta",
|
||||
"openai-organization",
|
||||
"openai-project",
|
||||
"x-stainless-lang",
|
||||
"x-stainless-runtime",
|
||||
"x-codex-turn-id",
|
||||
] {
|
||||
assert!(
|
||||
is_codex_client_fingerprint_header(header),
|
||||
"expected {header} to be dropped while impersonating Claude Code"
|
||||
);
|
||||
}
|
||||
|
||||
// Headers a real Claude Code client sends (or that the forwarder rebuilds) must
|
||||
// NOT be caught by the denylist.
|
||||
for header in [
|
||||
"anthropic-version",
|
||||
"anthropic-beta",
|
||||
"user-agent",
|
||||
"accept",
|
||||
"content-type",
|
||||
"x-app",
|
||||
] {
|
||||
assert!(
|
||||
!is_codex_client_fingerprint_header(header),
|
||||
"{header} must be preserved while impersonating Claude Code"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_anthropic_2xx_error_envelope_is_detected_for_failover() {
|
||||
let body = br#"{"type":"error","error":{"type":"overloaded_error","message":"busy"}}"#;
|
||||
assert_eq!(
|
||||
codex_anthropic_error_envelope_message(body).as_deref(),
|
||||
Some("overloaded_error: busy")
|
||||
);
|
||||
assert!(
|
||||
codex_anthropic_error_envelope_message(br#"{"type":"message","content":[]}"#).is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_codex_responses_compact_endpoint_to_chat_preserves_query() {
|
||||
let (endpoint, passthrough_query) =
|
||||
|
||||
@@ -18,12 +18,18 @@ use super::{
|
||||
handler_context::RequestContext,
|
||||
providers::{
|
||||
codex_chat_common::extract_reasoning_field_text,
|
||||
codex_chat_history::record_responses_sse_stream, get_adapter, get_claude_api_format,
|
||||
codex_chat_history::record_responses_sse_stream,
|
||||
get_adapter, get_claude_api_format,
|
||||
streaming::create_anthropic_sse_stream,
|
||||
streaming_codex_anthropic::{
|
||||
create_responses_sse_stream_from_anthropic_with_context,
|
||||
responses_sse_events_from_anthropic_message,
|
||||
},
|
||||
streaming_codex_chat::create_responses_sse_stream_from_chat_with_context,
|
||||
streaming_gemini::create_anthropic_sse_stream_from_gemini,
|
||||
streaming_responses::create_anthropic_sse_stream_from_responses, transform,
|
||||
transform_codex_chat, transform_gemini, transform_responses,
|
||||
streaming_responses::create_anthropic_sse_stream_from_responses,
|
||||
transform, transform_codex_anthropic, transform_codex_chat, transform_gemini,
|
||||
transform_responses,
|
||||
},
|
||||
response_processor::{
|
||||
create_logged_passthrough_stream, process_response, read_decoded_body,
|
||||
@@ -739,6 +745,18 @@ pub async fn handle_responses(
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
if super::providers::should_convert_codex_responses_to_anthropic(&ctx.provider, &endpoint) {
|
||||
return handle_codex_anthropic_to_responses_transform(
|
||||
response,
|
||||
&ctx,
|
||||
&state,
|
||||
is_stream,
|
||||
connection_guard,
|
||||
codex_tool_context,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if super::providers::should_convert_codex_responses_to_chat(&ctx.provider, &endpoint) {
|
||||
return handle_codex_chat_to_responses_transform(
|
||||
response,
|
||||
@@ -818,6 +836,18 @@ pub async fn handle_responses_compact(
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
if super::providers::should_convert_codex_responses_to_anthropic(&ctx.provider, &endpoint) {
|
||||
return handle_codex_anthropic_to_responses_transform(
|
||||
response,
|
||||
&ctx,
|
||||
&state,
|
||||
is_stream,
|
||||
connection_guard,
|
||||
codex_tool_context,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if super::providers::should_convert_codex_responses_to_chat(&ctx.provider, &endpoint) {
|
||||
return handle_codex_chat_to_responses_transform(
|
||||
response,
|
||||
@@ -1065,6 +1095,255 @@ async fn handle_codex_chat_to_responses_transform(
|
||||
})
|
||||
}
|
||||
|
||||
/// Response-transform handler for the Codex (Responses) ↔ Anthropic Messages gateway.
|
||||
///
|
||||
/// Parallel to `handle_codex_chat_to_responses_transform`: the upstream speaks
|
||||
/// Anthropic Messages, and this converts the response back into the Responses form
|
||||
/// Codex expects (both streaming and non-streaming). Error bodies reuse
|
||||
/// `handle_codex_chat_error_response` (whose extraction logic also works for
|
||||
/// Anthropic's `{"error":{type,message}}`). It does not involve codex_chat_history
|
||||
/// (tool ids round-trip natively through Anthropic).
|
||||
async fn handle_codex_anthropic_to_responses_transform(
|
||||
response: super::hyper_client::ProxyResponse,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
is_stream: bool,
|
||||
connection_guard: Option<ActiveConnectionGuard>,
|
||||
codex_tool_context: transform_codex_chat::CodexToolContext,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let status = response.status();
|
||||
|
||||
if !status.is_success() {
|
||||
return handle_codex_chat_error_response(response, ctx, status).await;
|
||||
}
|
||||
|
||||
// Preserve live streaming when the gateway marks SSE correctly or omits an
|
||||
// explicit JSON media type. Explicit JSON is buffered below so 2xx error
|
||||
// envelopes and gateways that ignore stream:true can be converted faithfully.
|
||||
if response.is_sse() || (is_stream && !response.is_json()) {
|
||||
let stream = response.bytes_stream();
|
||||
let sse_stream =
|
||||
create_responses_sse_stream_from_anthropic_with_context(stream, codex_tool_context);
|
||||
return build_codex_anthropic_sse_response(
|
||||
sse_stream,
|
||||
ctx,
|
||||
state,
|
||||
status,
|
||||
connection_guard,
|
||||
);
|
||||
}
|
||||
|
||||
let body_timeout =
|
||||
if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 {
|
||||
std::time::Duration::from_secs(ctx.app_config.non_streaming_timeout as u64)
|
||||
} else {
|
||||
std::time::Duration::ZERO
|
||||
};
|
||||
let (mut response_headers, status, body_bytes) =
|
||||
read_decoded_body(response, ctx.tag, body_timeout).await?;
|
||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||
let anthropic_response: Value = match serde_json::from_slice(&body_bytes) {
|
||||
Ok(value) => value,
|
||||
// Fallback sniffing symmetric to the chat / claude side (#2234): when the
|
||||
// upstream returns an Anthropic SSE body with an unmarked Content-Type,
|
||||
// aggregate it back into a message before continuing the conversion.
|
||||
Err(_) if body_looks_like_sse(&body_str) => {
|
||||
log::warn!("[Codex] Upstream returned an unmarked Anthropic SSE body, falling back to aggregation");
|
||||
transform_codex_anthropic::anthropic_sse_to_message_value(&body_str).map_err(|e| {
|
||||
log::error!("[Codex] Failed to aggregate Anthropic SSE body: {e}");
|
||||
e
|
||||
})?
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[Codex] Failed to parse Anthropic upstream response: {e}, body: {body_str}"
|
||||
);
|
||||
return Err(upstream_body_parse_error(
|
||||
"Failed to parse upstream anthropic response",
|
||||
&e,
|
||||
&response_headers,
|
||||
&body_str,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if is_stream {
|
||||
let events =
|
||||
responses_sse_events_from_anthropic_message(&anthropic_response, codex_tool_context);
|
||||
let sse_stream = futures::stream::iter(events.into_iter().map(Ok::<Bytes, std::io::Error>));
|
||||
return build_codex_anthropic_sse_response(
|
||||
sse_stream,
|
||||
ctx,
|
||||
state,
|
||||
status,
|
||||
connection_guard,
|
||||
);
|
||||
}
|
||||
|
||||
let _connection_guard = connection_guard;
|
||||
let responses_response =
|
||||
transform_codex_anthropic::anthropic_response_to_responses_with_context(
|
||||
anthropic_response,
|
||||
&codex_tool_context,
|
||||
)
|
||||
.map_err(|e| {
|
||||
log::error!("[Codex] Failed to convert Anthropic response to Responses: {e}");
|
||||
e
|
||||
})?;
|
||||
|
||||
if let Some(usage) = TokenUsage::from_codex_response_auto(&responses_response)
|
||||
.filter(TokenUsage::has_billable_tokens)
|
||||
{
|
||||
let model = responses_response
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.filter(|m| !m.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| ctx.outbound_model.clone())
|
||||
.unwrap_or_else(|| ctx.request_model.clone());
|
||||
let request_model = ctx.request_model.clone();
|
||||
let outbound_model = ctx
|
||||
.outbound_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| ctx.request_model.clone());
|
||||
let app_type_str = ctx.app_type_str;
|
||||
tokio::spawn({
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let session_id = ctx.session_id.clone();
|
||||
let latency_ms = ctx.latency_ms();
|
||||
async move {
|
||||
log_usage(
|
||||
&state,
|
||||
&provider_id,
|
||||
app_type_str,
|
||||
&model,
|
||||
&request_model,
|
||||
&outbound_model,
|
||||
usage,
|
||||
latency_ms,
|
||||
None,
|
||||
false,
|
||||
status.as_u16(),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
strip_entity_headers_for_rebuilt_body(&mut response_headers);
|
||||
strip_hop_by_hop_response_headers(&mut response_headers);
|
||||
response_headers.remove(axum::http::header::CONTENT_TYPE);
|
||||
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
for (key, value) in response_headers.iter() {
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
builder = builder.header(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
axum::http::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
|
||||
let response_body = serde_json::to_vec(&responses_response).map_err(|e| {
|
||||
log::error!("[Codex] Failed to serialize Responses response: {e}");
|
||||
ProxyError::TransformError(format!("Failed to serialize responses response: {e}"))
|
||||
})?;
|
||||
|
||||
builder
|
||||
.body(axum::body::Body::from(response_body))
|
||||
.map_err(|e| {
|
||||
log::error!("[Codex] Failed to build Responses response: {e}");
|
||||
ProxyError::Internal(format!("Failed to build response: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_codex_anthropic_sse_response(
|
||||
sse_stream: impl futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
status: StatusCode,
|
||||
connection_guard: Option<ActiveConnectionGuard>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let usage_collector = if usage_logging_enabled(state) {
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let request_model = ctx.request_model.clone();
|
||||
let fallback_model = ctx
|
||||
.outbound_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| ctx.request_model.clone());
|
||||
let app_type_str = ctx.app_type_str;
|
||||
let start_time = ctx.start_time;
|
||||
let session_id = ctx.session_id.clone();
|
||||
|
||||
Some(SseUsageCollector::new(
|
||||
start_time,
|
||||
Some(codex_stream_usage_event_filter),
|
||||
move |events, first_token_ms| {
|
||||
let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap_or_default();
|
||||
if !usage.has_billable_tokens() {
|
||||
log::debug!("[Codex] Anthropic streaming response usage is all-zero or missing, skipping usage recording");
|
||||
return;
|
||||
}
|
||||
let model = usage
|
||||
.model
|
||||
.clone()
|
||||
.filter(|m| !m.is_empty())
|
||||
.unwrap_or_else(|| fallback_model.clone());
|
||||
let latency_ms = start_time.elapsed().as_millis() as u64;
|
||||
|
||||
let state = state.clone();
|
||||
let provider_id = provider_id.clone();
|
||||
let request_model = request_model.clone();
|
||||
let outbound_model = fallback_model.clone();
|
||||
let session_id = session_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
log_usage(
|
||||
&state,
|
||||
&provider_id,
|
||||
app_type_str,
|
||||
&model,
|
||||
&request_model,
|
||||
&outbound_model,
|
||||
usage,
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
true,
|
||||
status.as_u16(),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
},
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let logged_stream = create_logged_passthrough_stream(
|
||||
sse_stream,
|
||||
ctx.tag,
|
||||
usage_collector,
|
||||
ctx.streaming_timeout_config(),
|
||||
connection_guard,
|
||||
);
|
||||
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
"Content-Type",
|
||||
axum::http::HeaderValue::from_static("text/event-stream"),
|
||||
);
|
||||
headers.insert(
|
||||
"Cache-Control",
|
||||
axum::http::HeaderValue::from_static("no-cache"),
|
||||
);
|
||||
|
||||
let body = axum::body::Body::from_stream(logged_stream);
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
|
||||
/// 把上游 Chat Completions 的错误响应转换为 Responses API 错误形状。
|
||||
///
|
||||
/// 与正常响应分支配套:正常响应已经被改写成 Responses 形式,错误响应若仍保留
|
||||
|
||||
@@ -142,6 +142,21 @@ impl ProxyResponse {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check whether the response explicitly declares a JSON media type.
|
||||
pub fn is_json(&self) -> bool {
|
||||
self.content_type()
|
||||
.map(|content_type| {
|
||||
let media_type = content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
media_type == "application/json" || media_type.ends_with("+json")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Consume the response and collect the full body into `Bytes`.
|
||||
pub async fn bytes(self) -> Result<Bytes, ProxyError> {
|
||||
match self {
|
||||
@@ -737,3 +752,27 @@ impl<S: Unpin> tokio::io::AsyncWrite for WriteFilter<S> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn buffered_with_content_type(content_type: Option<&str>) -> ProxyResponse {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
if let Some(content_type) = content_type {
|
||||
headers.insert(
|
||||
http::header::CONTENT_TYPE,
|
||||
http::HeaderValue::from_str(content_type).unwrap(),
|
||||
);
|
||||
}
|
||||
ProxyResponse::buffered(http::StatusCode::OK, headers, Bytes::new())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_content_type_detection_accepts_json_suffixes() {
|
||||
assert!(buffered_with_content_type(Some("application/json; charset=utf-8")).is_json());
|
||||
assert!(buffered_with_content_type(Some("application/problem+json")).is_json());
|
||||
assert!(!buffered_with_content_type(Some("text/event-stream")).is_json());
|
||||
assert!(!buffered_with_content_type(None).is_json());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,72 @@ pub fn should_convert_codex_responses_to_chat(provider: &Provider, endpoint: &st
|
||||
) && codex_provider_uses_chat_completions(provider)
|
||||
}
|
||||
|
||||
/// Whether this Codex provider's real upstream speaks the native Anthropic
|
||||
/// Messages protocol (`/v1/messages`). The local Codex client always talks to CC
|
||||
/// Switch through the Responses API, so CC Switch bridges Responses ⇄ Anthropic.
|
||||
///
|
||||
/// Determined solely from explicit config (apiFormat / wire_api); no base_url
|
||||
/// guessing — Anthropic gateway addresses vary widely and guessing easily misfires.
|
||||
pub fn codex_provider_uses_anthropic(provider: &Provider) -> bool {
|
||||
if let Some(api_format) = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.api_format.as_deref())
|
||||
.or_else(|| {
|
||||
provider
|
||||
.settings_config
|
||||
.get("api_format")
|
||||
.and_then(|v| v.as_str())
|
||||
})
|
||||
.or_else(|| {
|
||||
provider
|
||||
.settings_config
|
||||
.get("apiFormat")
|
||||
.and_then(|v| v.as_str())
|
||||
})
|
||||
{
|
||||
return is_anthropic_wire_api(api_format);
|
||||
}
|
||||
|
||||
provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(extract_codex_wire_api_from_toml)
|
||||
.map(|wire_api| is_anthropic_wire_api(&wire_api))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn should_convert_codex_responses_to_anthropic(provider: &Provider, endpoint: &str) -> bool {
|
||||
let path = endpoint
|
||||
.split_once('?')
|
||||
.map_or(endpoint, |(path, _query)| path);
|
||||
|
||||
matches!(
|
||||
path,
|
||||
"/responses" | "/v1/responses" | "/responses/compact" | "/v1/responses/compact"
|
||||
) && codex_provider_uses_anthropic(provider)
|
||||
}
|
||||
|
||||
/// Resolve the model-catalog tool profile for a Codex provider using the SAME
|
||||
/// Anthropic detection as the proxy router ([`codex_provider_uses_anthropic`]), so the
|
||||
/// generated catalog never disagrees with the routed transform. A provider whose
|
||||
/// Anthropic upstream is declared only via settings `apiFormat` or TOML `wire_api`
|
||||
/// (not `meta.api_format`) would otherwise get a `ProxyChat` catalog and emit the
|
||||
/// freeform `apply_patch` tool that the Anthropic transform then silently drops.
|
||||
/// Non-Anthropic providers keep the existing `meta.api_format` classification.
|
||||
pub fn resolve_codex_catalog_tool_profile(
|
||||
provider: &Provider,
|
||||
) -> crate::codex_config::CodexCatalogToolProfile {
|
||||
use crate::codex_config::CodexCatalogToolProfile;
|
||||
if codex_provider_uses_anthropic(provider) {
|
||||
return CodexCatalogToolProfile::Anthropic;
|
||||
}
|
||||
CodexCatalogToolProfile::from_api_format(
|
||||
provider.meta.as_ref().and_then(|m| m.api_format.as_deref()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract the real upstream model configured for a Codex provider.
|
||||
pub fn codex_provider_upstream_model(provider: &Provider) -> Option<String> {
|
||||
provider
|
||||
@@ -129,7 +195,13 @@ pub fn apply_codex_chat_upstream_model(
|
||||
if !codex_provider_uses_chat_completions(provider) {
|
||||
return None;
|
||||
}
|
||||
apply_codex_upstream_model(provider, body)
|
||||
}
|
||||
|
||||
/// Same model-substitution logic as `apply_codex_chat_upstream_model`, but without
|
||||
/// the chat gating check. Reused by the anthropic conversion path (the forwarder has
|
||||
/// already confirmed this provider uses anthropic).
|
||||
pub fn apply_codex_upstream_model(provider: &Provider, body: &mut JsonValue) -> Option<String> {
|
||||
let catalog_model_ids = codex_provider_catalog_model_ids(provider);
|
||||
if let Some(request_model) = body
|
||||
.get("model")
|
||||
@@ -345,6 +417,13 @@ fn is_chat_wire_api(value: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn is_anthropic_wire_api(value: &str) -> bool {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"anthropic" | "anthropic_messages" | "anthropic-messages" | "claude" | "messages"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_chat_completions_url(value: &str) -> bool {
|
||||
value
|
||||
.trim_end_matches('/')
|
||||
@@ -527,8 +606,28 @@ impl ProviderAdapter for CodexAdapter {
|
||||
}
|
||||
|
||||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||
// Anthropic upstream: the auth field is chosen by the user in the UI (meta.apiKeyField).
|
||||
// ANTHROPIC_API_KEY → x-api-key (AuthStrategy::Anthropic)
|
||||
// ANTHROPIC_AUTH_TOKEN → Authorization: Bearer (default, AuthStrategy::Bearer)
|
||||
// The two are mutually exclusive to avoid a 401 from the gateway receiving
|
||||
// both auth headers at once. All other Codex upstreams stay pure Bearer.
|
||||
let strategy = if codex_provider_uses_anthropic(provider) {
|
||||
let uses_x_api_key = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.api_key_field.as_deref())
|
||||
.map(|field| field.eq_ignore_ascii_case("ANTHROPIC_API_KEY"))
|
||||
.unwrap_or(false);
|
||||
if uses_x_api_key {
|
||||
AuthStrategy::Anthropic
|
||||
} else {
|
||||
AuthStrategy::Bearer
|
||||
}
|
||||
} else {
|
||||
AuthStrategy::Bearer
|
||||
};
|
||||
self.extract_key(provider)
|
||||
.map(|key| AuthInfo::new(key, AuthStrategy::Bearer))
|
||||
.map(|key| AuthInfo::new(key, strategy))
|
||||
}
|
||||
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
@@ -569,6 +668,15 @@ impl ProviderAdapter for CodexAdapter {
|
||||
) -> Result<Vec<(http::HeaderName, http::HeaderValue)>, ProxyError> {
|
||||
use super::adapter::auth_header_value;
|
||||
let bearer = format!("Bearer {}", auth.api_key);
|
||||
// Anthropic gateway: send only x-api-key (anthropic-version is filled in by
|
||||
// the forwarder). Mutually exclusive with Bearer to avoid a 401 from the
|
||||
// gateway receiving both auth headers at once.
|
||||
if auth.strategy == AuthStrategy::Anthropic {
|
||||
return Ok(vec![(
|
||||
http::HeaderName::from_static("x-api-key"),
|
||||
auth_header_value(&auth.api_key)?,
|
||||
)]);
|
||||
}
|
||||
Ok(vec![(
|
||||
http::HeaderName::from_static("authorization"),
|
||||
auth_header_value(&bearer)?,
|
||||
@@ -662,6 +770,197 @@ experimental_bearer_token = "sk-config-key"
|
||||
assert_eq!(url, "https://api.openai.com/v1/responses");
|
||||
}
|
||||
|
||||
// ==================== anthropic upstream detection ====================
|
||||
|
||||
#[test]
|
||||
fn test_uses_anthropic_from_settings_api_format() {
|
||||
let provider = create_provider(json!({ "apiFormat": "anthropic" }));
|
||||
assert!(codex_provider_uses_anthropic(&provider));
|
||||
|
||||
let provider = create_provider(json!({ "api_format": "anthropic_messages" }));
|
||||
assert!(codex_provider_uses_anthropic(&provider));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uses_anthropic_from_meta_api_format() {
|
||||
let mut provider = create_provider(json!({}));
|
||||
provider.meta = Some(crate::provider::ProviderMeta {
|
||||
api_format: Some("anthropic".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(codex_provider_uses_anthropic(&provider));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uses_anthropic_from_toml_wire_api() {
|
||||
let provider = create_provider(json!({
|
||||
"config": r#"model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
wire_api = "anthropic"
|
||||
"#
|
||||
}));
|
||||
assert!(codex_provider_uses_anthropic(&provider));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_false_for_chat_and_responses() {
|
||||
let chat = create_provider(json!({ "apiFormat": "openai_chat" }));
|
||||
assert!(!codex_provider_uses_anthropic(&chat));
|
||||
let responses = create_provider(json!({ "apiFormat": "openai_responses" }));
|
||||
assert!(!codex_provider_uses_anthropic(&responses));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_and_chat_are_mutually_exclusive() {
|
||||
let anth = create_provider(json!({ "apiFormat": "anthropic" }));
|
||||
assert!(codex_provider_uses_anthropic(&anth));
|
||||
assert!(!codex_provider_uses_chat_completions(&anth));
|
||||
|
||||
let chat = create_provider(json!({ "apiFormat": "openai_chat" }));
|
||||
assert!(codex_provider_uses_chat_completions(&chat));
|
||||
assert!(!codex_provider_uses_anthropic(&chat));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_convert_responses_to_anthropic_path_guard() {
|
||||
let provider = create_provider(json!({ "apiFormat": "anthropic" }));
|
||||
assert!(should_convert_codex_responses_to_anthropic(
|
||||
&provider,
|
||||
"/responses"
|
||||
));
|
||||
assert!(should_convert_codex_responses_to_anthropic(
|
||||
&provider,
|
||||
"/v1/responses/compact"
|
||||
));
|
||||
assert!(should_convert_codex_responses_to_anthropic(
|
||||
&provider,
|
||||
"/responses?x=1"
|
||||
));
|
||||
assert!(!should_convert_codex_responses_to_anthropic(
|
||||
&provider,
|
||||
"/chat/completions"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_catalog_profile_matches_router() {
|
||||
use crate::codex_config::CodexCatalogToolProfile;
|
||||
|
||||
// Anthropic declared only via TOML wire_api (no meta.api_format) must still
|
||||
// resolve to the Anthropic catalog profile — this is the routing/catalog
|
||||
// divergence that let apply_patch leak through.
|
||||
let toml_anthropic = create_provider(json!({
|
||||
"config": r#"model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
wire_api = "anthropic"
|
||||
"#
|
||||
}));
|
||||
assert_eq!(
|
||||
resolve_codex_catalog_tool_profile(&toml_anthropic),
|
||||
CodexCatalogToolProfile::Anthropic
|
||||
);
|
||||
|
||||
// Anthropic via settings apiFormat.
|
||||
let settings_anthropic = create_provider(json!({ "apiFormat": "anthropic" }));
|
||||
assert_eq!(
|
||||
resolve_codex_catalog_tool_profile(&settings_anthropic),
|
||||
CodexCatalogToolProfile::Anthropic
|
||||
);
|
||||
|
||||
// Native openai_responses (meta) → NativeResponses; chat → ProxyChat.
|
||||
let mut native = create_provider(json!({}));
|
||||
native.meta = Some(crate::provider::ProviderMeta {
|
||||
api_format: Some("openai_responses".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
resolve_codex_catalog_tool_profile(&native),
|
||||
CodexCatalogToolProfile::NativeResponses
|
||||
);
|
||||
|
||||
let chat = create_provider(json!({ "apiFormat": "openai_chat" }));
|
||||
assert_eq!(
|
||||
resolve_codex_catalog_tool_profile(&chat),
|
||||
CodexCatalogToolProfile::ProxyChat
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_codex_upstream_model_preserves_one_m_catalog_model() {
|
||||
// Regression for the [1m] path: a request model carrying the [1m] marker must
|
||||
// match its catalog entry and be preserved (not overridden by the provider
|
||||
// default) so the transform can later strip [1m] and emit the context-1m beta.
|
||||
// This only works because the forwarder no longer strips [1m] before this call
|
||||
// on the Anthropic path.
|
||||
let provider = create_provider(json!({
|
||||
"config": r#"model_provider = "custom"
|
||||
model = "claude-opus-4-1"
|
||||
|
||||
[model_providers.custom]
|
||||
wire_api = "anthropic"
|
||||
"#,
|
||||
"modelCatalog": {
|
||||
"models": [
|
||||
{ "model": "claude-opus-4-1[1m]" }
|
||||
]
|
||||
}
|
||||
}));
|
||||
let mut body = json!({ "model": "claude-opus-4-1[1m]", "input": "hi" });
|
||||
let result = apply_codex_upstream_model(&provider, &mut body);
|
||||
assert_eq!(result.as_deref(), Some("claude-opus-4-1[1m]"));
|
||||
assert_eq!(
|
||||
body.get("model").and_then(|v| v.as_str()),
|
||||
Some("claude-opus-4-1[1m]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_auth_defaults_to_bearer() {
|
||||
// No meta.apiKeyField (defaults to ANTHROPIC_AUTH_TOKEN) → Authorization: Bearer only
|
||||
let adapter = CodexAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"apiFormat": "anthropic",
|
||||
"auth": { "OPENAI_API_KEY": "sk-anthropic-key-123" }
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
||||
|
||||
let headers = adapter.get_auth_headers(&auth).unwrap();
|
||||
let names: Vec<String> = headers
|
||||
.iter()
|
||||
.map(|(name, _)| name.as_str().to_string())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["authorization".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_auth_x_api_key_when_selected() {
|
||||
// meta.apiKeyField = ANTHROPIC_API_KEY → x-api-key only
|
||||
let adapter = CodexAdapter::new();
|
||||
let mut provider = create_provider(json!({
|
||||
"apiFormat": "anthropic",
|
||||
"auth": { "OPENAI_API_KEY": "sk-anthropic-key-123" }
|
||||
}));
|
||||
provider.meta = Some(crate::provider::ProviderMeta {
|
||||
api_format: Some("anthropic".to_string()),
|
||||
api_key_field: Some("ANTHROPIC_API_KEY".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
|
||||
|
||||
let headers = adapter.get_auth_headers(&auth).unwrap();
|
||||
let names: Vec<String> = headers
|
||||
.iter()
|
||||
.map(|(name, _)| name.as_str().to_string())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["x-api-key".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_origin_adds_v1() {
|
||||
let adapter = CodexAdapter::new();
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Shared builders for the OpenAI Responses SSE envelope.
|
||||
//!
|
||||
//! The two Codex streaming converters — `streaming_codex_chat` (Chat Completions SSE →
|
||||
//! Responses SSE) and `streaming_codex_anthropic` (Anthropic Messages SSE → Responses
|
||||
//! SSE) — have completely different *input* state machines but must emit the identical
|
||||
//! Responses event stream the Codex client understands. This module owns that output
|
||||
//! envelope so the two converters cannot drift when an event's shape changes: a wire fix
|
||||
//! lands here once instead of being mirrored in both files.
|
||||
//!
|
||||
//! Each function is pure — it takes primitives or a caller-built `item` `Value` and
|
||||
//! returns the exact bytes the converters previously constructed inline. Item shapes that
|
||||
//! vary per converter (including function, namespace, custom, and tool-search calls)
|
||||
//! are supplied by the caller via the generic
|
||||
//! `output_item_added` / `output_item_done` helpers.
|
||||
|
||||
use bytes::Bytes;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Serialize one Responses SSE event with the standard `event:`/`data:` framing.
|
||||
pub(crate) fn sse_event(event: &str, data: Value) -> Bytes {
|
||||
Bytes::from(format!(
|
||||
"event: {event}\ndata: {}\n\n",
|
||||
serde_json::to_string(&data).unwrap_or_default()
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response lifecycle (created / in_progress / completed / failed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `response.created`, wrapping a caller-built `response` object (usage/created_at differ
|
||||
/// per converter, so the caller supplies the whole object).
|
||||
pub(crate) fn response_created(response: &Value) -> Bytes {
|
||||
sse_event(
|
||||
"response.created",
|
||||
json!({ "type": "response.created", "response": response }),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.in_progress`.
|
||||
pub(crate) fn response_in_progress(response: &Value) -> Bytes {
|
||||
sse_event(
|
||||
"response.in_progress",
|
||||
json!({ "type": "response.in_progress", "response": response }),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.completed`.
|
||||
pub(crate) fn response_completed(response: &Value) -> Bytes {
|
||||
sse_event(
|
||||
"response.completed",
|
||||
json!({ "type": "response.completed", "response": response }),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.failed`.
|
||||
pub(crate) fn response_failed(response: &Value) -> Bytes {
|
||||
sse_event(
|
||||
"response.failed",
|
||||
json!({ "type": "response.failed", "response": response }),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic output-item add/done (item value supplied by the caller)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `response.output_item.added` with a caller-built item (message / reasoning /
|
||||
/// function_call / custom_tool_call).
|
||||
pub(crate) fn output_item_added(output_index: u32, item: &Value) -> Bytes {
|
||||
sse_event(
|
||||
"response.output_item.added",
|
||||
json!({
|
||||
"type": "response.output_item.added",
|
||||
"output_index": output_index,
|
||||
"item": item
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.output_item.done` with a caller-built item.
|
||||
pub(crate) fn output_item_done(output_index: u32, item: &Value) -> Bytes {
|
||||
sse_event(
|
||||
"response.output_item.done",
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"output_index": output_index,
|
||||
"item": item
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assistant message (text) lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `response.output_item.added` for an in-progress assistant message.
|
||||
pub(crate) fn message_item_added(output_index: u32, item_id: &str) -> Bytes {
|
||||
output_item_added(
|
||||
output_index,
|
||||
&json!({
|
||||
"id": item_id,
|
||||
"type": "message",
|
||||
"status": "in_progress",
|
||||
"role": "assistant",
|
||||
"content": []
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.content_part.added` for the (empty) output_text part of a message.
|
||||
pub(crate) fn message_content_part_added(output_index: u32, item_id: &str) -> Bytes {
|
||||
sse_event(
|
||||
"response.content_part.added",
|
||||
json!({
|
||||
"type": "response.content_part.added",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"part": { "type": "output_text", "text": "", "annotations": [] }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.output_text.delta`.
|
||||
pub(crate) fn output_text_delta(output_index: u32, item_id: &str, delta: &str) -> Bytes {
|
||||
sse_event(
|
||||
"response.output_text.delta",
|
||||
json!({
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"delta": delta
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// The completed assistant-message item value.
|
||||
pub(crate) fn message_item(item_id: &str, text: &str) -> Value {
|
||||
json!({
|
||||
"id": item_id,
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [{ "type": "output_text", "text": text, "annotations": [] }]
|
||||
})
|
||||
}
|
||||
|
||||
/// Close an assistant message: emits `output_text.done` → `content_part.done` →
|
||||
/// `output_item.done`, and returns the completed item so the caller can record it.
|
||||
pub(crate) fn message_close(output_index: u32, item_id: &str, text: &str) -> (Vec<Bytes>, Value) {
|
||||
let item = message_item(item_id, text);
|
||||
let events = vec![
|
||||
sse_event(
|
||||
"response.output_text.done",
|
||||
json!({
|
||||
"type": "response.output_text.done",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"text": text
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.content_part.done",
|
||||
json!({
|
||||
"type": "response.content_part.done",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"part": { "type": "output_text", "text": text, "annotations": [] }
|
||||
}),
|
||||
),
|
||||
output_item_done(output_index, &item),
|
||||
];
|
||||
(events, item)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reasoning (summary) lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `response.output_item.added` for an in-progress reasoning item.
|
||||
pub(crate) fn reasoning_item_added(output_index: u32, item_id: &str) -> Bytes {
|
||||
output_item_added(
|
||||
output_index,
|
||||
&json!({
|
||||
"id": item_id,
|
||||
"type": "reasoning",
|
||||
"status": "in_progress",
|
||||
"summary": []
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.reasoning_summary_part.added` for the (empty) summary part.
|
||||
pub(crate) fn reasoning_summary_part_added(output_index: u32, item_id: &str) -> Bytes {
|
||||
sse_event(
|
||||
"response.reasoning_summary_part.added",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_part.added",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"part": { "type": "summary_text", "text": "" }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.reasoning_summary_text.delta`.
|
||||
pub(crate) fn reasoning_summary_text_delta(output_index: u32, item_id: &str, delta: &str) -> Bytes {
|
||||
sse_event(
|
||||
"response.reasoning_summary_text.delta",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_text.delta",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"delta": delta
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// The completed reasoning item value (note: no `status` field, matching both converters).
|
||||
pub(crate) fn reasoning_item(item_id: &str, text: &str) -> Value {
|
||||
json!({
|
||||
"id": item_id,
|
||||
"type": "reasoning",
|
||||
"summary": [{ "type": "summary_text", "text": text }]
|
||||
})
|
||||
}
|
||||
|
||||
/// Close a reasoning item: emits `reasoning_summary_text.done` →
|
||||
/// `reasoning_summary_part.done` → `output_item.done`, and returns the completed item.
|
||||
pub(crate) fn reasoning_close(output_index: u32, item_id: &str, text: &str) -> (Vec<Bytes>, Value) {
|
||||
let item = reasoning_item(item_id, text);
|
||||
let events = reasoning_close_with_item(output_index, item_id, text, &item, true);
|
||||
(events, item)
|
||||
}
|
||||
|
||||
/// Close a reasoning item whose completed shape is supplied by the converter.
|
||||
/// Anthropic uses this to attach opaque signed/redacted thinking in
|
||||
/// `encrypted_content` while keeping the standard Responses event lifecycle.
|
||||
pub(crate) fn reasoning_close_with_item(
|
||||
output_index: u32,
|
||||
item_id: &str,
|
||||
text: &str,
|
||||
item: &Value,
|
||||
has_visible_summary: bool,
|
||||
) -> Vec<Bytes> {
|
||||
let mut events = Vec::new();
|
||||
if has_visible_summary {
|
||||
events.extend([
|
||||
sse_event(
|
||||
"response.reasoning_summary_text.done",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_text.done",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"text": text
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.reasoning_summary_part.done",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_part.done",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"part": { "type": "summary_text", "text": text }
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
events.push(output_item_done(output_index, item));
|
||||
events
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool-call argument streaming (item value supplied by the caller)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `response.function_call_arguments.delta`.
|
||||
pub(crate) fn function_call_arguments_delta(
|
||||
output_index: u32,
|
||||
item_id: &str,
|
||||
delta: &str,
|
||||
) -> Bytes {
|
||||
sse_event(
|
||||
"response.function_call_arguments.delta",
|
||||
json!({
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"delta": delta
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.function_call_arguments.done`.
|
||||
pub(crate) fn function_call_arguments_done(
|
||||
output_index: u32,
|
||||
item_id: &str,
|
||||
arguments: &str,
|
||||
) -> Bytes {
|
||||
sse_event(
|
||||
"response.function_call_arguments.done",
|
||||
json!({
|
||||
"type": "response.function_call_arguments.done",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"arguments": arguments
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.custom_tool_call_input.delta` (Chat freeform tools only).
|
||||
pub(crate) fn custom_tool_call_input_delta(output_index: u32, item_id: &str, delta: &str) -> Bytes {
|
||||
sse_event(
|
||||
"response.custom_tool_call_input.delta",
|
||||
json!({
|
||||
"type": "response.custom_tool_call_input.delta",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"delta": delta
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `response.custom_tool_call_input.done` (Chat freeform tools only).
|
||||
pub(crate) fn custom_tool_call_input_done(output_index: u32, item_id: &str, input: &str) -> Bytes {
|
||||
sse_event(
|
||||
"response.custom_tool_call_input.done",
|
||||
json!({
|
||||
"type": "response.custom_tool_call_input.done",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"input": input
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn body(bytes: &Bytes) -> String {
|
||||
String::from_utf8(bytes.to_vec()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_event_framing() {
|
||||
let ev = sse_event("response.created", json!({ "a": 1 }));
|
||||
assert_eq!(body(&ev), "event: response.created\ndata: {\"a\":1}\n\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_close_shapes_match_legacy() {
|
||||
let (events, item) = message_close(2, "resp_1_msg", "hi");
|
||||
assert_eq!(events.len(), 3);
|
||||
assert!(body(&events[0]).contains("\"type\":\"response.output_text.done\""));
|
||||
assert!(body(&events[0]).contains("\"text\":\"hi\""));
|
||||
assert!(body(&events[1]).contains("\"type\":\"response.content_part.done\""));
|
||||
assert!(body(&events[2]).contains("\"type\":\"response.output_item.done\""));
|
||||
assert_eq!(item["type"], "message");
|
||||
assert_eq!(item["status"], "completed");
|
||||
assert_eq!(item["content"][0]["text"], "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_close_item_has_no_status() {
|
||||
let (events, item) = reasoning_close(0, "rs_1", "because");
|
||||
assert_eq!(events.len(), 3);
|
||||
assert!(body(&events[0]).contains("\"type\":\"response.reasoning_summary_text.done\""));
|
||||
assert!(body(&events[1]).contains("\"type\":\"response.reasoning_summary_part.done\""));
|
||||
// The completed reasoning item intentionally carries no `status` field.
|
||||
assert!(item.get("status").is_none());
|
||||
assert_eq!(item["summary"][0]["text"], "because");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_item_added_is_in_progress() {
|
||||
let ev = message_item_added(0, "m1");
|
||||
let s = body(&ev);
|
||||
assert!(s.contains("\"type\":\"response.output_item.added\""));
|
||||
assert!(s.contains("\"status\":\"in_progress\""));
|
||||
assert!(s.contains("\"role\":\"assistant\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_call_argument_events() {
|
||||
assert!(body(&function_call_arguments_delta(1, "fc_x", "{\"a\":"))
|
||||
.contains("\"type\":\"response.function_call_arguments.delta\""));
|
||||
assert!(body(&function_call_arguments_done(1, "fc_x", "{\"a\":1}"))
|
||||
.contains("\"arguments\":\"{\\\"a\\\":1}\""));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ mod codex;
|
||||
pub(crate) mod codex_chat_common;
|
||||
pub mod codex_chat_history;
|
||||
pub mod codex_oauth_auth;
|
||||
pub(crate) mod codex_responses_sse;
|
||||
pub mod copilot_auth;
|
||||
pub mod copilot_model_map;
|
||||
mod gemini;
|
||||
@@ -25,10 +26,12 @@ pub(crate) mod gemini_schema;
|
||||
pub mod gemini_shadow;
|
||||
pub mod models;
|
||||
pub mod streaming;
|
||||
pub mod streaming_codex_anthropic;
|
||||
pub mod streaming_codex_chat;
|
||||
pub mod streaming_gemini;
|
||||
pub mod streaming_responses;
|
||||
pub mod transform;
|
||||
pub mod transform_codex_anthropic;
|
||||
pub mod transform_codex_chat;
|
||||
pub mod transform_gemini;
|
||||
pub mod transform_responses;
|
||||
@@ -47,8 +50,9 @@ pub use claude::{
|
||||
};
|
||||
pub use codex::CodexAdapter;
|
||||
pub use codex::{
|
||||
apply_codex_chat_upstream_model, codex_provider_upstream_model,
|
||||
resolve_codex_chat_reasoning_config, should_convert_codex_responses_to_chat,
|
||||
apply_codex_chat_upstream_model, apply_codex_upstream_model, codex_provider_upstream_model,
|
||||
resolve_codex_catalog_tool_profile, resolve_codex_chat_reasoning_config,
|
||||
should_convert_codex_responses_to_anthropic, should_convert_codex_responses_to_chat,
|
||||
};
|
||||
pub use gemini::GeminiAdapter;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
//! OpenAI Chat Completions SSE → OpenAI Responses SSE conversion.
|
||||
|
||||
use super::codex_responses_sse as sse;
|
||||
use super::{
|
||||
codex_chat_common::{
|
||||
extract_reasoning_field_text, split_leading_think_block, strip_leading_think_open_tag,
|
||||
@@ -270,20 +271,8 @@ impl ChatToResponsesState {
|
||||
let response = self.base_response("in_progress", Vec::new());
|
||||
|
||||
vec![
|
||||
sse_event(
|
||||
"response.created",
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": response
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.in_progress",
|
||||
json!({
|
||||
"type": "response.in_progress",
|
||||
"response": self.base_response("in_progress", Vec::new())
|
||||
}),
|
||||
),
|
||||
sse::response_created(&response),
|
||||
sse::response_in_progress(&response),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -297,45 +286,16 @@ impl ChatToResponsesState {
|
||||
self.reasoning.item_id = item_id.clone();
|
||||
self.reasoning.added = true;
|
||||
|
||||
events.push(sse_event(
|
||||
"response.output_item.added",
|
||||
json!({
|
||||
"type": "response.output_item.added",
|
||||
"output_index": output_index,
|
||||
"item": {
|
||||
"id": item_id,
|
||||
"type": "reasoning",
|
||||
"status": "in_progress",
|
||||
"summary": []
|
||||
}
|
||||
}),
|
||||
));
|
||||
events.push(sse_event(
|
||||
"response.reasoning_summary_part.added",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_part.added",
|
||||
"item_id": self.reasoning.item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"part": {
|
||||
"type": "summary_text",
|
||||
"text": ""
|
||||
}
|
||||
}),
|
||||
));
|
||||
events.push(sse::reasoning_item_added(output_index, &item_id));
|
||||
events.push(sse::reasoning_summary_part_added(output_index, &item_id));
|
||||
}
|
||||
|
||||
self.reasoning.text.push_str(delta);
|
||||
let output_index = self.reasoning.output_index.unwrap_or(0);
|
||||
events.push(sse_event(
|
||||
"response.reasoning_summary_text.delta",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_text.delta",
|
||||
"item_id": self.reasoning.item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"delta": delta
|
||||
}),
|
||||
events.push(sse::reasoning_summary_text_delta(
|
||||
output_index,
|
||||
&self.reasoning.item_id,
|
||||
delta,
|
||||
));
|
||||
|
||||
events
|
||||
@@ -351,47 +311,16 @@ impl ChatToResponsesState {
|
||||
self.text.item_id = item_id.clone();
|
||||
self.text.added = true;
|
||||
|
||||
events.push(sse_event(
|
||||
"response.output_item.added",
|
||||
json!({
|
||||
"type": "response.output_item.added",
|
||||
"output_index": output_index,
|
||||
"item": {
|
||||
"id": item_id,
|
||||
"type": "message",
|
||||
"status": "in_progress",
|
||||
"role": "assistant",
|
||||
"content": []
|
||||
}
|
||||
}),
|
||||
));
|
||||
events.push(sse_event(
|
||||
"response.content_part.added",
|
||||
json!({
|
||||
"type": "response.content_part.added",
|
||||
"item_id": self.text.item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"part": {
|
||||
"type": "output_text",
|
||||
"text": "",
|
||||
"annotations": []
|
||||
}
|
||||
}),
|
||||
));
|
||||
events.push(sse::message_item_added(output_index, &item_id));
|
||||
events.push(sse::message_content_part_added(output_index, &item_id));
|
||||
}
|
||||
|
||||
self.text.text.push_str(delta);
|
||||
let output_index = self.text.output_index.unwrap_or(0);
|
||||
events.push(sse_event(
|
||||
"response.output_text.delta",
|
||||
json!({
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": self.text.item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"delta": delta
|
||||
}),
|
||||
events.push(sse::output_text_delta(
|
||||
output_index,
|
||||
&self.text.item_id,
|
||||
delta,
|
||||
));
|
||||
|
||||
events
|
||||
@@ -485,36 +414,21 @@ impl ChatToResponsesState {
|
||||
&self.tool_context,
|
||||
);
|
||||
|
||||
events.push(sse_event(
|
||||
"response.output_item.added",
|
||||
json!({
|
||||
"type": "response.output_item.added",
|
||||
"output_index": assigned,
|
||||
"item": item
|
||||
}),
|
||||
));
|
||||
events.push(sse::output_item_added(assigned, &item));
|
||||
|
||||
if !pending_arguments.is_empty() && !is_custom_tool {
|
||||
events.push(sse_event(
|
||||
"response.function_call_arguments.delta",
|
||||
json!({
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"item_id": state.item_id,
|
||||
"output_index": assigned,
|
||||
"delta": pending_arguments
|
||||
}),
|
||||
events.push(sse::function_call_arguments_delta(
|
||||
assigned,
|
||||
&state.item_id,
|
||||
&pending_arguments,
|
||||
));
|
||||
}
|
||||
} else if !args_delta.is_empty() && !is_custom_tool {
|
||||
if let Some(output_index) = output_index {
|
||||
events.push(sse_event(
|
||||
"response.function_call_arguments.delta",
|
||||
json!({
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"delta": args_delta
|
||||
}),
|
||||
events.push(sse::function_call_arguments_delta(
|
||||
output_index,
|
||||
&item_id,
|
||||
&args_delta,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -567,13 +481,7 @@ impl ChatToResponsesState {
|
||||
response["incomplete_details"] = json!({ "reason": "max_output_tokens" });
|
||||
}
|
||||
|
||||
events.push(sse_event(
|
||||
"response.completed",
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": response
|
||||
}),
|
||||
));
|
||||
events.push(sse::response_completed(&response));
|
||||
self.completed = true;
|
||||
events
|
||||
}
|
||||
@@ -586,50 +494,10 @@ impl ChatToResponsesState {
|
||||
let output_index = self.reasoning.output_index.unwrap_or(0);
|
||||
let item_id = self.reasoning.item_id.clone();
|
||||
let text = self.reasoning.text.clone();
|
||||
let item = json!({
|
||||
"id": item_id,
|
||||
"type": "reasoning",
|
||||
"summary": [{
|
||||
"type": "summary_text",
|
||||
"text": text
|
||||
}]
|
||||
});
|
||||
self.output_items.push((output_index, item.clone()));
|
||||
let (events, item) = sse::reasoning_close(output_index, &item_id, &text);
|
||||
self.output_items.push((output_index, item));
|
||||
self.reasoning.done = true;
|
||||
|
||||
vec![
|
||||
sse_event(
|
||||
"response.reasoning_summary_text.done",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_text.done",
|
||||
"item_id": self.reasoning.item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"text": self.reasoning.text
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.reasoning_summary_part.done",
|
||||
json!({
|
||||
"type": "response.reasoning_summary_part.done",
|
||||
"item_id": self.reasoning.item_id,
|
||||
"output_index": output_index,
|
||||
"summary_index": 0,
|
||||
"part": {
|
||||
"type": "summary_text",
|
||||
"text": self.reasoning.text
|
||||
}
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.output_item.done",
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"output_index": output_index,
|
||||
"item": item
|
||||
}),
|
||||
),
|
||||
]
|
||||
events
|
||||
}
|
||||
|
||||
fn finalize_text(&mut self) -> Vec<Bytes> {
|
||||
@@ -638,54 +506,12 @@ impl ChatToResponsesState {
|
||||
}
|
||||
|
||||
let output_index = self.text.output_index.unwrap_or(0);
|
||||
let item = json!({
|
||||
"id": self.text.item_id,
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "output_text",
|
||||
"text": self.text.text,
|
||||
"annotations": []
|
||||
}]
|
||||
});
|
||||
self.output_items.push((output_index, item.clone()));
|
||||
let item_id = self.text.item_id.clone();
|
||||
let text = self.text.text.clone();
|
||||
let (events, item) = sse::message_close(output_index, &item_id, &text);
|
||||
self.output_items.push((output_index, item));
|
||||
self.text.done = true;
|
||||
|
||||
vec![
|
||||
sse_event(
|
||||
"response.output_text.done",
|
||||
json!({
|
||||
"type": "response.output_text.done",
|
||||
"item_id": self.text.item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"text": self.text.text
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.content_part.done",
|
||||
json!({
|
||||
"type": "response.content_part.done",
|
||||
"item_id": self.text.item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"part": {
|
||||
"type": "output_text",
|
||||
"text": self.text.text,
|
||||
"annotations": []
|
||||
}
|
||||
}),
|
||||
),
|
||||
sse_event(
|
||||
"response.output_item.done",
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"output_index": output_index,
|
||||
"item": item
|
||||
}),
|
||||
),
|
||||
]
|
||||
events
|
||||
}
|
||||
|
||||
fn finalize_tools(&mut self) -> Vec<Bytes> {
|
||||
@@ -742,14 +568,7 @@ impl ChatToResponsesState {
|
||||
Some(&state.reasoning_content),
|
||||
&self.tool_context,
|
||||
);
|
||||
add_event = Some(sse_event(
|
||||
"response.output_item.added",
|
||||
json!({
|
||||
"type": "response.output_item.added",
|
||||
"output_index": assigned,
|
||||
"item": item
|
||||
}),
|
||||
));
|
||||
add_event = Some(sse::output_item_added(assigned, &item));
|
||||
}
|
||||
|
||||
if let Some(event) = add_event {
|
||||
@@ -777,44 +596,25 @@ impl ChatToResponsesState {
|
||||
if is_custom_tool {
|
||||
let input = custom_tool_input_from_chat_arguments(&arguments);
|
||||
if !input.is_empty() {
|
||||
events.push(sse_event(
|
||||
"response.custom_tool_call_input.delta",
|
||||
json!({
|
||||
"type": "response.custom_tool_call_input.delta",
|
||||
"item_id": state.item_id,
|
||||
"output_index": output_index,
|
||||
"delta": input.clone()
|
||||
}),
|
||||
events.push(sse::custom_tool_call_input_delta(
|
||||
output_index,
|
||||
&state.item_id,
|
||||
&input,
|
||||
));
|
||||
}
|
||||
events.push(sse_event(
|
||||
"response.custom_tool_call_input.done",
|
||||
json!({
|
||||
"type": "response.custom_tool_call_input.done",
|
||||
"item_id": state.item_id,
|
||||
"output_index": output_index,
|
||||
"input": input
|
||||
}),
|
||||
events.push(sse::custom_tool_call_input_done(
|
||||
output_index,
|
||||
&state.item_id,
|
||||
&input,
|
||||
));
|
||||
} else {
|
||||
events.push(sse_event(
|
||||
"response.function_call_arguments.done",
|
||||
json!({
|
||||
"type": "response.function_call_arguments.done",
|
||||
"item_id": state.item_id,
|
||||
"output_index": output_index,
|
||||
"arguments": arguments
|
||||
}),
|
||||
events.push(sse::function_call_arguments_done(
|
||||
output_index,
|
||||
&state.item_id,
|
||||
&arguments,
|
||||
));
|
||||
}
|
||||
events.push(sse_event(
|
||||
"response.output_item.done",
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"output_index": output_index,
|
||||
"item": item
|
||||
}),
|
||||
));
|
||||
events.push(sse::output_item_done(output_index, &item));
|
||||
}
|
||||
|
||||
events
|
||||
@@ -864,13 +664,7 @@ impl ChatToResponsesState {
|
||||
let mut response = self.base_response("failed", self.completed_output_items());
|
||||
response["error"] = error;
|
||||
|
||||
sse_event(
|
||||
"response.failed",
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": response
|
||||
}),
|
||||
)
|
||||
sse::response_failed(&response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1030,13 +824,6 @@ fn extract_chat_sse_error(value: &Value) -> (String, Option<String>) {
|
||||
(message, error_type)
|
||||
}
|
||||
|
||||
fn sse_event(event: &str, data: Value) -> Bytes {
|
||||
Bytes::from(format!(
|
||||
"event: {event}\ndata: {}\n\n",
|
||||
serde_json::to_string(&data).unwrap_or_default()
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -80,7 +80,11 @@ impl CodexToolContext {
|
||||
.is_some_and(|spec| matches!(&spec.kind, CodexToolKind::Custom))
|
||||
}
|
||||
|
||||
fn chat_name_for_response_function(&self, name: &str, namespace: Option<&str>) -> String {
|
||||
pub(crate) fn chat_name_for_response_function(
|
||||
&self,
|
||||
name: &str,
|
||||
namespace: Option<&str>,
|
||||
) -> String {
|
||||
if let Some(namespace) = namespace.filter(|value| !value.is_empty()) {
|
||||
if let Some(chat_name) = self
|
||||
.namespace_name_to_chat_name
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, any>)?.config;
|
||||
return (
|
||||
typeof config === "string" &&
|
||||
isCodexChatWireApi(extractCodexWireApi(config))
|
||||
(isCodexChatWireApi(extractCodexWireApi(config)) ||
|
||||
isCodexAnthropicWireApi(extractCodexWireApi(config)))
|
||||
);
|
||||
}, [
|
||||
appId,
|
||||
|
||||
@@ -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(原生)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="anthropic">
|
||||
{t("codexConfig.upstreamFormatAnthropic", {
|
||||
defaultValue: "Anthropic Messages(需开启路由)",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{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。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isAnthropicFormat && (
|
||||
<div className="space-y-1.5">
|
||||
<FormLabel htmlFor="codex-anthropic-auth-field">
|
||||
{t("codexConfig.anthropicAuthFieldLabel", {
|
||||
defaultValue: "认证字段",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={anthropicAuthField}
|
||||
onValueChange={(value) =>
|
||||
onAnthropicAuthFieldChange(value as ClaudeApiKeyField)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="codex-anthropic-auth-field"
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
|
||||
{t("codexConfig.anthropicAuthFieldAuthToken", {
|
||||
defaultValue:
|
||||
"ANTHROPIC_AUTH_TOKEN(Authorization)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="ANTHROPIC_API_KEY">
|
||||
{t("codexConfig.anthropicAuthFieldApiKey", {
|
||||
defaultValue: "ANTHROPIC_API_KEY(x-api-key)",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.anthropicAuthFieldHint", {
|
||||
defaultValue:
|
||||
"选择网关接收 API Key 的请求头:ANTHROPIC_AUTH_TOKEN 发送 Authorization: Bearer;ANTHROPIC_API_KEY 发送 x-api-key。两者只发其一。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAnthropicFormat && (
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border-default pt-3">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>
|
||||
{t("codexConfig.impersonateClaudeCodeLabel", {
|
||||
defaultValue: "模拟 Claude Code 客户端",
|
||||
})}
|
||||
</FormLabel>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.impersonateClaudeCodeHint", {
|
||||
defaultValue:
|
||||
"网关或其上游限制只能通过 Claude Code 使用时开启:伪装 User-Agent、anthropic-beta、x-app 请求头,并在系统提示首行注入 Claude Code 身份。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={impersonateClaudeCode}
|
||||
onCheckedChange={onImpersonateClaudeCodeChange}
|
||||
aria-label={t("codexConfig.impersonateClaudeCodeLabel", {
|
||||
defaultValue: "模拟 Claude Code 客户端",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAnthropicFormat && (
|
||||
<div className="space-y-1.5 border-t border-border-default pt-3">
|
||||
<FormLabel htmlFor="codex-anthropic-max-output-tokens">
|
||||
{t("codexConfig.maxOutputTokensLabel", {
|
||||
defaultValue: "最大输出 tokens",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="codex-anthropic-max-output-tokens"
|
||||
type="number"
|
||||
min={1}
|
||||
inputMode="numeric"
|
||||
value={maxOutputTokens}
|
||||
onChange={(event) =>
|
||||
onMaxOutputTokensChange(
|
||||
event.target.value.replace(/[^\d]/g, ""),
|
||||
)
|
||||
}
|
||||
placeholder={t("codexConfig.maxOutputTokensPlaceholder", {
|
||||
defaultValue: "留空则使用默认 8192",
|
||||
})}
|
||||
/>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.maxOutputTokensHint", {
|
||||
defaultValue:
|
||||
"Codex 不会把 model_max_output_tokens 写进请求体,默认上限 8192 容易在长回答或深度思考时被截断(stop_reason=max_tokens)。此处设置会作为 Anthropic 的 max_tokens 覆盖请求值。请勿超过该模型/网关的真实输出上限,否则可能 400。留空使用默认 8192。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<CodexApiFormat>(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<ClaudeApiKeyField>(initialCodexAnthropicAuthField);
|
||||
|
||||
// Emulate the Claude Code client: off by default, enabled only when the user explicitly turns it on (true)
|
||||
const [localCodexImpersonateClaudeCode, setLocalCodexImpersonateClaudeCode] =
|
||||
useState<boolean>(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<string>(
|
||||
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}
|
||||
|
||||
@@ -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<string, any>).config,
|
||||
),
|
||||
)));
|
||||
const isCodexAnthropicFormat =
|
||||
activeApp === "codex" &&
|
||||
(provider.meta?.apiFormat === "anthropic" ||
|
||||
(typeof (provider.settingsConfig as Record<string, any>)?.config ===
|
||||
"string" &&
|
||||
isCodexAnthropicWireApi(
|
||||
extractCodexWireApi(
|
||||
(provider.settingsConfig as Record<string, any>).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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "新增模型",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+10
-1
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user