feat(xai): add Grok OAuth device-flow backend and proxy routing

Add an xAI OAuth manager using the OAuth 2.0 Device Authorization Grant
with endpoints resolved from xAI's OIDC discovery document. All HTTP goes
through the app-managed proxy client.

- Managed provider kind xai_oauth: forced openai_responses wire format,
  pinned api.x.ai base URL, bearer injection gated to the xAI origin,
  tokens registered for log redaction, single-auth-key takeover policy.
- Token cache cannot bypass account state: cache hits re-validate account
  usability, refresh commits run under the mutation lock with a
  refresh-token CAS check, and pending logins are re-checked before an
  account is persisted.
- Refresh classification: 401/403 with any body and 400 with a non-JSON
  body mark the account for re-auth; 429/5xx stay transient.
- Shared auth_* commands dispatch to xAI with guard types mirroring the
  Copilot/Codex branches.
This commit is contained in:
Jason
2026-07-19 00:20:02 +08:00
parent c4795e98ff
commit a35209a6e7
14 changed files with 1803 additions and 15 deletions
+55 -3
View File
@@ -22,9 +22,10 @@ use super::{
types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig},
ProxyError,
};
use crate::commands::{CodexOAuthState, CopilotAuthState};
use crate::commands::{CodexOAuthState, CopilotAuthState, XaiOAuthState};
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
use crate::proxy::providers::xai_oauth_auth::XaiOAuthManager;
use crate::{
app_config::AppType,
provider::{LocalProxyRequestOverrides, Provider},
@@ -1129,7 +1130,9 @@ impl RequestForwarder {
.meta
.as_ref()
.and_then(|meta| meta.is_full_url)
.unwrap_or(false);
.unwrap_or(false)
&& !provider.is_codex_oauth()
&& !provider.is_xai_oauth();
// GitHub Copilot API 使用 /chat/completions(无 /v1 前缀)
let is_copilot = provider
@@ -1671,6 +1674,44 @@ impl RequestForwarder {
}
}
// xAI OAuth: resolve a managed account token immediately before
// sending the request. Invalid refresh credentials are persisted as
// requiring re-authentication by the manager.
if auth.strategy == AuthStrategy::XaiOAuth {
if let Some(app_handle) = &self.app_handle {
let xai_state = app_handle.state::<XaiOAuthState>();
let xai_auth: tokio::sync::RwLockReadGuard<'_, XaiOAuthManager> =
xai_state.0.read().await;
let account_id = provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("xai_oauth"));
let token_result = match &account_id {
Some(id) => xai_auth.get_valid_token_for_account(id).await,
None => xai_auth.get_valid_token().await,
};
match token_result {
Ok(token) => {
auth = AuthInfo::new(token, AuthStrategy::XaiOAuth);
log::debug!(
"[XaiOAuth] 成功获取 access_token (account={})",
account_id.as_deref().unwrap_or("default")
);
}
Err(error) => {
log::error!("[XaiOAuth] 获取 access_token 失败: {error}");
return Err(ProxyError::AuthError(format!(
"xAI OAuth 认证失败: {error}"
)));
}
}
} else {
return Err(ProxyError::AuthError(
"xAI OAuth 认证不可用(无 AppHandle".to_string(),
));
}
}
for secret in std::iter::once(&auth.api_key).chain(auth.access_token.iter()) {
if !secret.is_empty() && !log_secrets.contains(secret) {
log_secrets.push(secret.clone());
@@ -3143,6 +3184,7 @@ fn is_managed_account_upstream_url(url: &str) -> bool {
host == "githubcopilot.com"
|| host.ends_with(".githubcopilot.com")
|| (host == "chatgpt.com" && uri.path().starts_with("/backend-api/codex"))
|| (host == "api.x.ai" && uri.path().starts_with("/v1/"))
}
fn headers_contain_proxy_placeholder(headers: &http::HeaderMap) -> bool {
@@ -3164,7 +3206,7 @@ fn should_preserve_exact_header_case(
return false;
}
if is_copilot || provider.is_codex_oauth() {
if is_copilot || provider.is_codex_oauth() || provider.is_xai_oauth() {
return false;
}
@@ -3904,6 +3946,16 @@ mod tests {
err,
ProxyError::AuthError(message) if message.contains("PROXY_MANAGED")
));
let xai_err = reject_proxy_placeholder_for_managed_account_upstream(
"https://api.x.ai/v1/responses",
&headers,
)
.expect_err("xAI placeholder should be rejected before upstream");
assert!(matches!(
xai_err,
ProxyError::AuthError(message) if message.contains("PROXY_MANAGED")
));
}
#[test]
+8
View File
@@ -128,6 +128,13 @@ pub enum AuthStrategy {
///
/// 使用动态获取的 OpenAI access_token(通过 Device Code 流程获取)
CodexOAuth,
/// xAI OAuthGrok API
///
/// - Header: `Authorization: Bearer <access_token>`
///
/// access token 由 xAI Device Code 流程获取并由 forwarder 动态注入。
XaiOAuth,
}
#[cfg(test)]
@@ -170,6 +177,7 @@ mod tests {
assert_eq!(AuthStrategy::Anthropic, AuthStrategy::Anthropic);
assert_ne!(AuthStrategy::Anthropic, AuthStrategy::Bearer);
assert_ne!(AuthStrategy::Bearer, AuthStrategy::Google);
assert_ne!(AuthStrategy::CodexOAuth, AuthStrategy::XaiOAuth);
}
#[test]
+123 -4
View File
@@ -36,9 +36,14 @@ const CODEX_OAUTH_CLIENT_VERSION: &str = "0.144.1";
/// 供 handler/forwarder 外部使用的公开函数。
/// 优先级:meta.apiFormat > settings_config.api_format > openrouter_compat_mode > 默认 "anthropic"
pub fn get_claude_api_format(provider: &Provider) -> &'static str {
// 0) Codex OAuth 强制使用 openai_responses(不可被覆盖)
// 0) Managed Responses OAuth providers force their wire protocol. This is
// an invariant, not a preset default: editable metadata must not be able to
// send an Anthropic Messages body to a Responses-only upstream.
if let Some(meta) = provider.meta.as_ref() {
if meta.provider_type.as_deref() == Some("codex_oauth") {
if matches!(
meta.provider_type.as_deref(),
Some("codex_oauth" | "xai_oauth")
) {
return "openai_responses";
}
}
@@ -400,12 +405,28 @@ pub fn transform_claude_request_for_api_format(
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false
// + include: ["reasoning.encrypted_content"],由 transform 层统一处理。
let codex_fast_mode = provider.codex_fast_mode_enabled();
super::transform_responses::anthropic_to_responses(
let mut result = super::transform_responses::anthropic_to_responses(
body,
cache_key,
is_codex_oauth,
codex_fast_mode,
)
)?;
if provider.is_xai_oauth() {
const REASONING_MARKER: &str = "reasoning.encrypted_content";
let mut include = result
.get("include")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if !include
.iter()
.any(|item| item.as_str() == Some(REASONING_MARKER))
{
include.push(json!(REASONING_MARKER));
}
result["include"] = json!(include);
}
Ok(result)
}
"openai_chat" => {
let preserve_reasoning_content =
@@ -451,6 +472,7 @@ impl ClaudeAdapter {
/// 根据 base_url 和 auth_mode 检测具体的供应商类型:
/// - GitHubCopilot: meta.provider_type 为 github_copilot 或 base_url 包含 githubcopilot.com
/// - CodexOAuth: meta.provider_type 为 codex_oauth
/// - XaiOAuth: meta.provider_type 为 xai_oauth
/// - OpenRouter: base_url 包含 openrouter.ai
/// - ClaudeAuth: auth_mode 为 bearer_only
/// - Claude: 默认 Anthropic 官方
@@ -470,6 +492,10 @@ impl ClaudeAdapter {
return ProviderType::CodexOAuth;
}
if self.is_xai_oauth(provider) {
return ProviderType::XaiOAuth;
}
// 检测 GitHub Copilot
if self.is_github_copilot(provider) {
return ProviderType::GitHubCopilot;
@@ -498,6 +524,10 @@ impl ClaudeAdapter {
false
}
fn is_xai_oauth(&self, provider: &Provider) -> bool {
provider.is_xai_oauth()
}
/// 检测是否为 GitHub Copilot 供应商
fn is_github_copilot(&self, provider: &Provider) -> bool {
// 方式1: 检查 meta.provider_type
@@ -676,6 +706,12 @@ impl ProviderAdapter for ClaudeAdapter {
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 self.is_xai_oauth(provider) {
return Ok(super::XAI_API_BASE_URL.to_string());
}
// 1. 从 env 中获取
if let Some(env) = provider.settings_config.get("env") {
if let Some(url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
@@ -735,6 +771,13 @@ impl ProviderAdapter for ClaudeAdapter {
));
}
if provider_type == ProviderType::XaiOAuth {
return Some(AuthInfo::new(
"xai_oauth_placeholder".to_string(),
AuthStrategy::XaiOAuth,
));
}
let key = self.extract_key(provider)?;
match provider_type {
@@ -790,6 +833,17 @@ impl ProviderAdapter for ClaudeAdapter {
return format!("{}/responses", super::CHATGPT_CODEX_BASE_URL);
}
// Defense in depth for callers that bypass endpoint rewriting.
if base_url == super::XAI_API_BASE_URL {
let query = endpoint.split_once('?').map(|(_, query)| query);
return match query {
Some(query) if !query.is_empty() => {
format!("{}/responses?{query}", super::XAI_API_BASE_URL)
}
_ => format!("{}/responses", super::XAI_API_BASE_URL),
};
}
// NOTE:
// 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages`
// 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。
@@ -858,6 +912,9 @@ impl ProviderAdapter for ClaudeAdapter {
),
]
}
AuthStrategy::XaiOAuth => {
vec![(HeaderName::from_static("authorization"), hv(&bearer)?)]
}
AuthStrategy::GitHubCopilot => {
// 生成请求追踪 ID
let request_id = uuid::Uuid::new_v4().to_string();
@@ -919,6 +976,10 @@ impl ProviderAdapter for ClaudeAdapter {
return true;
}
if self.is_xai_oauth(provider) {
return true;
}
// 根据 api_format 配置决定是否需要格式转换
// - "anthropic" (默认): 直接透传,无需转换
// - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换
@@ -1376,6 +1437,64 @@ mod tests {
assert_eq!(url, "https://api.anthropic.com/v1/messages");
}
#[test]
fn xai_oauth_invariants_ignore_editable_format_and_base_url() {
let adapter = ClaudeAdapter::new();
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://attacker.example/anthropic",
"ANTHROPIC_API_KEY": "user-edited"
}
}),
ProviderMeta {
provider_type: Some("xai_oauth".to_string()),
api_format: Some("anthropic".to_string()),
is_full_url: Some(true),
..Default::default()
},
);
assert_eq!(get_claude_api_format(&provider), "openai_responses");
assert_eq!(adapter.provider_type(&provider), ProviderType::XaiOAuth);
assert_eq!(
adapter.extract_base_url(&provider).unwrap(),
super::super::XAI_API_BASE_URL
);
assert!(adapter.needs_transform(&provider));
assert_eq!(
adapter
.extract_auth(&provider)
.expect("managed auth placeholder")
.strategy,
AuthStrategy::XaiOAuth
);
assert_eq!(
adapter.build_url(super::super::XAI_API_BASE_URL, "/v1/messages?beta=1"),
"https://api.x.ai/v1/responses?beta=1"
);
let transformed = transform_claude_request_for_api_format(
json!({
"model": "grok-4.5",
"max_tokens": 2048,
"thinking": { "type": "enabled", "budget_tokens": 20000 },
"messages": [{ "role": "user", "content": "hello" }]
}),
&provider,
"openai_responses",
None,
None,
)
.unwrap();
assert_eq!(transformed["reasoning"]["effort"], json!("high"));
assert_eq!(
transformed["include"],
json!(["reasoning.encrypted_content"])
);
assert!(transformed.get("store").is_none());
}
#[test]
fn test_build_url_openrouter() {
let adapter = ClaudeAdapter::new();
+18 -1
View File
@@ -36,12 +36,14 @@ pub mod transform_codex_anthropic;
pub mod transform_codex_chat;
pub mod transform_gemini;
pub mod transform_responses;
pub mod xai_oauth_auth;
use crate::app_config::AppType;
use crate::provider::Provider;
use serde::{Deserialize, Serialize};
pub const CHATGPT_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
pub const XAI_API_BASE_URL: &str = "https://api.x.ai/v1";
// 公开导出
pub use adapter::ProviderAdapter;
@@ -83,6 +85,8 @@ pub enum ProviderType {
GitHubCopilot,
/// OpenAI Codex (ChatGPT Plus/Pro OAuth,需要 Anthropic ↔ Responses API 转换)
CodexOAuth,
/// xAI Grok OAuth(需要 Anthropic ↔ Responses API 转换)
XaiOAuth,
}
impl ProviderType {
@@ -96,6 +100,7 @@ impl ProviderType {
match self {
ProviderType::GitHubCopilot => true,
ProviderType::CodexOAuth => true,
ProviderType::XaiOAuth => true,
ProviderType::OpenRouter => false,
_ => false,
}
@@ -113,6 +118,7 @@ impl ProviderType {
ProviderType::OpenRouter => "https://openrouter.ai/api",
ProviderType::GitHubCopilot => "https://api.githubcopilot.com",
ProviderType::CodexOAuth => CHATGPT_CODEX_BASE_URL,
ProviderType::XaiOAuth => XAI_API_BASE_URL,
}
}
@@ -139,6 +145,9 @@ impl ProviderType {
if meta.provider_type.as_deref() == Some("codex_oauth") {
return ProviderType::CodexOAuth;
}
if meta.provider_type.as_deref() == Some("xai_oauth") {
return ProviderType::XaiOAuth;
}
}
// 检测 base_url 是否为 GitHub Copilot
@@ -208,6 +217,7 @@ impl ProviderType {
ProviderType::OpenRouter => "openrouter",
ProviderType::GitHubCopilot => "github_copilot",
ProviderType::CodexOAuth => "codex_oauth",
ProviderType::XaiOAuth => "xai_oauth",
}
}
}
@@ -233,6 +243,7 @@ impl std::str::FromStr for ProviderType {
Ok(ProviderType::GitHubCopilot)
}
"codex_oauth" | "codex-oauth" | "codexoauth" => Ok(ProviderType::CodexOAuth),
"xai_oauth" | "xai-oauth" | "xaioauth" => Ok(ProviderType::XaiOAuth),
_ => Err(format!("Invalid provider type: {s}")),
}
}
@@ -257,7 +268,8 @@ pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn Pr
| ProviderType::ClaudeAuth
| ProviderType::OpenRouter
| ProviderType::GitHubCopilot
| ProviderType::CodexOAuth => Box::new(ClaudeAdapter::new()),
| ProviderType::CodexOAuth
| ProviderType::XaiOAuth => Box::new(ClaudeAdapter::new()),
ProviderType::Codex => Box::new(CodexAdapter::new()),
ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()),
}
@@ -374,6 +386,10 @@ mod tests {
"githubcopilot".parse::<ProviderType>().unwrap(),
ProviderType::GitHubCopilot
);
assert_eq!(
"xai_oauth".parse::<ProviderType>().unwrap(),
ProviderType::XaiOAuth
);
assert!("invalid".parse::<ProviderType>().is_err());
}
@@ -386,6 +402,7 @@ mod tests {
assert_eq!(ProviderType::GeminiCli.as_str(), "gemini_cli");
assert_eq!(ProviderType::OpenRouter.as_str(), "openrouter");
assert_eq!(ProviderType::GitHubCopilot.as_str(), "github_copilot");
assert_eq!(ProviderType::XaiOAuth.as_str(), "xai_oauth");
}
#[test]
+11 -4
View File
@@ -54,18 +54,23 @@ pub fn is_openai_o_series(model: &str) -> bool {
&& model.as_bytes().get(1).is_some_and(|b| b.is_ascii_digit())
}
/// Detect OpenAI models that support reasoning_effort.
/// Detect Responses-compatible models that support reasoning effort.
///
/// Supported families:
/// - o-series: o1, o3, o4-mini, etc.
/// - GPT-5+: gpt-5, gpt-5.1, gpt-5.4, gpt-5-codex, etc.
/// - xAI Grok Build models. `grok-4.5` is the current documented Grok Build
/// model; retain the previous `grok-build-*` family for saved providers.
pub fn supports_reasoning_effort(model: &str) -> bool {
is_openai_o_series(model)
|| model
.to_lowercase()
let normalized = model.to_lowercase();
is_openai_o_series(&normalized)
|| normalized
.strip_prefix("gpt-")
.and_then(|rest| rest.chars().next())
.is_some_and(|c| c.is_ascii_digit() && c >= '5')
|| normalized == "grok-4.5"
|| normalized.starts_with("grok-4.5-")
|| normalized.starts_with("grok-build-")
}
/// Resolve the appropriate OpenAI `reasoning_effort` from an Anthropic request body.
@@ -1566,6 +1571,8 @@ mod tests {
assert!(supports_reasoning_effort("gpt-5"));
assert!(supports_reasoning_effort("gpt-5.4"));
assert!(supports_reasoning_effort("gpt-5-codex"));
assert!(supports_reasoning_effort("grok-4.5"));
assert!(supports_reasoning_effort("grok-build-0.1"));
assert!(!supports_reasoning_effort("gpt-4o"));
assert!(!supports_reasoning_effort("claude-sonnet-4-6"));
}
File diff suppressed because it is too large Load Diff