diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 64e3981fb..0f86857fa 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -1521,6 +1521,48 @@ impl RequestForwarder { mapped_body }; + // Native Responses passthrough to a strict third-party gateway (xAI): + // flatten Codex's private `namespace`/plugin tool declarations into + // top-level function tools so the upstream's strict serde parser does + // not 422 on `unknown variant "namespace"`. The Chat/Anthropic paths + // above already unwrap namespaces, so this only fires on the native + // passthrough. The response handler restores the flat names using a map + // re-derived from the same request tools. + if matches!(app_type, AppType::Codex | AppType::GrokBuild) + && !codex_responses_to_chat + && !codex_responses_to_anthropic + && super::providers::provider_needs_responses_namespace_flatten(provider) + && super::providers::transform_codex_responses_namespace::flatten_request_namespaces( + &mut request_body, + )? + { + log::debug!( + "[Codex] Flattened namespace tools for native Responses upstream (provider={})", + provider.id + ); + } + + // Same native-Responses path: scrub the OpenAI-backend-private fields + // and tool carriers (`external_web_access`, `prompt_cache_retention`, + // `additional_tools`, `tool_search`, …) that xAI's strict serde parser + // rejects with 400/422. Deterministic field removals only, gated on the + // xAI OAuth path, so the prompt-cache prefix stays stable and no other + // provider is affected. Runs after the flatten above so lifted + // `namespace` tools survive the tool-type whitelist. + if matches!(app_type, AppType::Codex | AppType::GrokBuild) + && !codex_responses_to_chat + && !codex_responses_to_anthropic + && super::providers::provider_needs_responses_namespace_flatten(provider) + && super::providers::transform_codex_responses_xai_sanitize::sanitize_xai_responses_request( + &mut request_body, + ) + { + log::debug!( + "[Codex] Sanitized xAI-unsupported Responses fields (provider={})", + provider.id + ); + } + if matches!(app_type, AppType::Codex | AppType::GrokBuild) { self.apply_media_prevention(&mut request_body, provider); } @@ -2608,6 +2650,14 @@ impl RequestForwarder { return ErrorCategory::NonRetryable; } + // xAI OAuth mirrors the same rule for token acquisition: a local + // AuthError means the managed account needs re-login. Failing over + // would silently move the conversation off the selected Grok account + // and poison the provider's health state for an account-level issue. + if provider.is_xai_oauth() && matches!(error, ProxyError::AuthError(_)) { + return ErrorCategory::NonRetryable; + } + match error { // 网络和上游错误:都应该尝试下一个供应商 ProxyError::Timeout(_) => ErrorCategory::Retryable, @@ -4343,6 +4393,32 @@ mod tests { } } + #[test] + fn xai_oauth_token_auth_failures_are_not_retryable() { + let forwarder = test_forwarder(Duration::ZERO, Duration::ZERO); + let provider = test_provider_with_type(Some("xai_oauth")); + + // 本地取 token 失败 = 账号级问题(需重新登录),failover 无济于事 + assert_eq!( + forwarder.categorize_proxy_error( + &ProxyError::AuthError("xAI OAuth 认证失败".to_string()), + &provider, + ), + ErrorCategory::NonRetryable + ); + // 上游 401/403 保持 Retryable:换 provider 可能持有可用的 key + assert_eq!( + forwarder.categorize_proxy_error( + &ProxyError::UpstreamError { + status: 401, + body: None, + }, + &provider, + ), + ErrorCategory::Retryable + ); + } + #[test] fn official_codex_rejects_stale_proxy_placeholder_with_restart_hint() { let mut headers = HeaderMap::new(); diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index af1620eb8..530c5143b 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -28,13 +28,13 @@ use super::{ 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_anthropic, transform_codex_chat, transform_gemini, - transform_responses, + transform, transform_codex_anthropic, transform_codex_chat, + transform_codex_responses_namespace, transform_gemini, transform_responses, }, response_processor::{ - create_logged_passthrough_stream, process_response, read_decoded_body, - strip_entity_headers_for_rebuilt_body, strip_hop_by_hop_response_headers, - usage_logging_enabled, SseUsageCollector, + create_logged_passthrough_stream, create_usage_collector, process_response, + read_decoded_body, strip_entity_headers_for_rebuilt_body, + strip_hop_by_hop_response_headers, usage_logging_enabled, SseUsageCollector, }, server::ProxyState, sse::{strip_sse_field, take_sse_block}, @@ -812,6 +812,10 @@ async fn handle_responses_for_app( .and_then(|v| v.as_bool()) .unwrap_or(false); let codex_tool_context = transform_codex_chat::build_codex_tool_context_from_request(&body); + // Captured before `body` is moved into the forwarder: the flat-name → + // {namespace, name} map used to restore the native Responses upstream's + // function-call names (see the namespace-restore dispatch below). + let namespace_restore_map = transform_codex_responses_namespace::namespace_restore_map(&body); let forwarder = ctx.create_forwarder(&state); let mut result = match forwarder @@ -865,6 +869,24 @@ async fn handle_responses_for_app( .await; } + // Native Responses passthrough to a strict gateway (xAI): the request-side + // flatten (in the forwarder) turned Codex `namespace` tools into flat + // function tools, so the upstream returns flat function-call names. Restore + // them to `{name, namespace}` so the Codex client matches them against its + // namespaced tool registry. + if super::providers::provider_needs_responses_namespace_flatten(&ctx.provider) + && !namespace_restore_map.is_empty() + { + return handle_codex_responses_namespace_restore( + response, + &ctx, + &state, + connection_guard, + namespace_restore_map, + ) + .await; + } + process_response( response, &ctx, @@ -927,6 +949,7 @@ async fn handle_responses_compact_for_app( .and_then(|v| v.as_bool()) .unwrap_or(false); let codex_tool_context = transform_codex_chat::build_codex_tool_context_from_request(&body); + let namespace_restore_map = transform_codex_responses_namespace::namespace_restore_map(&body); let forwarder = ctx.create_forwarder(&state); let mut result = match forwarder @@ -980,6 +1003,19 @@ async fn handle_responses_compact_for_app( .await; } + if super::providers::provider_needs_responses_namespace_flatten(&ctx.provider) + && !namespace_restore_map.is_empty() + { + return handle_codex_responses_namespace_restore( + response, + &ctx, + &state, + connection_guard, + namespace_restore_map, + ) + .await; + } + process_response( response, &ctx, @@ -990,6 +1026,154 @@ async fn handle_responses_compact_for_app( .await } +/// Response handler for the native Responses passthrough to a strict gateway +/// (xAI), restoring the flattened `function_call` names produced by the +/// request-side namespace flatten. Success bodies only carry a light rename; +/// error bodies and everything unrelated pass through unchanged. Usage is +/// collected exactly as `process_response` would (same `CODEX_PARSER_CONFIG`). +async fn handle_codex_responses_namespace_restore( + response: super::hyper_client::ProxyResponse, + ctx: &RequestContext, + state: &ProxyState, + connection_guard: Option, + restore_map: std::collections::HashMap< + String, + transform_codex_responses_namespace::NamespacedName, + >, +) -> Result { + let status = response.status(); + + // Error bodies (and any non-SSE, non-success response) never contain + // restorable function calls; hand them to the generic passthrough so error + // shape and usage handling stay identical to the untransformed path. + if !status.is_success() { + return process_response(response, ctx, state, &CODEX_PARSER_CONFIG, connection_guard) + .await; + } + + if response.is_sse() { + let mut response_headers = response.headers().clone(); + strip_hop_by_hop_response_headers(&mut response_headers); + + let mut builder = axum::response::Response::builder().status(status); + for (key, value) in &response_headers { + builder = builder.header(key, value); + } + + let restore_stream = + transform_codex_responses_namespace::create_namespace_restore_sse_stream( + response.bytes_stream(), + restore_map, + ); + let usage_collector = + create_usage_collector(ctx, state, status.as_u16(), &CODEX_PARSER_CONFIG); + let logged_stream = create_logged_passthrough_stream( + restore_stream, + ctx.tag, + usage_collector, + ctx.streaming_timeout_config(), + connection_guard, + ); + + let body = axum::body::Body::from_stream(logged_stream); + return builder.body(body).map_err(|e| { + log::error!("[{}] 构建 namespace 还原流式响应失败: {e}", ctx.tag); + ProxyError::Internal(format!("Failed to build streaming response: {e}")) + }); + } + + // Non-streaming: restore the flattened function-call names in the full body, + // then account usage from the (restore-neutral) Responses payload. + let _connection_guard = 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?; + strip_hop_by_hop_response_headers(&mut response_headers); + + // Restore names when the body parses as JSON; otherwise pass the bytes + // through untouched (a native Responses non-stream body is always JSON, so + // this only guards against a malformed upstream). + let restored_bytes = match serde_json::from_slice::(&body_bytes) { + Ok(mut value) => { + transform_codex_responses_namespace::restore_response_namespaces( + &mut value, + &restore_map, + ); + if let Some(usage) = + TokenUsage::from_codex_response_auto(&value).filter(TokenUsage::has_billable_tokens) + { + let model = value + .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; + } + }); + } + match serde_json::to_vec(&value) { + Ok(bytes) => Bytes::from(bytes), + Err(e) => { + log::error!("[{}] 序列化 namespace 还原响应失败: {e}", ctx.tag); + body_bytes + } + } + } + Err(_) => body_bytes, + }; + + strip_entity_headers_for_rebuilt_body(&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"), + ); + builder + .body(axum::body::Body::from(restored_bytes)) + .map_err(|e| { + log::error!("[{}] 构建 namespace 还原响应失败: {e}", ctx.tag); + ProxyError::Internal(format!("Failed to build response: {e}")) + }) +} + async fn handle_codex_chat_to_responses_transform( response: super::hyper_client::ProxyResponse, ctx: &RequestContext, diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs index b68a94df1..b3bff940e 100644 --- a/src-tauri/src/proxy/providers/codex.rs +++ b/src-tauri/src/proxy/providers/codex.rs @@ -206,6 +206,19 @@ pub fn should_convert_codex_responses_to_anthropic(provider: &Provider, endpoint ) && codex_provider_uses_anthropic(provider) } +/// Whether a native-Responses Codex upstream needs Codex `namespace`/plugin +/// tool declarations flattened before forwarding. +/// +/// Codex 0.142+ emits ChatGPT-backend-private `{"type":"namespace",…}` tool +/// shapes that strict third-party Responses gateways reject with +/// `422 unknown variant "namespace"`. Only providers whose upstream is such a +/// strict native gateway need the flatten+restore pass; the Chat/Anthropic +/// transform paths already unwrap namespaces on their own. Currently that is the +/// managed xAI (Grok) OAuth provider — the first strict gateway cc-switch hit. +pub fn provider_needs_responses_namespace_flatten(provider: &Provider) -> bool { + provider.is_xai_oauth() +} + /// The single built-in official Codex provider. Unlike managed Codex OAuth /// providers used by Claude, this route receives authentication from the /// calling Codex client (`requires_openai_auth = true`). @@ -228,6 +241,11 @@ pub fn resolve_codex_catalog_tool_profile( if is_codex_official_provider(provider) { return CodexCatalogToolProfile::NativeResponses; } + // xAI OAuth pins the native Responses profile regardless of editable + // api_format, mirroring the Claude-side managed-provider invariant. + if provider.is_xai_oauth() { + return CodexCatalogToolProfile::NativeResponses; + } if codex_provider_uses_anthropic(provider) { return CodexCatalogToolProfile::Anthropic; } @@ -656,6 +674,12 @@ impl ProviderAdapter for CodexAdapter { return Ok(super::CHATGPT_CODEX_BASE_URL.to_string()); } + // xAI OAuth: ignore editable provider base URLs and always use the xAI + // API origin associated with the managed token. + if provider.is_xai_oauth() { + return Ok(super::XAI_API_BASE_URL.to_string()); + } + // 1. 尝试直接获取 base_url 字段 if let Some(url) = provider .settings_config @@ -706,6 +730,15 @@ impl ProviderAdapter for CodexAdapter { } fn extract_auth(&self, provider: &Provider) -> Option { + // xAI OAuth (Grok subscription): placeholder credentials only; the real + // access_token is resolved per-request by the forwarder via XaiOAuthManager. + if provider.is_xai_oauth() { + return Some(AuthInfo::new( + "xai_oauth_placeholder".to_string(), + AuthStrategy::XaiOAuth, + )); + } + // 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) @@ -1509,4 +1542,70 @@ wire_api = "chat" assert_eq!(config.supports_effort, Some(false)); assert_eq!(config.output_format.as_deref(), Some("reasoning_content")); } + + #[test] + fn xai_oauth_invariants_ignore_editable_base_url_and_auth() { + let adapter = CodexAdapter::new(); + let mut provider = create_provider(json!({ + "auth": { "OPENAI_API_KEY": "user-edited" }, + "config": r#" +model = "grok-4.5" + +[model_providers.custom] +name = "xai" +base_url = "https://attacker.example/v1" +wire_api = "responses" +"# + })); + provider.meta = Some(crate::provider::ProviderMeta { + provider_type: Some("xai_oauth".to_string()), + ..Default::default() + }); + + // 可编辑字段(base_url / auth key)不得影响托管路由: + // 端点硬定向 api.x.ai,凭据是占位符(真 token 由 forwarder 注入)。 + assert_eq!( + adapter.extract_base_url(&provider).unwrap(), + super::super::XAI_API_BASE_URL + ); + let auth = adapter + .extract_auth(&provider) + .expect("managed auth placeholder"); + assert_eq!(auth.api_key, "xai_oauth_placeholder"); + assert_eq!(auth.strategy, AuthStrategy::XaiOAuth); + } + + #[test] + fn xai_oauth_pins_native_responses_catalog_profile() { + let mut provider = create_provider(json!({ "auth": {}, "config": "" })); + provider.meta = Some(crate::provider::ProviderMeta { + provider_type: Some("xai_oauth".to_string()), + // 即使 api_format 被改成 anthropic,catalog 画像也必须钉死原生 Responses + api_format: Some("anthropic".to_string()), + ..Default::default() + }); + + assert!(matches!( + resolve_codex_catalog_tool_profile(&provider), + crate::codex_config::CodexCatalogToolProfile::NativeResponses + )); + } + + #[test] + fn namespace_flatten_gate_only_fires_for_xai_oauth() { + // xAI OAuth: strict native gateway → needs namespace flattening. + let mut xai = create_provider(json!({ "auth": {}, "config": "" })); + xai.meta = Some(crate::provider::ProviderMeta { + provider_type: Some("xai_oauth".to_string()), + ..Default::default() + }); + assert!(provider_needs_responses_namespace_flatten(&xai)); + + // A plain third-party API-key Codex provider must not be flattened. + let plain = create_provider(json!({ + "auth": { "OPENAI_API_KEY": "sk-x" }, + "config": "base_url = \"https://api.x.ai/v1\"\nwire_api = \"responses\"" + })); + assert!(!provider_needs_responses_namespace_flatten(&plain)); + } } diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 8a5b24fce..625e1d0bb 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -34,6 +34,8 @@ pub mod streaming_responses; pub mod transform; pub mod transform_codex_anthropic; pub mod transform_codex_chat; +pub mod transform_codex_responses_namespace; +pub mod transform_codex_responses_xai_sanitize; pub mod transform_gemini; pub mod transform_responses; pub mod xai_oauth_auth; @@ -57,8 +59,9 @@ pub use codex::CodexAdapter; pub use codex::{ apply_codex_chat_upstream_model, apply_codex_upstream_model, codex_provider_upstream_model, inject_codex_chat_prompt_cache_key, is_codex_official_provider, - resolve_codex_catalog_tool_profile, resolve_codex_chat_reasoning_config, - should_convert_codex_responses_to_anthropic, should_convert_codex_responses_to_chat, + provider_needs_responses_namespace_flatten, resolve_codex_catalog_tool_profile, + resolve_codex_chat_reasoning_config, should_convert_codex_responses_to_anthropic, + should_convert_codex_responses_to_chat, }; pub use gemini::GeminiAdapter; diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index e90c728d2..05f27f391 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -1094,7 +1094,7 @@ fn collect_tool_search_output_tools(value: &Value, context: &mut CodexToolContex } } -fn flatten_namespace_tool_name(namespace: &str, name: &str) -> String { +pub(crate) fn flatten_namespace_tool_name(namespace: &str, name: &str) -> String { let full_name = format!("{namespace}__{name}"); if full_name.len() <= CHAT_TOOL_NAME_MAX_LEN { return full_name; diff --git a/src-tauri/src/proxy/providers/transform_codex_responses_namespace.rs b/src-tauri/src/proxy/providers/transform_codex_responses_namespace.rs new file mode 100644 index 000000000..5bdae6c6a --- /dev/null +++ b/src-tauri/src/proxy/providers/transform_codex_responses_namespace.rs @@ -0,0 +1,623 @@ +//! Codex `namespace` tool flattening for native Responses upstreams. +//! +//! Codex 0.142+ declares its plugin/MCP tools with a private Responses +//! extension shape — `{"type":"namespace","name":"mcp__x__","tools":[…]}` plus +//! `tool_search` — that the OpenAI ChatGPT backend understands but strict +//! third-party gateways (e.g. xAI's `api.x.ai/v1/responses`) reject with +//! `422 unknown variant "namespace"`. cc-switch's Chat/Anthropic transforms +//! already unwrap these, but the *native* Responses passthrough sends them +//! verbatim. +//! +//! This module implements the request-side flatten + response-side restore for +//! that native path, mirroring the proven design of sub2api +//! (`pkg/apicompat/responses_namespace.go`): +//! +//! - **Request**: lift every `namespace` child into a top-level `function` tool +//! whose name is the deterministic flat name `__` (with the +//! same sha256 truncation used by the Chat path, so both layers agree), then +//! rewrite namespace-qualified `function_call` items in the replayed `input` +//! history to the flat name and drop a `namespace`-typed `tool_choice`. +//! - **Response**: restore the flat `function_call` names back to +//! `{name, namespace}` so the Codex client can match the call against its own +//! namespaced tool registry (streaming and non-streaming). +//! +//! Flatten and restore both derive their name map from the *same* request tools +//! via [`flatten_namespace_tool_name`], so the forwarder (flatten) and the +//! response handler (restore) stay consistent without threading state between +//! them. + +use std::collections::HashMap; + +use bytes::Bytes; +use futures::stream::{Stream, StreamExt}; +use serde_json::{json, Value}; + +use super::transform_codex_chat::flatten_namespace_tool_name; +use crate::proxy::error::ProxyError; +use crate::proxy::sse::{append_utf8_safe, strip_sse_field, take_sse_block}; + +/// Reverse map entry: a flattened tool name resolves back to its original +/// namespace and bare child name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct NamespacedName { + pub namespace: String, + pub name: String, +} + +/// Build the flat-name → `{namespace, name}` restore map from a Codex Responses +/// request body. Used by the response handler to invert the request-side +/// flatten; derives names exactly as [`flatten_request_namespaces`] does. +pub(crate) fn namespace_restore_map(request_body: &Value) -> HashMap { + let mut map = HashMap::new(); + let Some(tools) = request_body.get("tools").and_then(Value::as_array) else { + return map; + }; + for tool in tools { + if tool.get("type").and_then(Value::as_str) != Some("namespace") { + continue; + } + let Some(namespace) = tool.get("name").and_then(Value::as_str) else { + continue; + }; + let namespace = namespace.trim(); + if namespace.is_empty() { + continue; + } + for child in namespace_children(tool) { + if child.get("type").and_then(Value::as_str) != Some("function") { + continue; + } + let Some(name) = child.get("name").and_then(Value::as_str) else { + continue; + }; + let name = name.trim(); + if name.is_empty() { + continue; + } + let flat = flatten_namespace_tool_name(namespace, name); + map.entry(flat).or_insert_with(|| NamespacedName { + namespace: namespace.to_string(), + name: name.to_string(), + }); + } + } + map +} + +/// Flatten Codex `namespace` tool declarations in a native Responses request +/// body into top-level `function` tools, rewrite namespace-qualified calls in +/// the `input` history, and neutralize a `namespace` `tool_choice`. +/// +/// Returns `Ok(true)` when the body was rewritten. Returns a `TransformError` +/// when two distinct namespace children (or a child and a top-level tool) +/// collapse to the same flat name — the upstream could not disambiguate them, +/// so failing loudly beats silently dropping a tool (matches sub2api). +pub(crate) fn flatten_request_namespaces(body: &mut Value) -> Result { + let Some(tools) = body.get("tools").and_then(Value::as_array) else { + return Ok(false); + }; + if !tools + .iter() + .any(|tool| tool.get("type").and_then(Value::as_str) == Some("namespace")) + { + return Ok(false); + } + + // Names already occupied by top-level function/custom tools; a namespace + // child flattening onto one of these is an unrecoverable collision. + let mut top_level = std::collections::HashSet::new(); + for tool in tools { + let typ = tool.get("type").and_then(Value::as_str).unwrap_or(""); + if typ == "function" || typ == "custom" { + if let Some(name) = tool.get("name").and_then(Value::as_str) { + let name = name.trim(); + if !name.is_empty() { + top_level.insert(name.to_string()); + } + } + } + } + + // Validate flat-name uniqueness before mutating anything. + let mut owners: HashMap = HashMap::new(); + for tool in tools { + if tool.get("type").and_then(Value::as_str) != Some("namespace") { + continue; + } + let Some(namespace) = tool.get("name").and_then(Value::as_str).map(str::trim) else { + continue; + }; + if namespace.is_empty() { + continue; + } + for child in namespace_children(tool) { + if child.get("type").and_then(Value::as_str) != Some("function") { + continue; + } + let Some(name) = child.get("name").and_then(Value::as_str).map(str::trim) else { + continue; + }; + if name.is_empty() { + continue; + } + let flat = flatten_namespace_tool_name(namespace, name); + if top_level.contains(&flat) { + return Err(ProxyError::TransformError(format!( + "namespace tool {namespace:?}/{name:?} flattens to {flat:?} which \ + collides with a top-level tool of the same name; rename one of them" + ))); + } + let entry = NamespacedName { + namespace: namespace.to_string(), + name: name.to_string(), + }; + if let Some(prev) = owners.get(&flat) { + if *prev != entry { + return Err(ProxyError::TransformError(format!( + "namespace tools {:?}/{:?} and {namespace:?}/{name:?} both flatten to \ + {flat:?}; rename one of them", + prev.namespace, prev.name + ))); + } + } else { + owners.insert(flat, entry); + } + } + } + + // Rebuild the tools array with namespace children lifted to top level. + let tools = body + .get("tools") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut flattened: Vec = Vec::with_capacity(tools.len()); + let mut seen_flat = std::collections::HashSet::new(); + for tool in tools { + if tool.get("type").and_then(Value::as_str) != Some("namespace") { + flattened.push(tool); + continue; + } + let Some(namespace) = tool.get("name").and_then(Value::as_str).map(str::trim) else { + continue; + }; + for child in namespace_children(&tool) { + if child.get("type").and_then(Value::as_str) != Some("function") { + continue; + } + let Some(name) = child.get("name").and_then(Value::as_str).map(str::trim) else { + continue; + }; + if name.is_empty() { + continue; + } + let flat = flatten_namespace_tool_name(namespace, name); + if !seen_flat.insert(flat.clone()) { + continue; + } + let mut lifted = child.clone(); + if let Some(obj) = lifted.as_object_mut() { + obj.insert("name".to_string(), json!(flat)); + } + flattened.push(lifted); + } + } + body["tools"] = json!(flattened); + + // Rewrite namespace-qualified function_call items in the replayed history. + if let Some(input) = body.get_mut("input") { + rewrite_namespace_qualified_calls(input, &owners); + } + + // A namespace-typed tool_choice cannot survive flattening: degrade to auto. + if let Some(choice) = body.get_mut("tool_choice") { + if choice.get("type").and_then(Value::as_str) == Some("namespace") { + *choice = json!("auto"); + } else { + rewrite_namespace_qualified_call(choice, &owners); + } + } + + Ok(true) +} + +/// Restore flattened `function_call` names in a full (non-streaming) Responses +/// payload back to their `{name, namespace}` identity. Returns whether anything +/// changed. +pub(crate) fn restore_response_namespaces( + value: &mut Value, + map: &HashMap, +) -> bool { + if map.is_empty() { + return false; + } + restore_value(value, map) +} + +/// Restore a single parsed SSE event (e.g. `response.output_item.added` / +/// `.done` carrying a `function_call`). Returns whether anything changed. +pub(crate) fn restore_sse_event_namespaces( + event: &mut Value, + map: &HashMap, +) -> bool { + if map.is_empty() { + return false; + } + restore_value(event, map) +} + +fn namespace_children(tool: &Value) -> Vec { + tool.get("tools") + .or_else(|| tool.get("children")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() +} + +fn rewrite_namespace_qualified_calls(value: &mut Value, owners: &HashMap) { + match value { + Value::Array(items) => { + for item in items { + rewrite_namespace_qualified_calls(item, owners); + } + } + Value::Object(obj) => { + if obj.get("type").and_then(Value::as_str) == Some("function_call") { + rewrite_namespace_qualified_call(value, owners); + return; + } + for child in obj.values_mut() { + rewrite_namespace_qualified_calls(child, owners); + } + } + _ => {} + } +} + +fn rewrite_namespace_qualified_call( + item: &mut Value, + owners: &HashMap, +) -> bool { + let Some(obj) = item.as_object_mut() else { + return false; + }; + let namespace = obj + .get("namespace") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or("") + .to_string(); + let name = obj + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or("") + .to_string(); + if namespace.is_empty() || name.is_empty() { + return false; + } + let flat = flatten_namespace_tool_name(&namespace, &name); + match owners.get(&flat) { + Some(entry) if entry.namespace == namespace && entry.name == name => { + obj.insert("name".to_string(), json!(flat)); + obj.remove("namespace"); + true + } + _ => false, + } +} + +fn restore_value(value: &mut Value, map: &HashMap) -> bool { + let mut changed = false; + match value { + Value::Array(items) => { + for item in items { + changed |= restore_value(item, map); + } + } + Value::Object(obj) => { + if obj.get("type").and_then(Value::as_str) == Some("function_call") { + if let Some(flat) = obj.get("name").and_then(Value::as_str) { + if let Some(entry) = map.get(flat) { + obj.insert("name".to_string(), json!(entry.name)); + obj.insert("namespace".to_string(), json!(entry.namespace)); + changed = true; + } + } + } + for child in obj.values_mut() { + changed |= restore_value(child, map); + } + } + _ => {} + } + changed +} + +/// Wrap a native Responses SSE byte stream, restoring flattened `function_call` +/// names in each event back to their namespace identity. Events that carry no +/// affected function call pass through with their inner content preserved +/// verbatim (only the block delimiter is normalized to `\n\n`). +pub(crate) fn create_namespace_restore_sse_stream( + stream: impl Stream> + Send + 'static, + map: HashMap, +) -> impl Stream> + Send +where + E: std::error::Error + Send + 'static, +{ + async_stream::stream! { + let mut buffer = String::new(); + let mut utf8_remainder: Vec = Vec::new(); + + tokio::pin!(stream); + + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => { + append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes); + while let Some(block) = take_sse_block(&mut buffer) { + if block.trim().is_empty() { + continue; + } + yield Ok(restore_sse_block(&block, &map)); + } + } + Err(e) => { + yield Err(std::io::Error::other(e.to_string())); + return; + } + } + } + + // Flush any trailing partial block (streams normally end on a delimiter, + // but be defensive so no bytes are dropped). + if !utf8_remainder.is_empty() { + buffer.push_str(&String::from_utf8_lossy(&utf8_remainder)); + } + let tail = std::mem::take(&mut buffer); + if !tail.trim().is_empty() { + yield Ok(restore_sse_block(&tail, &map)); + } + } +} + +/// Restore one SSE block. When the block's `data:` JSON carries an affected +/// function call, re-serialize just that line; otherwise the original block text +/// is preserved and only the `\n\n` delimiter re-appended. +fn restore_sse_block(block: &str, map: &HashMap) -> Bytes { + let mut event_name: Option<&str> = None; + let mut data_parts: Vec<&str> = Vec::new(); + for line in block.lines() { + if let Some(event) = strip_sse_field(line, "event") { + event_name = Some(event.trim()); + } + if let Some(data) = strip_sse_field(line, "data") { + data_parts.push(data); + } + } + + if data_parts.is_empty() { + return Bytes::from(format!("{block}\n\n")); + } + + let data = data_parts.join("\n"); + if data.trim() == "[DONE]" { + return Bytes::from(format!("{block}\n\n")); + } + + let mut event: Value = match serde_json::from_str(&data) { + Ok(value) => value, + // Non-JSON data (shouldn't happen on the Responses wire): pass through. + Err(_) => return Bytes::from(format!("{block}\n\n")), + }; + + if !restore_sse_event_namespaces(&mut event, map) { + return Bytes::from(format!("{block}\n\n")); + } + + let restored = serde_json::to_string(&event).unwrap_or(data); + let mut out = String::new(); + if let Some(name) = event_name { + out.push_str("event: "); + out.push_str(name); + out.push('\n'); + } + out.push_str("data: "); + out.push_str(&restored); + out.push_str("\n\n"); + Bytes::from(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::stream; + use serde_json::json; + + fn namespace_request() -> Value { + json!({ + "model": "grok-4.5", + "tools": [ + { "type": "function", "name": "plain_tool", "parameters": {} }, + { + "type": "namespace", + "name": "mcp__files__", + "tools": [ + { "type": "function", "name": "read", "description": "read a file", "parameters": {} }, + { "type": "function", "name": "write", "parameters": {} } + ] + } + ], + "input": [ + { + "type": "function_call", + "name": "read", + "namespace": "mcp__files__", + "call_id": "c1", + "arguments": "{}" + } + ], + "tool_choice": { "type": "namespace", "name": "mcp__files__" } + }) + } + + #[test] + fn flatten_lifts_namespace_children_to_top_level_functions() { + let mut body = namespace_request(); + assert!(flatten_request_namespaces(&mut body).unwrap()); + + let tools = body["tools"].as_array().unwrap(); + // plain + read + write, all top-level function tools; no namespace left. + assert_eq!(tools.len(), 3); + assert!(tools.iter().all(|t| t["type"] == "function")); + let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect(); + assert!(names.contains(&"plain_tool")); + assert!(names.contains(&"mcp__files____read")); + assert!(names.contains(&"mcp__files____write")); + // Child metadata is preserved on the lifted tool. + let read = tools + .iter() + .find(|t| t["name"] == "mcp__files____read") + .unwrap(); + assert_eq!(read["description"], "read a file"); + } + + #[test] + fn flatten_rewrites_input_history_calls_and_tool_choice() { + let mut body = namespace_request(); + flatten_request_namespaces(&mut body).unwrap(); + + let call = &body["input"][0]; + assert_eq!(call["name"], "mcp__files____read"); + assert!(call.get("namespace").is_none()); + assert_eq!(call["call_id"], "c1"); + // A namespace-typed tool_choice degrades to "auto". + assert_eq!(body["tool_choice"], json!("auto")); + } + + #[test] + fn flatten_is_noop_without_namespace_tools() { + let mut body = json!({ + "tools": [ { "type": "function", "name": "plain", "parameters": {} } ] + }); + assert!(!flatten_request_namespaces(&mut body).unwrap()); + } + + #[test] + fn flatten_errors_on_flat_name_collision_with_top_level() { + let mut body = json!({ + "tools": [ + { "type": "function", "name": "mcp__files____read", "parameters": {} }, + { + "type": "namespace", + "name": "mcp__files__", + "tools": [ { "type": "function", "name": "read", "parameters": {} } ] + } + ] + }); + assert!(flatten_request_namespaces(&mut body).is_err()); + } + + #[test] + fn restore_map_inverts_flatten_naming() { + let body = namespace_request(); + let map = namespace_restore_map(&body); + let entry = map.get("mcp__files____read").unwrap(); + assert_eq!(entry.namespace, "mcp__files__"); + assert_eq!(entry.name, "read"); + // Plain top-level tools are not in the restore map. + assert!(!map.contains_key("plain_tool")); + } + + #[test] + fn round_trip_flatten_then_restore_recovers_namespace() { + let request = namespace_request(); + let map = namespace_restore_map(&request); + + // Upstream returns a function_call using the flattened name. + let mut response = json!({ + "type": "response", + "output": [ + { + "type": "function_call", + "name": "mcp__files____read", + "call_id": "c1", + "arguments": "{}" + } + ] + }); + assert!(restore_response_namespaces(&mut response, &map)); + let call = &response["output"][0]; + assert_eq!(call["name"], "read"); + assert_eq!(call["namespace"], "mcp__files__"); + } + + #[test] + fn restore_leaves_unmapped_calls_untouched() { + let map = namespace_restore_map(&namespace_request()); + let mut response = json!({ + "output": [ + { "type": "function_call", "name": "plain_tool", "call_id": "x" } + ] + }); + assert!(!restore_response_namespaces(&mut response, &map)); + assert_eq!(response["output"][0]["name"], "plain_tool"); + assert!(response["output"][0].get("namespace").is_none()); + } + + #[test] + fn long_flat_names_stay_consistent_between_flatten_and_restore() { + let long_child = "a".repeat(80); + let body = json!({ + "tools": [{ + "type": "namespace", + "name": "mcp__srv__", + "tools": [ { "type": "function", "name": long_child, "parameters": {} } ] + }] + }); + let mut flattened = body.clone(); + flatten_request_namespaces(&mut flattened).unwrap(); + let flat_name = flattened["tools"][0]["name"].as_str().unwrap().to_string(); + // Truncation kicks in past the 64-char chat tool-name limit. + assert!(flat_name.len() <= 64); + + let map = namespace_restore_map(&body); + // The truncated name from flatten must be a restore-map key. + let entry = map.get(&flat_name).unwrap(); + assert_eq!(entry.namespace, "mcp__srv__"); + assert_eq!(entry.name, long_child); + } + + #[tokio::test] + async fn sse_stream_restores_function_call_events_and_passes_others_through() { + let map = namespace_restore_map(&namespace_request()); + + let added = "event: response.output_item.added\n\ + data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"name\":\"mcp__files____read\",\"call_id\":\"c1\"}}\n\n"; + let delta = "event: response.output_text.delta\n\ + data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\n"; + let done = "data: [DONE]\n\n"; + + let chunks = vec![ + Ok::(Bytes::from(added)), + Ok(Bytes::from(delta)), + Ok(Bytes::from(done)), + ]; + let input = stream::iter(chunks); + let out = create_namespace_restore_sse_stream(input, map); + futures::pin_mut!(out); + + let mut collected = String::new(); + while let Some(chunk) = out.next().await { + collected.push_str(std::str::from_utf8(&chunk.unwrap()).unwrap()); + } + + // function_call name restored to namespace form. + assert!(collected.contains("\"name\":\"read\"")); + assert!(collected.contains("\"namespace\":\"mcp__files__\"")); + assert!(!collected.contains("mcp__files____read")); + // Unrelated events preserved verbatim. + assert!(collected.contains("\"delta\":\"hi\"")); + assert!(collected.contains("[DONE]")); + } +} diff --git a/src-tauri/src/proxy/providers/transform_codex_responses_xai_sanitize.rs b/src-tauri/src/proxy/providers/transform_codex_responses_xai_sanitize.rs new file mode 100644 index 000000000..2fbc5660e --- /dev/null +++ b/src-tauri/src/proxy/providers/transform_codex_responses_xai_sanitize.rs @@ -0,0 +1,539 @@ +//! xAI (Grok) `Responses` request field sanitization for native Responses +//! upstreams. +//! +//! Codex 0.142+ sends `wire_api="responses"` requests carrying a handful of +//! OpenAI-backend-private fields and tool carriers that xAI's strict +//! `api.x.ai/v1/responses` serde parser rejects (HTTP 400/422). cc-switch's +//! Chat/Anthropic transforms already drop these on the way through, but the +//! *native* Responses passthrough forwards the body verbatim, so we scrub them +//! here. +//! +//! This is a faithful port of sub2api's `patchGrokResponsesBody` +//! (`backend/internal/service/openai_gateway_grok.go`), the production Go +//! gateway that routes Codex → Grok subscriptions. Every transform is a +//! deterministic field removal or structural lift — no semantic rewriting — so +//! the same input always yields the same output and the upstream prompt-cache +//! prefix stays stable across requests. Gated on the xAI OAuth path only (see +//! [`super::codex::provider_needs_responses_namespace_flatten`]), so no other +//! provider is ever touched. +//! +//! Run this *after* namespace flattening: by then Codex's `namespace` tools are +//! already lifted to top-level `function` tools, so the tool-type whitelist +//! below keeps them instead of dropping them. + +use std::collections::HashSet; + +use serde_json::Value; + +/// Codex plugin-private fields removed recursively at any nesting depth. +const RECURSIVE_UNSUPPORTED_FIELDS: &[&str] = &["external_web_access"]; + +/// Top-level request fields xAI rejects regardless of model. +const TOP_LEVEL_UNSUPPORTED_FIELDS: &[&str] = &["prompt_cache_retention", "safety_identifier"]; + +/// Top-level sampling fields rejected specifically by grok-4.5. +const GROK_45_UNSUPPORTED_FIELDS: &[&str] = &[ + "presence_penalty", + "presencePenalty", + "frequency_penalty", + "frequencyPenalty", + "stop", +]; + +/// Tool `type` values xAI's Responses schema accepts. Sourced from xAI's own +/// serde error enumeration (which is more complete than sub2api's hand-copied +/// list — it includes `image_generation`). Any other `type` is a Codex/OpenAI +/// private carrier (`tool_search`, a stray `namespace`, `custom`, …) that the +/// strict parser would reject, so it is dropped. +const XAI_SUPPORTED_TOOL_TYPES: &[&str] = &[ + "function", + "web_search", + "x_search", + "image_generation", + "collections_search", + "file_search", + "code_execution", + "code_interpreter", + "mcp", + "shell", +]; + +/// Strip xAI-unsupported fields and tools from a native Codex Responses request +/// body in place. Returns whether anything changed. Deterministic and +/// idempotent: running it twice on the same body changes nothing the second +/// time. +pub(crate) fn sanitize_xai_responses_request(body: &mut Value) -> bool { + if !body.is_object() { + return false; + } + + let mut changed = false; + + // 1. Top-level fields xAI rejects for every model. + for field in TOP_LEVEL_UNSUPPORTED_FIELDS { + changed |= remove_top_level_field(body, field); + } + + // 2. grok-4.5 additionally rejects these sampling knobs. + if request_targets_grok_45(body) { + for field in GROK_45_UNSUPPORTED_FIELDS { + changed |= remove_top_level_field(body, field); + } + } + + // 3. Codex plugin-private flags buried at any depth (e.g. inside tools or + // tool parameter schemas). + for field in RECURSIVE_UNSUPPORTED_FIELDS { + changed |= remove_field_recursive(body, field); + } + + // 4. Lift the `additional_tools` input carrier (Responses Lite private + // shape) up to top-level `tools` so the supported ones survive. + changed |= promote_additional_tools(body); + + // 5. Drop `content: null` on reasoning input items — xAI's untagged enum + // deserializer refuses a present-but-null content field. + changed |= strip_null_reasoning_content(body); + + // 6. Whitelist the tool types and clean a now-dangling `tool_choice`. + changed |= filter_unsupported_tools(body); + + changed +} + +/// Whether the request's (possibly provider-prefixed) model resolves to +/// grok-4.5. Mirrors sub2api's suffix match: `foo/grok-4.5` counts. +fn request_targets_grok_45(body: &Value) -> bool { + let Some(model) = body.get("model").and_then(Value::as_str) else { + return false; + }; + let mut model = model.trim(); + if let Some(idx) = model.rfind('/') { + model = model[idx + 1..].trim(); + } + model.eq_ignore_ascii_case("grok-4.5") +} + +fn remove_top_level_field(body: &mut Value, field: &str) -> bool { + body.as_object_mut() + .and_then(|obj| obj.remove(field)) + .is_some() +} + +/// Delete every occurrence of `field` in the tree, at any depth. +fn remove_field_recursive(value: &mut Value, field: &str) -> bool { + match value { + Value::Object(map) => { + let mut changed = map.remove(field).is_some(); + for child in map.values_mut() { + changed |= remove_field_recursive(child, field); + } + changed + } + Value::Array(items) => { + let mut changed = false; + for child in items.iter_mut() { + changed |= remove_field_recursive(child, field); + } + changed + } + _ => false, + } +} + +fn is_additional_tools_item(item: &Value) -> bool { + item.get("type").and_then(Value::as_str).map(str::trim) == Some("additional_tools") +} + +/// Promote any `additional_tools` carrier items from `input` into top-level +/// `tools`, preserving top-level order and appending carrier tools in order, +/// de-duplicated. The carrier items themselves are removed from `input`. +fn promote_additional_tools(body: &mut Value) -> bool { + // Clone `input` up front so the later mutable write-back to `body` doesn't + // collide with the read borrow. Only pays the clone on the rare carrier path. + let input_items: Vec = match body.get("input").and_then(Value::as_array) { + Some(arr) if arr.iter().any(is_additional_tools_item) => arr.clone(), + _ => return false, + }; + + // Seed merged tools + dedup keys from the existing top-level tools. + let mut merged: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + if let Some(tools) = body.get("tools").and_then(Value::as_array) { + for tool in tools { + seen.insert(tool_dedup_key(tool)); + merged.push(tool.clone()); + } + } + + let mut filtered_input: Vec = Vec::with_capacity(input_items.len()); + let mut promoted = false; + for item in input_items { + if is_additional_tools_item(&item) { + if let Some(carrier_tools) = item.get("tools").and_then(Value::as_array) { + for tool in carrier_tools { + if seen.insert(tool_dedup_key(tool)) { + merged.push(tool.clone()); + promoted = true; + } + } + } + continue; // carrier item dropped regardless of dedup outcome + } + filtered_input.push(item); + } + + if let Some(obj) = body.as_object_mut() { + obj.insert("input".to_string(), Value::Array(filtered_input)); + if promoted { + obj.insert("tools".to_string(), Value::Array(merged)); + } + } + // We reached here only because a carrier existed, so `input` changed. + true +} + +/// Stable dedup key for a tool: `(type, name)`, `(mcp, server_label)`, or the +/// serialized tool as a last resort. Mirrors sub2api's `grokResponsesToolDedupKey`. +fn tool_dedup_key(tool: &Value) -> String { + let tool_type = tool + .get("type") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + if !tool_type.is_empty() { + if let Some(name) = tool.get("name").and_then(Value::as_str) { + let name = name.trim(); + if !name.is_empty() { + return format!("type:{tool_type}\u{0}name:{name}"); + } + } + if tool_type == "mcp" { + if let Some(label) = tool.get("server_label").and_then(Value::as_str) { + let label = label.trim(); + if !label.is_empty() { + return format!("type:mcp\u{0}server_label:{label}"); + } + } + } + } + format!("json:{tool}") +} + +fn strip_null_reasoning_content(body: &mut Value) -> bool { + let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) else { + return false; + }; + let mut changed = false; + for item in input.iter_mut() { + if item.get("type").and_then(Value::as_str).map(str::trim) != Some("reasoning") { + continue; + } + if let Some(obj) = item.as_object_mut() { + if matches!(obj.get("content"), Some(Value::Null)) { + obj.remove("content"); + changed = true; + } + } + } + changed +} + +/// Keep only whitelisted tool types and drop a `tool_choice` that now points at +/// a removed or unsupported tool. +fn filter_unsupported_tools(body: &mut Value) -> bool { + let Some(tools) = body.get("tools").and_then(Value::as_array) else { + return false; + }; + let original_len = tools.len(); + let filtered: Vec = tools + .iter() + .filter(|tool| { + let t = tool + .get("type") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + XAI_SUPPORTED_TOOL_TYPES.contains(&t) + }) + .cloned() + .collect(); + + let mut changed = false; + if filtered.len() != original_len { + if let Some(obj) = body.as_object_mut() { + if filtered.is_empty() { + obj.remove("tools"); + } else { + obj.insert("tools".to_string(), Value::Array(filtered.clone())); + } + } + changed = true; + } + + if body.get("tool_choice").is_some() && should_drop_tool_choice(body, &filtered) { + if let Some(obj) = body.as_object_mut() { + obj.remove("tool_choice"); + } + changed = true; + } + + changed +} + +/// Whether `tool_choice` should be dropped given the surviving `tools`. String +/// choices (`"auto"`, `"none"`, `"required"`) are always kept; object choices +/// are dropped when they reference an unsupported type or a function name that +/// no longer exists. +fn should_drop_tool_choice(body: &Value, tools: &[Value]) -> bool { + let Some(tool_choice) = body.get("tool_choice") else { + return false; + }; + if tools.is_empty() { + return true; + } + let Some(choice) = tool_choice.as_object() else { + return false; // "auto"/"none"/"required" string choices stay + }; + let choice_type = choice + .get("type") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + if choice_type.is_empty() { + return false; + } + if !XAI_SUPPORTED_TOOL_TYPES.contains(&choice_type) { + return true; + } + if choice_type == "function" { + let choice_name = choice + .get("name") + .and_then(Value::as_str) + .or_else(|| { + choice + .get("function") + .and_then(|f| f.get("name")) + .and_then(Value::as_str) + }) + .unwrap_or("") + .trim(); + if choice_name.is_empty() { + return false; + } + let exists = tools.iter().any(|tool| { + let t = tool + .get("type") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + let name = tool + .get("name") + .and_then(Value::as_str) + .or_else(|| { + tool.get("function") + .and_then(|f| f.get("name")) + .and_then(Value::as_str) + }) + .unwrap_or("") + .trim(); + t == "function" && name == choice_name + }); + return !exists; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn strips_external_web_access_recursively() { + let mut body = json!({ + "model": "grok-4.5", + "external_web_access": true, + "tools": [ + {"type": "function", "name": "f", "external_web_access": true, + "parameters": {"type": "object", "q": {"external_web_access": true}}} + ], + "metadata": {"external_web_access": false} + }); + assert!(sanitize_xai_responses_request(&mut body)); + let s = body.to_string(); + assert!(!s.contains("external_web_access"), "left over: {s}"); + } + + #[test] + fn strips_top_level_unsupported_fields() { + let mut body = json!({ + "model": "grok-4.5", + "prompt_cache_retention": "24h", + "safety_identifier": "abc" + }); + assert!(sanitize_xai_responses_request(&mut body)); + assert!(body.get("prompt_cache_retention").is_none()); + assert!(body.get("safety_identifier").is_none()); + } + + #[test] + fn strips_grok_45_only_sampling_fields() { + let mut body = json!({ + "model": "grok-4.5", + "presence_penalty": 0.1, + "frequency_penalty": 0.2, + "stop": ["x"] + }); + assert!(sanitize_xai_responses_request(&mut body)); + assert!(body.get("presence_penalty").is_none()); + assert!(body.get("frequency_penalty").is_none()); + assert!(body.get("stop").is_none()); + } + + #[test] + fn keeps_sampling_fields_for_non_grok_45() { + let mut body = json!({ + "model": "grok-4-fast", + "presence_penalty": 0.1, + "stop": ["x"] + }); + // No unsupported fields present, so no change and knobs preserved. + assert!(!sanitize_xai_responses_request(&mut body)); + assert_eq!(body.get("presence_penalty"), Some(&json!(0.1))); + assert_eq!(body.get("stop"), Some(&json!(["x"]))); + } + + #[test] + fn matches_grok_45_with_provider_prefix() { + let mut body = json!({"model": "xai/grok-4.5", "stop": ["x"]}); + assert!(sanitize_xai_responses_request(&mut body)); + assert!(body.get("stop").is_none()); + } + + #[test] + fn promotes_additional_tools_dedup() { + let mut body = json!({ + "model": "grok-4.5", + "tools": [{"type": "function", "name": "kept"}], + "input": [ + {"type": "message", "role": "user", "content": "hi"}, + {"type": "additional_tools", "tools": [ + {"type": "function", "name": "kept"}, + {"type": "function", "name": "extra"} + ]} + ] + }); + assert!(sanitize_xai_responses_request(&mut body)); + // carrier removed from input + let input = body.get("input").unwrap().as_array().unwrap(); + assert_eq!(input.len(), 1); + assert!(input.iter().all(|i| !is_additional_tools_item(i))); + // extra promoted, kept not duplicated + let tools = body.get("tools").unwrap().as_array().unwrap(); + let names: Vec<&str> = tools + .iter() + .map(|t| t.get("name").and_then(Value::as_str).unwrap()) + .collect(); + assert_eq!(names, vec!["kept", "extra"]); + } + + #[test] + fn strips_null_reasoning_content() { + let mut body = json!({ + "model": "grok-4.5", + "input": [ + {"type": "reasoning", "content": null, "id": "r1"}, + {"type": "reasoning", "content": [{"text": "keep"}], "id": "r2"} + ] + }); + assert!(sanitize_xai_responses_request(&mut body)); + let input = body.get("input").unwrap().as_array().unwrap(); + assert!(input[0].get("content").is_none()); + assert!(input[1].get("content").is_some()); + } + + #[test] + fn filters_unsupported_tool_types() { + let mut body = json!({ + "model": "grok-4.5", + "tools": [ + {"type": "function", "name": "f"}, + {"type": "tool_search"}, + {"type": "custom", "name": "c"}, + {"type": "mcp", "server_label": "s"} + ] + }); + assert!(sanitize_xai_responses_request(&mut body)); + let types: Vec<&str> = body + .get("tools") + .unwrap() + .as_array() + .unwrap() + .iter() + .map(|t| t.get("type").and_then(Value::as_str).unwrap()) + .collect(); + assert_eq!(types, vec!["function", "mcp"]); + } + + #[test] + fn drops_dangling_function_tool_choice() { + let mut body = json!({ + "model": "grok-4.5", + "tools": [{"type": "tool_search"}], + "tool_choice": {"type": "function", "name": "gone"} + }); + assert!(sanitize_xai_responses_request(&mut body)); + // tool_search filtered → no tools → tool_choice dropped + assert!(body.get("tools").is_none()); + assert!(body.get("tool_choice").is_none()); + } + + #[test] + fn keeps_valid_function_tool_choice() { + let mut body = json!({ + "model": "grok-4.5", + "tools": [{"type": "function", "name": "run"}], + "tool_choice": {"type": "function", "name": "run"} + }); + assert!(!sanitize_xai_responses_request(&mut body)); + assert_eq!( + body.get("tool_choice").unwrap(), + &json!({"type": "function", "name": "run"}) + ); + } + + #[test] + fn keeps_string_tool_choice() { + let mut body = json!({ + "model": "grok-4.5", + "tools": [{"type": "function", "name": "run"}], + "tool_choice": "auto" + }); + assert!(!sanitize_xai_responses_request(&mut body)); + assert_eq!(body.get("tool_choice").unwrap(), &json!("auto")); + } + + #[test] + fn noop_on_clean_request() { + let mut body = json!({ + "model": "grok-4.5", + "input": [{"type": "message", "role": "user", "content": "hi"}], + "tools": [{"type": "function", "name": "f"}] + }); + assert!(!sanitize_xai_responses_request(&mut body)); + } + + #[test] + fn idempotent_second_pass() { + let mut body = json!({ + "model": "grok-4.5", + "external_web_access": true, + "prompt_cache_retention": "24h", + "tools": [{"type": "function", "name": "f"}, {"type": "tool_search"}] + }); + assert!(sanitize_xai_responses_request(&mut body)); + // second pass finds nothing left to change + assert!(!sanitize_xai_responses_request(&mut body)); + } +} diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index 9ab57fe37..ed9cb3873 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -456,7 +456,7 @@ impl Drop for SseUsageFinishGuard { // ============================================================================ /// 创建使用量收集器 -fn create_usage_collector( +pub(crate) fn create_usage_collector( ctx: &RequestContext, state: &ProxyState, status_code: u16, diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index 2285f119f..9158eb494 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -27,8 +27,10 @@ import { } from "lucide-react"; import EndpointSpeedTest from "./EndpointSpeedTest"; import { ApiKeySection, EndpointField, ModelDropdown } from "./shared"; +import { XaiOAuthSection } from "./XaiOAuthSection"; import { fetchModelsForConfig, + fetchXaiOauthModels, showFetchModelsError, type FetchedModel, } from "@/lib/api/model-fetch"; @@ -52,6 +54,11 @@ interface EndpointCandidate { interface CodexFormFieldsProps { appId?: AppId; providerId?: string; + // xAI OAuth 托管预设(Grok 订阅):隐藏 API Key / 端点输入,挂账号选择区块 + isXaiOauthPreset?: boolean; + isXaiOauthAuthenticated?: boolean; + selectedXaiAccountId?: string | null; + onXaiAccountSelect?: (accountId: string | null) => void; // API Key codexApiKey: string; onApiKeyChange: (key: string) => void; @@ -159,6 +166,10 @@ function catalogRowsMatchModels( export function CodexFormFields({ appId = "codex", providerId, + isXaiOauthPreset, + isXaiOauthAuthenticated, + selectedXaiAccountId, + onXaiAccountSelect, codexApiKey, onApiKeyChange, category, @@ -212,7 +223,15 @@ export function CodexFormFields({ useEffect(() => { fetchModelsSeqRef.current += 1; setFetchedModels((prev) => (prev.length === 0 ? prev : [])); - }, [codexBaseUrl, isFullUrl, codexApiKey, customUserAgent]); + }, [ + codexBaseUrl, + isFullUrl, + codexApiKey, + customUserAgent, + isXaiOauthPreset, + isXaiOauthAuthenticated, + selectedXaiAccountId, + ]); // 思考能力随 Chat 格式显示(仅 Chat Completions 转换路径用得上);模型映射常驻 //(填了才生成 catalog)。两者都已与「路由接管」概念解耦。 const isChatFormat = apiFormat === "openai_chat"; @@ -239,14 +258,20 @@ export function CodexFormFields({ supportsEffort || promptCacheRouting !== "auto" || !!maxOutputTokens; - const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue); + const [advancedExpanded, setAdvancedExpanded] = useState( + isXaiOauthPreset ? false : hasAnyAdvancedValue, + ); - // 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠) + // 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠); + // xAI OAuth 托管预设的高级值都是预设自带的,无需展示,保持折叠 useEffect(() => { + if (isXaiOauthPreset) { + return; + } if (hasAnyAdvancedValue) { setAdvancedExpanded(true); } - }, [hasAnyAdvancedValue]); + }, [hasAnyAdvancedValue, isXaiOauthPreset]); const [catalogRows, setCatalogRows] = useState(() => catalogModels.map((m) => createCatalogRow(m)), @@ -307,6 +332,40 @@ export function CodexFormFields({ ); const handleFetchModels = useCallback(() => { + // xAI OAuth 托管预设:不走 base_url + key 的 /models 探测, + // 直接用托管账号 token 拉取(与 Claude 表单同一后端命令) + if (isXaiOauthPreset) { + if (!isXaiOauthAuthenticated) { + toast.error( + t("xaiOauth.loginRequired", { + defaultValue: "请先登录 xAI 账号", + }), + ); + return; + } + const seq = ++fetchModelsSeqRef.current; + setIsFetchingModels(true); + fetchXaiOauthModels(selectedXaiAccountId ?? null) + .then((models) => { + if (seq !== fetchModelsSeqRef.current) return; + setFetchedModels(models); + if (models.length === 0) { + toast.info(t("providerForm.fetchModelsEmpty")); + } else { + toast.success( + t("providerForm.fetchModelsSuccess", { count: models.length }), + ); + } + }) + .catch((err) => { + if (seq !== fetchModelsSeqRef.current) return; + console.warn("[XaiOAuth] Failed to fetch models:", err); + showFetchModelsError(err, t); + }) + .finally(() => setIsFetchingModels(false)); + return; + } + if (!codexBaseUrl || !codexApiKey) { showFetchModelsError(null, t, { hasApiKey: !!codexApiKey, @@ -340,7 +399,16 @@ export function CodexFormFields({ showFetchModelsError(err, t); }) .finally(() => setIsFetchingModels(false)); - }, [codexBaseUrl, codexApiKey, isFullUrl, customUserAgent, t]); + }, [ + codexBaseUrl, + codexApiKey, + isFullUrl, + customUserAgent, + isXaiOauthPreset, + isXaiOauthAuthenticated, + selectedXaiAccountId, + t, + ]); const handleAddCatalogRow = useCallback(() => { if (!onCatalogModelsChange) return; @@ -433,29 +501,39 @@ export function CodexFormFields({ return ( <> - {/* Codex API Key 输入框 */} - + {/* xAI OAuth 认证(Grok 订阅托管账号) */} + {isXaiOauthPreset && ( + + )} - {/* Codex Base URL 输入框 */} - {shouldShowSpeedTest && ( + {/* Codex API Key 输入框(托管 OAuth 预设无需 Key) */} + {!isXaiOauthPreset && ( + + )} + + {/* Codex Base URL 输入框(托管 OAuth 端点由 adapter 硬定向,不展示) */} + {shouldShowSpeedTest && !isXaiOauthPreset && ( {/* 上游格式 —— Chat 需开启路由接管(走代理转换),Responses 原生直连。 - 沿用 shouldShowSpeedTest 门控,cloud_provider 保持不可切换。 */} - {shouldShowSpeedTest && ( + 沿用 shouldShowSpeedTest 门控,cloud_provider 保持不可切换; + xAI OAuth 托管预设格式钉死 Responses,不可切换。 */} + {shouldShowSpeedTest && !isXaiOauthPreset && (
diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 59dc3d590..b7b04876e 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -717,6 +717,17 @@ function ProviderFormFull({ })); }, [appId]); + // 预设声明的托管身份类型(github_copilot / codex_oauth / xai_oauth)。 + // 跨应用通用:claude 的 templatePreset 与此查同一张 presetEntries 表, + // codex 等其它应用没有 templatePreset,只能走这里。 + const presetProviderType = useMemo(() => { + if (!selectedPresetId) return undefined; + const preset = presetEntries.find( + (entry) => entry.id === selectedPresetId, + )?.preset; + return preset && "providerType" in preset ? preset.providerType : undefined; + }, [presetEntries, selectedPresetId]); + const { templateValues, templateValueEntries, @@ -1153,14 +1164,14 @@ function ProviderFormFull({ // OAuth 未登录:B 类(token 根本不存在,保存了也没法建立) const isCopilotProvider = - templatePreset?.providerType === "github_copilot" || + presetProviderType === "github_copilot" || initialData?.meta?.providerType === "github_copilot" || baseUrl.includes("githubcopilot.com"); const isCodexOauthProvider = - templatePreset?.providerType === "codex_oauth" || + presetProviderType === "codex_oauth" || initialData?.meta?.providerType === "codex_oauth"; const isXaiOauthProvider = - templatePreset?.providerType === "xai_oauth" || + presetProviderType === "xai_oauth" || initialData?.meta?.providerType === "xai_oauth"; if (isCopilotProvider && !isCopilotAuthenticated) { toast.error( @@ -1284,14 +1295,16 @@ function ProviderFormFull({ ); } } else if (appId === "codex") { - if (!codexBaseUrl.trim()) { + // 托管 OAuth 预设(xAI):端点由 adapter 硬定向、token 由代理注入, + // 两项都不需要用户填写 + if (!isXaiOauthProvider && !codexBaseUrl.trim()) { issues.push( t("providerForm.endpointRequired", { defaultValue: "非官方供应商请填写 API 端点", }), ); } - if (!codexApiKey.trim()) { + if (!isXaiOauthProvider && !codexApiKey.trim()) { issues.push( t("providerForm.apiKeyRequired", { defaultValue: "非官方供应商请填写 API Key", @@ -1343,14 +1356,14 @@ function ProviderFormFull({ // OAuth / 其它身份识别(与 handleSubmit 保持一致) const isCopilotProvider = - templatePreset?.providerType === "github_copilot" || + presetProviderType === "github_copilot" || initialData?.meta?.providerType === "github_copilot" || baseUrl.includes("githubcopilot.com"); const isCodexOauthProvider = - templatePreset?.providerType === "codex_oauth" || + presetProviderType === "codex_oauth" || initialData?.meta?.providerType === "codex_oauth"; const isXaiOauthProvider = - templatePreset?.providerType === "xai_oauth" || + presetProviderType === "xai_oauth" || initialData?.meta?.providerType === "xai_oauth"; let settingsConfig: string; @@ -1529,7 +1542,7 @@ function ProviderFormFull({ // 确定 providerType(新建时从预设获取,编辑时从现有数据获取) const providerType = - templatePreset?.providerType || initialData?.meta?.providerType; + presetProviderType || initialData?.meta?.providerType; const nextMeta: ProviderMeta = { ...(baseMeta ?? {}), @@ -1603,7 +1616,9 @@ function ProviderFormFull({ ? "openai_responses" : localApiFormat : appId === "codex" && category !== "official" - ? localCodexApiFormat + ? isXaiOauthProvider + ? "openai_responses" + : localCodexApiFormat : undefined, apiKeyField: appId === "claude" && @@ -2186,26 +2201,26 @@ function ProviderFormFull({ isPartner={isClaudePartner} partnerPromotionKey={claudePartnerPromotionKey} isCopilotPreset={ - templatePreset?.providerType === "github_copilot" || + presetProviderType === "github_copilot" || initialData?.meta?.providerType === "github_copilot" || baseUrl.includes("githubcopilot.com") } isCodexOauthPreset={ - templatePreset?.providerType === "codex_oauth" || + presetProviderType === "codex_oauth" || initialData?.meta?.providerType === "codex_oauth" } isXaiOauthPreset={ - templatePreset?.providerType === "xai_oauth" || + presetProviderType === "xai_oauth" || initialData?.meta?.providerType === "xai_oauth" } usesOAuth={ templatePreset?.requiresOAuth === true || - templatePreset?.providerType === "github_copilot" || + presetProviderType === "github_copilot" || initialData?.meta?.providerType === "github_copilot" || baseUrl.includes("githubcopilot.com") || - templatePreset?.providerType === "codex_oauth" || + presetProviderType === "codex_oauth" || initialData?.meta?.providerType === "codex_oauth" || - templatePreset?.providerType === "xai_oauth" || + presetProviderType === "xai_oauth" || initialData?.meta?.providerType === "xai_oauth" } isCopilotAuthenticated={isCopilotAuthenticated} @@ -2265,6 +2280,13 @@ function ProviderFormFull({ {appId === "codex" && ( Chat Completions reasoning capability defaults @@ -1023,6 +1027,29 @@ requires_openai_auth = true`, icon: "xai", iconColor: "#000000", }, + { + name: "xAI (Grok) OAuth", + websiteUrl: "https://x.ai/grok", + auth: generateThirdPartyAuth(""), + // 托管 OAuth:真实 token 由本地代理按请求注入,CodexAdapter 硬定向 + // api.x.ai;这里的 base_url / 空 auth 只是配置快照,转发时不生效。 + config: generateThirdPartyConfig("xai", "https://api.x.ai/v1", "grok-4.5"), + apiFormat: "openai_responses", + providerType: "xai_oauth", + requiresOAuth: true, + modelCatalog: modelCatalog([ + { + model: "grok-4.5", + displayName: "Grok 4.5", + contextWindow: 500000, + supportsParallelToolCalls: true, + inputModalities: ["text", "image"], + }, + ]), + category: "third_party", + icon: "xai", + iconColor: "#000000", + }, { name: "Nvidia", websiteUrl: "https://build.nvidia.com", diff --git a/tests/config/xaiOauthProviderPresets.test.ts b/tests/config/xaiOauthProviderPresets.test.ts index acf66f225..664a13eee 100644 --- a/tests/config/xaiOauthProviderPresets.test.ts +++ b/tests/config/xaiOauthProviderPresets.test.ts @@ -80,4 +80,32 @@ describe("xAI OAuth provider presets", () => { }), ]); }); + + it("pins the Codex OAuth preset to managed native Responses", () => { + const preset = codexProviderPresets.find( + (entry) => entry.name === "xAI (Grok) OAuth", + ); + expect(preset).toBeDefined(); + expect(preset).toMatchObject({ + category: "third_party", + apiFormat: "openai_responses", + providerType: "xai_oauth", + requiresOAuth: true, + icon: "xai", + }); + // Managed OAuth: auth.json keeps an empty key; the forwarder injects the + // real access token per request and the adapter pins the base URL. + expect(preset!.auth).toEqual({ OPENAI_API_KEY: "" }); + expect(extractCodexBaseUrl(preset!.config)).toBe("https://api.x.ai/v1"); + expect(extractCodexWireApi(preset!.config)).toBe("responses"); + expect(extractCodexModelName(preset!.config)).toBe("grok-4.5"); + expect(preset!.modelCatalog).toEqual([ + expect.objectContaining({ + model: "grok-4.5", + contextWindow: 500000, + supportsParallelToolCalls: true, + inputModalities: ["text", "image"], + }), + ]); + }); });