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
+99
View File
@@ -206,6 +206,19 @@ pub fn should_convert_codex_responses_to_anthropic(provider: &Provider, endpoint
) && codex_provider_uses_anthropic(provider)
}
/// Whether a native-Responses Codex upstream needs Codex `namespace`/plugin
/// tool declarations flattened before forwarding.
///
/// Codex 0.142+ emits ChatGPT-backend-private `{"type":"namespace",…}` tool
/// shapes that strict third-party Responses gateways reject with
/// `422 unknown variant "namespace"`. Only providers whose upstream is such a
/// strict native gateway need the flatten+restore pass; the Chat/Anthropic
/// transform paths already unwrap namespaces on their own. Currently that is the
/// managed xAI (Grok) OAuth provider — the first strict gateway cc-switch hit.
pub fn provider_needs_responses_namespace_flatten(provider: &Provider) -> bool {
provider.is_xai_oauth()
}
/// The single built-in official Codex provider. Unlike managed Codex OAuth
/// providers used by Claude, this route receives authentication from the
/// calling Codex client (`requires_openai_auth = true`).
@@ -228,6 +241,11 @@ pub fn resolve_codex_catalog_tool_profile(
if is_codex_official_provider(provider) {
return CodexCatalogToolProfile::NativeResponses;
}
// xAI OAuth pins the native Responses profile regardless of editable
// api_format, mirroring the Claude-side managed-provider invariant.
if provider.is_xai_oauth() {
return CodexCatalogToolProfile::NativeResponses;
}
if codex_provider_uses_anthropic(provider) {
return CodexCatalogToolProfile::Anthropic;
}
@@ -656,6 +674,12 @@ impl ProviderAdapter for CodexAdapter {
return Ok(super::CHATGPT_CODEX_BASE_URL.to_string());
}
// xAI OAuth: ignore editable provider base URLs and always use the xAI
// API origin associated with the managed token.
if provider.is_xai_oauth() {
return Ok(super::XAI_API_BASE_URL.to_string());
}
// 1. 尝试直接获取 base_url 字段
if let Some(url) = provider
.settings_config
@@ -706,6 +730,15 @@ impl ProviderAdapter for CodexAdapter {
}
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
// xAI OAuth (Grok subscription): placeholder credentials only; the real
// access_token is resolved per-request by the forwarder via XaiOAuthManager.
if provider.is_xai_oauth() {
return Some(AuthInfo::new(
"xai_oauth_placeholder".to_string(),
AuthStrategy::XaiOAuth,
));
}
// Anthropic upstream: the auth field is chosen by the user in the UI (meta.apiKeyField).
// ANTHROPIC_API_KEY → x-api-key (AuthStrategy::Anthropic)
// ANTHROPIC_AUTH_TOKEN → Authorization: Bearer (default, AuthStrategy::Bearer)
@@ -1509,4 +1542,70 @@ wire_api = "chat"
assert_eq!(config.supports_effort, Some(false));
assert_eq!(config.output_format.as_deref(), Some("reasoning_content"));
}
#[test]
fn xai_oauth_invariants_ignore_editable_base_url_and_auth() {
let adapter = CodexAdapter::new();
let mut provider = create_provider(json!({
"auth": { "OPENAI_API_KEY": "user-edited" },
"config": r#"
model = "grok-4.5"
[model_providers.custom]
name = "xai"
base_url = "https://attacker.example/v1"
wire_api = "responses"
"#
}));
provider.meta = Some(crate::provider::ProviderMeta {
provider_type: Some("xai_oauth".to_string()),
..Default::default()
});
// 可编辑字段(base_url / auth key)不得影响托管路由:
// 端点硬定向 api.x.ai,凭据是占位符(真 token 由 forwarder 注入)。
assert_eq!(
adapter.extract_base_url(&provider).unwrap(),
super::super::XAI_API_BASE_URL
);
let auth = adapter
.extract_auth(&provider)
.expect("managed auth placeholder");
assert_eq!(auth.api_key, "xai_oauth_placeholder");
assert_eq!(auth.strategy, AuthStrategy::XaiOAuth);
}
#[test]
fn xai_oauth_pins_native_responses_catalog_profile() {
let mut provider = create_provider(json!({ "auth": {}, "config": "" }));
provider.meta = Some(crate::provider::ProviderMeta {
provider_type: Some("xai_oauth".to_string()),
// 即使 api_format 被改成 anthropiccatalog 画像也必须钉死原生 Responses
api_format: Some("anthropic".to_string()),
..Default::default()
});
assert!(matches!(
resolve_codex_catalog_tool_profile(&provider),
crate::codex_config::CodexCatalogToolProfile::NativeResponses
));
}
#[test]
fn namespace_flatten_gate_only_fires_for_xai_oauth() {
// xAI OAuth: strict native gateway → needs namespace flattening.
let mut xai = create_provider(json!({ "auth": {}, "config": "" }));
xai.meta = Some(crate::provider::ProviderMeta {
provider_type: Some("xai_oauth".to_string()),
..Default::default()
});
assert!(provider_needs_responses_namespace_flatten(&xai));
// A plain third-party API-key Codex provider must not be flattened.
let plain = create_provider(json!({
"auth": { "OPENAI_API_KEY": "sk-x" },
"config": "base_url = \"https://api.x.ai/v1\"\nwire_api = \"responses\""
}));
assert!(!provider_needs_responses_namespace_flatten(&plain));
}
}