mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 被改成 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CodexCatalogRow[]>(() =>
|
||||
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,7 +501,16 @@ export function CodexFormFields({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Codex API Key 输入框 */}
|
||||
{/* xAI OAuth 认证(Grok 订阅托管账号) */}
|
||||
{isXaiOauthPreset && (
|
||||
<XaiOAuthSection
|
||||
selectedAccountId={selectedXaiAccountId}
|
||||
onAccountSelect={onXaiAccountSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Codex API Key 输入框(托管 OAuth 预设无需 Key) */}
|
||||
{!isXaiOauthPreset && (
|
||||
<ApiKeySection
|
||||
id="codexApiKey"
|
||||
label="API Key"
|
||||
@@ -453,9 +530,10 @@ export function CodexFormFields({
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Codex Base URL 输入框 */}
|
||||
{shouldShowSpeedTest && (
|
||||
{/* Codex Base URL 输入框(托管 OAuth 端点由 adapter 硬定向,不展示) */}
|
||||
{shouldShowSpeedTest && !isXaiOauthPreset && (
|
||||
<EndpointField
|
||||
id="codexBaseUrl"
|
||||
label={t("codexConfig.apiUrlLabel")}
|
||||
@@ -571,8 +649,9 @@ export function CodexFormFields({
|
||||
)}
|
||||
<CollapsibleContent className="space-y-3 pt-3">
|
||||
{/* 上游格式 —— Chat 需开启路由接管(走代理转换),Responses 原生直连。
|
||||
沿用 shouldShowSpeedTest 门控,cloud_provider 保持不可切换。 */}
|
||||
{shouldShowSpeedTest && (
|
||||
沿用 shouldShowSpeedTest 门控,cloud_provider 保持不可切换;
|
||||
xAI OAuth 托管预设格式钉死 Responses,不可切换。 */}
|
||||
{shouldShowSpeedTest && !isXaiOauthPreset && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<FormLabel htmlFor="codex-upstream-format">
|
||||
|
||||
@@ -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" && (
|
||||
<CodexFormFields
|
||||
providerId={providerId}
|
||||
isXaiOauthPreset={
|
||||
presetProviderType === "xai_oauth" ||
|
||||
initialData?.meta?.providerType === "xai_oauth"
|
||||
}
|
||||
isXaiOauthAuthenticated={isXaiOauthAuthenticated}
|
||||
selectedXaiAccountId={selectedXaiAccountId}
|
||||
onXaiAccountSelect={setSelectedXaiAccountId}
|
||||
codexApiKey={codexApiKey}
|
||||
onApiKeyChange={handleCodexApiKeyChange}
|
||||
category={category}
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface CodexProviderPreset {
|
||||
iconColor?: string; // 图标颜色
|
||||
// Codex API 格式
|
||||
apiFormat?: CodexApiFormat;
|
||||
// 托管账号预设:目前仅 xAI OAuth(Grok 订阅经本地代理注入 token 直连 api.x.ai)
|
||||
providerType?: "xai_oauth";
|
||||
// OAuth 预设:隐藏 API Key 输入,保存前要求已登录托管账号
|
||||
requiresOAuth?: boolean;
|
||||
// Codex Chat 本地路由模式下的模型目录
|
||||
modelCatalog?: CodexCatalogModel[];
|
||||
// Codex Responses -> 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",
|
||||
|
||||
@@ -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"],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user