mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-01 21:16:12 +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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user