feat(codex): xAI (Grok) OAuth managed provider with native Responses compat

Add a managed "xAI (Grok) OAuth" Codex provider that routes through the
local proxy to api.x.ai via the shared Grok CLI OAuth identity, plus the
native-Responses compatibility layer that makes Codex 0.142+ traffic work
against xAI's strict upstream serde parser.

Provider:
- codex.rs: recognize the xai_oauth placeholder in extract_auth, hard-pin
  the base URL to api.x.ai and the tool profile to native Responses
- forwarder.rs: treat xAI OAuth auth failures as non-retryable
- presets + ProviderForm/CodexFormFields: managed OAuth preset that hides
  the api key/endpoint fields and derives the provider type across apps

Native Responses compatibility (gated on is_xai_oauth, so no other
provider is affected):
- transform_codex_responses_namespace: flatten Codex's private
  namespace/plugin tool declarations into top-level function tools on the
  request; restore the flat function_call names back to {name, namespace}
  on the response (streaming and non-streaming) so the client matches its
  own namespaced tool registry
- transform_codex_responses_xai_sanitize: strip the OpenAI-backend-private
  fields xAI rejects (external_web_access, prompt_cache_retention,
  safety_identifier, the additional_tools carrier, tool_search, ...) with
  deterministic removals that keep the prompt-cache prefix stable
- wire both into the native passthrough after the request transform;
  response restore runs in a dedicated handler so the generic passthrough
  hot path is untouched

Ports the proven approach of sub2api's Grok Responses gateway. Verified
with a 4-round codex -> xAI OAuth workload: all tasks green, zero upstream
errors.
This commit is contained in:
Jason
2026-07-20 23:50:24 +08:00
parent 8dcedbc062
commit dbb5bd1537
12 changed files with 1734 additions and 54 deletions
+76
View File
@@ -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();
+189 -5
View File
@@ -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<ActiveConnectionGuard>,
restore_map: std::collections::HashMap<
String,
transform_codex_responses_namespace::NamespacedName,
>,
) -> Result<axum::response::Response, ProxyError> {
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::<Value>(&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,
+99
View File
@@ -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<AuthInfo> {
// 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 被改成 anthropiccatalog 画像也必须钉死原生 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));
}
}
+5 -2
View File
@@ -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;
@@ -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;
@@ -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 `<namespace>__<child>` (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<String, NamespacedName> {
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<bool, ProxyError> {
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<String, NamespacedName> = 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<Value> = 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<String, NamespacedName>,
) -> 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<String, NamespacedName>,
) -> bool {
if map.is_empty() {
return false;
}
restore_value(event, map)
}
fn namespace_children(tool: &Value) -> Vec<Value> {
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<String, NamespacedName>) {
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<String, NamespacedName>,
) -> 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<String, NamespacedName>) -> 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<E>(
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
map: HashMap<String, NamespacedName>,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send
where
E: std::error::Error + Send + 'static,
{
async_stream::stream! {
let mut buffer = String::new();
let mut utf8_remainder: Vec<u8> = 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<String, NamespacedName>) -> 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, std::io::Error>(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]"));
}
}
@@ -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<Value> = 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<Value> = Vec::new();
let mut seen: HashSet<String> = 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<Value> = 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<Value> = 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));
}
}
+1 -1
View File
@@ -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,