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
+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,