From a35209a6e7410faf9f33edaec769783ddc2db5b2 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 19 Jul 2026 00:20:02 +0800 Subject: [PATCH] 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. --- src-tauri/src/claude_desktop_config.rs | 10 +- src-tauri/src/commands/auth.rs | 93 ++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/provider.rs | 2 +- src-tauri/src/commands/xai_oauth.rs | 72 + src-tauri/src/lib.rs | 13 + src-tauri/src/provider.rs | 5 + src-tauri/src/proxy/forwarder.rs | 58 +- src-tauri/src/proxy/providers/auth.rs | 8 + src-tauri/src/proxy/providers/claude.rs | 127 +- src-tauri/src/proxy/providers/mod.rs | 19 +- src-tauri/src/proxy/providers/transform.rs | 15 +- .../src/proxy/providers/xai_oauth_auth.rs | 1355 +++++++++++++++++ src-tauri/src/services/proxy.rs | 39 + 14 files changed, 1803 insertions(+), 15 deletions(-) create mode 100644 src-tauri/src/commands/xai_oauth.rs create mode 100644 src-tauri/src/proxy/providers/xai_oauth_auth.rs diff --git a/src-tauri/src/claude_desktop_config.rs b/src-tauri/src/claude_desktop_config.rs index 9e3edf8b4..5388911db 100644 --- a/src-tauri/src/claude_desktop_config.rs +++ b/src-tauri/src/claude_desktop_config.rs @@ -371,7 +371,7 @@ pub fn validate_direct_provider(provider: &Provider) -> Result<(), AppError> { if matches!( meta.provider_type.as_deref(), - Some("github_copilot") | Some("codex_oauth") + Some("github_copilot") | Some("codex_oauth") | Some("xai_oauth") ) { return Err(AppError::localized( "claude_desktop.provider.type_unsupported", @@ -476,7 +476,12 @@ fn is_managed_oauth_proxy_provider(provider: &Provider) -> bool { .meta .as_ref() .and_then(|meta| meta.provider_type.as_deref()) - .is_some_and(|provider_type| matches!(provider_type, "github_copilot" | "codex_oauth")) + .is_some_and(|provider_type| { + matches!( + provider_type, + "github_copilot" | "codex_oauth" | "xai_oauth" + ) + }) } pub fn validate_provider(provider: &Provider) -> Result<(), AppError> { @@ -1583,6 +1588,7 @@ mod tests { for (provider_type, api_format) in [ ("github_copilot", "openai_chat"), ("codex_oauth", "openai_responses"), + ("xai_oauth", "openai_responses"), ] { let provider = oauth_proxy_provider(provider_type, provider_type, api_format); validate_proxy_provider(&provider).expect("oauth proxy provider should validate"); diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index c3036023a..9c121921d 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -2,13 +2,16 @@ use tauri::State; use crate::commands::codex_oauth::CodexOAuthState; use crate::commands::copilot::CopilotAuthState; +use crate::commands::xai_oauth::XaiOAuthState; use crate::proxy::providers::codex_oauth_auth::CodexOAuthError; use crate::proxy::providers::copilot_auth::{ CopilotAuthError, GitHubAccount, GitHubDeviceCodeResponse, }; +use crate::proxy::providers::xai_oauth_auth::{XaiOAuthAccount, XaiOAuthError}; const AUTH_PROVIDER_GITHUB_COPILOT: &str = "github_copilot"; const AUTH_PROVIDER_CODEX_OAUTH: &str = "codex_oauth"; +const AUTH_PROVIDER_XAI_OAUTH: &str = "xai_oauth"; #[derive(Debug, Clone, serde::Serialize)] pub struct ManagedAuthAccount { @@ -19,6 +22,7 @@ pub struct ManagedAuthAccount { pub authenticated_at: i64, pub is_default: bool, pub github_domain: String, + pub requires_reauth: bool, } #[derive(Debug, Clone, serde::Serialize)] @@ -44,6 +48,7 @@ fn ensure_auth_provider(auth_provider: &str) -> Result<&'static str, String> { match auth_provider { AUTH_PROVIDER_GITHUB_COPILOT => Ok(AUTH_PROVIDER_GITHUB_COPILOT), AUTH_PROVIDER_CODEX_OAUTH => Ok(AUTH_PROVIDER_CODEX_OAUTH), + AUTH_PROVIDER_XAI_OAUTH => Ok(AUTH_PROVIDER_XAI_OAUTH), _ => Err(format!("Unsupported auth provider: {auth_provider}")), } } @@ -61,6 +66,23 @@ fn map_account( avatar_url: account.avatar_url, authenticated_at: account.authenticated_at, github_domain: account.github_domain, + requires_reauth: false, + } +} + +fn map_xai_account( + account: XaiOAuthAccount, + default_account_id: Option<&str>, +) -> ManagedAuthAccount { + ManagedAuthAccount { + is_default: default_account_id == Some(account.id.as_str()), + id: account.id, + provider: AUTH_PROVIDER_XAI_OAUTH.to_string(), + login: account.login, + avatar_url: account.avatar_url, + authenticated_at: account.authenticated_at, + github_domain: account.github_domain, + requires_reauth: account.requires_reauth, } } @@ -84,6 +106,7 @@ pub async fn auth_start_login( github_domain: Option, copilot_state: State<'_, CopilotAuthState>, codex_state: State<'_, CodexOAuthState>, + xai_state: State<'_, XaiOAuthState>, ) -> Result { let auth_provider = ensure_auth_provider(&auth_provider)?; match auth_provider { @@ -103,6 +126,14 @@ pub async fn auth_start_login( .map_err(|e| e.to_string())?; Ok(map_device_code_response(auth_provider, response)) } + AUTH_PROVIDER_XAI_OAUTH => { + let auth_manager = xai_state.0.read().await; + let response = auth_manager + .start_device_flow() + .await + .map_err(|e| e.to_string())?; + Ok(map_device_code_response(auth_provider, response)) + } _ => unreachable!(), } } @@ -114,6 +145,7 @@ pub async fn auth_poll_for_account( github_domain: Option, copilot_state: State<'_, CopilotAuthState>, codex_state: State<'_, CodexOAuthState>, + xai_state: State<'_, XaiOAuthState>, ) -> Result, String> { let auth_provider = ensure_auth_provider(&auth_provider)?; match auth_provider { @@ -146,6 +178,18 @@ pub async fn auth_poll_for_account( Err(e) => Err(e.to_string()), } } + AUTH_PROVIDER_XAI_OAUTH => { + let auth_manager = xai_state.0.write().await; + match auth_manager.poll_for_token(&device_code).await { + Ok(account) => { + let default_account_id = auth_manager.get_status().await.default_account_id; + Ok(account + .map(|account| map_xai_account(account, default_account_id.as_deref()))) + } + Err(XaiOAuthError::AuthorizationPending) => Ok(None), + Err(e) => Err(e.to_string()), + } + } _ => unreachable!(), } } @@ -155,6 +199,7 @@ pub async fn auth_list_accounts( auth_provider: String, copilot_state: State<'_, CopilotAuthState>, codex_state: State<'_, CodexOAuthState>, + xai_state: State<'_, XaiOAuthState>, ) -> Result, String> { let auth_provider = ensure_auth_provider(&auth_provider)?; match auth_provider { @@ -178,6 +223,16 @@ pub async fn auth_list_accounts( .map(|account| map_account(auth_provider, account, default_account_id.as_deref())) .collect()) } + AUTH_PROVIDER_XAI_OAUTH => { + let auth_manager = xai_state.0.read().await; + let status = auth_manager.get_status().await; + let default_account_id = status.default_account_id.clone(); + Ok(status + .accounts + .into_iter() + .map(|account| map_xai_account(account, default_account_id.as_deref())) + .collect()) + } _ => unreachable!(), } } @@ -187,6 +242,7 @@ pub async fn auth_get_status( auth_provider: String, copilot_state: State<'_, CopilotAuthState>, codex_state: State<'_, CodexOAuthState>, + xai_state: State<'_, XaiOAuthState>, ) -> Result { let auth_provider = ensure_auth_provider(&auth_provider)?; match auth_provider { @@ -226,6 +282,22 @@ pub async fn auth_get_status( .collect(), }) } + AUTH_PROVIDER_XAI_OAUTH => { + let auth_manager = xai_state.0.read().await; + let status = auth_manager.get_status().await; + let default_account_id = status.default_account_id.clone(); + Ok(ManagedAuthStatus { + provider: auth_provider.to_string(), + authenticated: status.authenticated, + default_account_id: default_account_id.clone(), + migration_error: None, + accounts: status + .accounts + .into_iter() + .map(|account| map_xai_account(account, default_account_id.as_deref())) + .collect(), + }) + } _ => unreachable!(), } } @@ -236,6 +308,7 @@ pub async fn auth_remove_account( account_id: String, copilot_state: State<'_, CopilotAuthState>, codex_state: State<'_, CodexOAuthState>, + xai_state: State<'_, XaiOAuthState>, ) -> Result<(), String> { let auth_provider = ensure_auth_provider(&auth_provider)?; match auth_provider { @@ -253,6 +326,13 @@ pub async fn auth_remove_account( .await .map_err(|e| e.to_string()) } + AUTH_PROVIDER_XAI_OAUTH => { + let auth_manager = xai_state.0.write().await; + auth_manager + .remove_account(&account_id) + .await + .map_err(|e| e.to_string()) + } _ => unreachable!(), } } @@ -263,6 +343,7 @@ pub async fn auth_set_default_account( account_id: String, copilot_state: State<'_, CopilotAuthState>, codex_state: State<'_, CodexOAuthState>, + xai_state: State<'_, XaiOAuthState>, ) -> Result<(), String> { let auth_provider = ensure_auth_provider(&auth_provider)?; match auth_provider { @@ -280,6 +361,13 @@ pub async fn auth_set_default_account( .await .map_err(|e| e.to_string()) } + AUTH_PROVIDER_XAI_OAUTH => { + let auth_manager = xai_state.0.write().await; + auth_manager + .set_default_account(&account_id) + .await + .map_err(|e| e.to_string()) + } _ => unreachable!(), } } @@ -289,6 +377,7 @@ pub async fn auth_logout( auth_provider: String, copilot_state: State<'_, CopilotAuthState>, codex_state: State<'_, CodexOAuthState>, + xai_state: State<'_, XaiOAuthState>, ) -> Result<(), String> { let auth_provider = ensure_auth_provider(&auth_provider)?; match auth_provider { @@ -300,6 +389,10 @@ pub async fn auth_logout( let auth_manager = codex_state.0.write().await; auth_manager.clear_auth().await.map_err(|e| e.to_string()) } + AUTH_PROVIDER_XAI_OAUTH => { + let auth_manager = xai_state.0.write().await; + auth_manager.clear_auth().await.map_err(|e| e.to_string()) + } _ => unreachable!(), } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 29a26710e..08e270919 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -28,6 +28,7 @@ pub mod skill; mod stream_check; mod subscription; mod sync_support; +mod xai_oauth; mod lightweight; mod s3_sync; @@ -62,6 +63,7 @@ pub use settings::*; pub use skill::*; pub use stream_check::*; pub use subscription::*; +pub use xai_oauth::*; pub use lightweight::*; pub use s3_sync::*; diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 993ee80d2..4279a4984 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -281,7 +281,7 @@ pub(crate) fn suggested_claude_desktop_routes( .meta .as_ref() .and_then(|meta| meta.provider_type.as_deref()), - Some("github_copilot") | Some("codex_oauth") + Some("github_copilot") | Some("codex_oauth") | Some("xai_oauth") ); fn add_route( diff --git a/src-tauri/src/commands/xai_oauth.rs b/src-tauri/src/commands/xai_oauth.rs new file mode 100644 index 000000000..37dc89987 --- /dev/null +++ b/src-tauri/src/commands/xai_oauth.rs @@ -0,0 +1,72 @@ +//! xAI OAuth state and xAI-specific commands. + +use crate::proxy::providers::xai_oauth_auth::XaiOAuthManager; +use crate::proxy::providers::XAI_API_BASE_URL; +use crate::services::model_fetch::FetchedModel; +use serde::Deserialize; +use std::sync::Arc; +use std::time::Duration; +use tauri::State; +use tokio::sync::RwLock; + +pub struct XaiOAuthState(pub Arc>); + +#[derive(Debug, Deserialize)] +struct ModelsResponse { + #[serde(default)] + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct ModelEntry { + id: String, + #[serde(default)] + owned_by: Option, +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn get_xai_oauth_models( + account_id: Option, + state: State<'_, XaiOAuthState>, +) -> Result, String> { + let manager = state.0.read().await; + let resolved = match account_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + { + Some(id) => Some(id.to_string()), + None => manager.default_account_id().await, + }; + let account_id = resolved.ok_or_else(|| "No usable xAI account available".to_string())?; + let token = manager + .get_valid_token_for_account(&account_id) + .await + .map_err(|error| format!("xAI OAuth token unavailable: {error}"))?; + + let response = crate::proxy::http_client::get() + .get(format!("{XAI_API_BASE_URL}/models")) + .bearer_auth(token) + .timeout(Duration::from_secs(15)) + .send() + .await + .map_err(|error| format!("xAI models request failed: {error}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!("xAI models request failed: HTTP {status}")); + } + let payload: ModelsResponse = response + .json() + .await + .map_err(|_| "xAI models response was not valid JSON".to_string())?; + let mut models: Vec = payload + .data + .into_iter() + .map(|model| FetchedModel { + id: model.id, + owned_by: model.owned_by, + }) + .collect(); + models.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(models) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 954437399..d5b866522 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1115,6 +1115,18 @@ pub fn run() { log::info!("✓ CodexOAuthManager initialized"); } + // 初始化 xAI OAuthManager (Grok API 反代) + { + use crate::proxy::providers::xai_oauth_auth::XaiOAuthManager; + use commands::XaiOAuthState; + use tokio::sync::RwLock; + + let app_config_dir = crate::config::get_app_config_dir(); + let xai_oauth_manager = XaiOAuthManager::new(app_config_dir); + app.manage(XaiOAuthState(Arc::new(RwLock::new(xai_oauth_manager)))); + log::info!("✓ XaiOAuthManager initialized"); + } + // 初始化全局出站代理 HTTP 客户端 { let db = &app.state::().db; @@ -1377,6 +1389,7 @@ pub fn run() { commands::get_subscription_quota, commands::get_codex_oauth_quota, commands::get_codex_oauth_models, + commands::get_xai_oauth_models, commands::get_coding_plan_quota, commands::get_balance, // New MCP via config.json (SSOT) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 6c1c33a34..04e6ab5c3 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -71,6 +71,10 @@ impl Provider { self.provider_type() == Some("codex_oauth") } + pub fn is_xai_oauth(&self) -> bool { + self.provider_type() == Some("xai_oauth") + } + pub fn is_github_copilot(&self) -> bool { self.provider_type() == Some("github_copilot") || self.claude_base_url_contains("githubcopilot.com") @@ -79,6 +83,7 @@ impl Provider { pub fn uses_managed_account_auth(&self) -> bool { self.is_github_copilot() || self.is_codex_oauth() + || self.is_xai_oauth() || self.claude_base_url_contains("chatgpt.com/backend-api/codex") } diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 7f934dc9a..64e3981fb 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -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::(); + 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] diff --git a/src-tauri/src/proxy/providers/auth.rs b/src-tauri/src/proxy/providers/auth.rs index 04242eb25..f4e68add1 100644 --- a/src-tauri/src/proxy/providers/auth.rs +++ b/src-tauri/src/proxy/providers/auth.rs @@ -128,6 +128,13 @@ pub enum AuthStrategy { /// /// 使用动态获取的 OpenAI access_token(通过 Device Code 流程获取) CodexOAuth, + + /// xAI OAuth(Grok API) + /// + /// - Header: `Authorization: Bearer ` + /// + /// 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] diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index bfde18b6f..5cd02a09d 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -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(); diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 1aeec596f..8a5b24fce 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -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 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::().unwrap(), ProviderType::GitHubCopilot ); + assert_eq!( + "xai_oauth".parse::().unwrap(), + ProviderType::XaiOAuth + ); assert!("invalid".parse::().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] diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index a1c1d023d..fd5aac801 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -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")); } diff --git a/src-tauri/src/proxy/providers/xai_oauth_auth.rs b/src-tauri/src/proxy/providers/xai_oauth_auth.rs new file mode 100644 index 000000000..2baf5a133 --- /dev/null +++ b/src-tauri/src/proxy/providers/xai_oauth_auth.rs @@ -0,0 +1,1355 @@ +//! xAI OAuth authentication manager. +//! +//! xAI uses the OAuth 2.0 Device Authorization Grant. Endpoints are resolved +//! from xAI's OpenID Connect discovery document so authentication protocol +//! changes do not require duplicating endpoint constants across the app. + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; + +use super::copilot_auth::GitHubDeviceCodeResponse; + +const XAI_ISSUER: &str = "https://auth.x.ai"; +const XAI_DISCOVERY_URL: &str = "https://auth.x.ai/.well-known/openid-configuration"; +const XAI_CLIENT_ID: &str = "b1a00492-073a-47ea-816f-4c329264a828"; +const XAI_SCOPE: &str = "openid profile email offline_access grok-cli:access api:access"; +const XAI_USER_AGENT: &str = "cc-switch-xai-oauth"; +const TOKEN_REFRESH_BUFFER_MS: i64 = 60_000; +const DEFAULT_TOKEN_LIFETIME_SECS: i64 = 3_600; +const POLLING_SAFETY_MARGIN_SECS: u64 = 3; +const MAX_DEVICE_CODE_LIFETIME_SECS: u64 = 24 * 60 * 60; +const MAX_POLL_INTERVAL_SECS: u64 = 60; +const MAX_OAUTH_RESPONSE_BYTES: usize = 64 * 1024; + +#[derive(Debug, thiserror::Error)] +pub enum XaiOAuthError { + #[error("等待用户授权中")] + AuthorizationPending, + #[error("用户拒绝授权")] + AccessDenied, + #[error("Device Code 已过期")] + ExpiredToken, + #[error("OAuth Token 获取失败: {0}")] + TokenFetchFailed(String), + #[error("Refresh Token 失效或已过期,请重新登录 xAI")] + RefreshTokenInvalid, + #[error("账号需要重新登录: {0}")] + ReauthRequired(String), + #[error("网络错误: {0}")] + NetworkError(String), + #[error("解析错误: {0}")] + ParseError(String), + #[error("IO 错误: {0}")] + IoError(String), + #[error("账号不存在: {0}")] + AccountNotFound(String), +} + +impl From for XaiOAuthError { + fn from(err: reqwest::Error) -> Self { + Self::NetworkError(err.to_string()) + } +} + +impl From for XaiOAuthError { + fn from(err: std::io::Error) -> Self { + Self::IoError(err.to_string()) + } +} + +#[derive(Debug, Clone, Deserialize)] +struct DiscoveryDocument { + issuer: String, + token_endpoint: String, + device_authorization_endpoint: String, +} + +#[derive(Debug, Clone)] +struct OAuthEndpoints { + token_endpoint: String, + device_authorization_endpoint: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct DeviceCodeResponse { + device_code: String, + user_code: String, + verification_uri: String, + #[serde(default)] + verification_uri_complete: Option, + expires_in: u64, + #[serde(default = "default_poll_interval")] + interval: u64, +} + +#[derive(Debug, Clone, Deserialize)] +struct OAuthTokenResponse { + access_token: String, + #[serde(default)] + refresh_token: Option, + #[serde(default)] + id_token: Option, + #[serde(default)] + expires_in: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct XaiTokenClaims { + #[serde(default)] + sub: Option, + #[serde(default)] + email: Option, + #[serde(default)] + preferred_username: Option, + #[serde(default)] + name: Option, +} + +#[derive(Debug, Clone)] +struct CachedAccessToken { + token: String, + expires_at_ms: i64, +} + +impl CachedAccessToken { + fn is_expiring_soon(&self) -> bool { + self.expires_at_ms - chrono::Utc::now().timestamp_millis() < TOKEN_REFRESH_BUFFER_MS + } +} + +#[derive(Debug, Clone)] +struct PendingDeviceCode { + token_endpoint: String, + expires_at_ms: i64, + interval_secs: u64, + next_poll_at_ms: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct XaiAccountData { + account_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + login: Option, + refresh_token: String, + authenticated_at: i64, + #[serde(default)] + requires_reauth: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XaiOAuthAccount { + pub id: String, + pub login: String, + pub avatar_url: Option, + pub authenticated_at: i64, + pub github_domain: String, + pub requires_reauth: bool, +} + +impl From<&XaiAccountData> for XaiOAuthAccount { + fn from(data: &XaiAccountData) -> Self { + let short_id: String = data.account_id.chars().take(12).collect(); + Self { + id: data.account_id.clone(), + login: data + .login + .clone() + .unwrap_or_else(|| format!("xAI ({short_id})")), + avatar_url: None, + authenticated_at: data.authenticated_at, + github_domain: "x.ai".to_string(), + requires_reauth: data.requires_reauth, + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct XaiOAuthStore { + #[serde(default)] + version: u32, + #[serde(default)] + accounts: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + default_account_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XaiOAuthStatus { + pub accounts: Vec, + pub default_account_id: Option, + pub authenticated: bool, + pub username: Option, +} + +pub struct XaiOAuthManager { + accounts: Arc>>, + default_account_id: Arc>>, + access_tokens: Arc>>, + refresh_locks: Arc>>>>, + pending_device_codes: Arc>>, + discovered_endpoints: Arc>>, + mutation_lock: Arc>, + storage_path: PathBuf, +} + +impl XaiOAuthManager { + pub fn new(data_dir: PathBuf) -> Self { + let manager = Self { + accounts: Arc::new(RwLock::new(HashMap::new())), + default_account_id: Arc::new(RwLock::new(None)), + access_tokens: Arc::new(RwLock::new(HashMap::new())), + refresh_locks: Arc::new(RwLock::new(HashMap::new())), + pending_device_codes: Arc::new(RwLock::new(HashMap::new())), + discovered_endpoints: Arc::new(RwLock::new(None)), + mutation_lock: Arc::new(Mutex::new(())), + storage_path: data_dir.join("xai_oauth_auth.json"), + }; + + if let Err(error) = manager.load_from_disk_sync() { + log::warn!("[XaiOAuth] 加载存储失败: {error}"); + } + manager + } + + pub async fn start_device_flow(&self) -> Result { + let endpoints = self.discover_endpoints().await?; + let response = crate::proxy::http_client::get() + .post(&endpoints.device_authorization_endpoint) + .header("User-Agent", XAI_USER_AGENT) + .form(&[("client_id", XAI_CLIENT_ID), ("scope", XAI_SCOPE)]) + .send() + .await?; + + let status = response.status(); + let value = read_json_response(response).await?; + if !status.is_success() { + return Err(XaiOAuthError::TokenFetchFailed(format_oauth_error( + status, &value, + ))); + } + let device = parse_device_code_response(value)?; + let interval = device + .interval + .clamp(1, MAX_POLL_INTERVAL_SECS) + .saturating_add(POLLING_SAFETY_MARGIN_SECS); + let expires_in = device.expires_in.clamp(1, MAX_DEVICE_CODE_LIFETIME_SECS); + let now_ms = chrono::Utc::now().timestamp_millis(); + + { + let mut pending = self.pending_device_codes.write().await; + pending.retain(|_, entry| entry.expires_at_ms > now_ms); + pending.insert( + device.device_code.clone(), + PendingDeviceCode { + token_endpoint: endpoints.token_endpoint, + expires_at_ms: now_ms.saturating_add( + i64::try_from(expires_in) + .unwrap_or(i64::MAX) + .saturating_mul(1_000), + ), + interval_secs: interval, + next_poll_at_ms: now_ms, + }, + ); + } + + Ok(GitHubDeviceCodeResponse { + device_code: device.device_code, + user_code: device.user_code, + verification_uri: device + .verification_uri_complete + .unwrap_or(device.verification_uri), + expires_in, + interval, + }) + } + + pub async fn poll_for_token( + &self, + device_code: &str, + ) -> Result, XaiOAuthError> { + let now_ms = chrono::Utc::now().timestamp_millis(); + let entry = { + let pending = self.pending_device_codes.read().await; + pending.get(device_code).cloned() + } + .ok_or_else(|| { + XaiOAuthError::TokenFetchFailed("Device Code 不存在,请重新启动登录".to_string()) + })?; + + if entry.expires_at_ms <= now_ms { + self.pending_device_codes.write().await.remove(device_code); + return Err(XaiOAuthError::ExpiredToken); + } + if entry.next_poll_at_ms > now_ms { + return Err(XaiOAuthError::AuthorizationPending); + } + self.schedule_next_poll(device_code, entry.interval_secs) + .await; + + let response = crate::proxy::http_client::get() + .post(&entry.token_endpoint) + .header("User-Agent", XAI_USER_AGENT) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("client_id", XAI_CLIENT_ID), + ("device_code", device_code), + ]) + .send() + .await?; + let status = response.status(); + let value = read_json_response(response).await?; + + if let Some(error_code) = oauth_error_code(&value) { + return match error_code.as_str() { + "authorization_pending" => Err(XaiOAuthError::AuthorizationPending), + "slow_down" => { + self.increase_poll_interval(device_code).await; + Err(XaiOAuthError::AuthorizationPending) + } + "access_denied" => { + self.pending_device_codes.write().await.remove(device_code); + Err(XaiOAuthError::AccessDenied) + } + "expired_token" => { + self.pending_device_codes.write().await.remove(device_code); + Err(XaiOAuthError::ExpiredToken) + } + _ => Err(XaiOAuthError::TokenFetchFailed(format_oauth_error( + status, &value, + ))), + }; + } + if !status.is_success() { + return Err(XaiOAuthError::TokenFetchFailed(format_oauth_error( + status, &value, + ))); + } + + let tokens = parse_token_response(value)?; + validate_access_token(&tokens.access_token)?; + let refresh_token = tokens + .refresh_token + .as_deref() + .filter(|token| !token.trim().is_empty()) + .map(ToString::to_string) + .ok_or_else(|| { + XaiOAuthError::TokenFetchFailed("成功响应缺少 refresh_token".to_string()) + })?; + let (account_id, login) = extract_identity_from_tokens(&tokens).ok_or_else(|| { + XaiOAuthError::ParseError("xAI token 缺少稳定的 sub claim,未保存账号".to_string()) + })?; + + let cached_access_token = CachedAccessToken { + token: tokens.access_token, + expires_at_ms: compute_expires_at_ms(tokens.expires_in), + }; + let account = self + .add_account_internal( + account_id, + login, + refresh_token, + Some(device_code), + Some(cached_access_token), + ) + .await?; + Ok(Some(account)) + } + + pub async fn get_valid_token_for_account( + &self, + account_id: &str, + ) -> Result { + if let Some(token) = self.cached_token_for_usable_account(account_id).await { + return Ok(token); + } + + let refresh_lock = self.get_refresh_lock(account_id).await; + let _refresh_guard = refresh_lock.lock().await; + if let Some(token) = self.cached_token_for_usable_account(account_id).await { + return Ok(token); + } + + let account = self + .accounts + .read() + .await + .get(account_id) + .cloned() + .ok_or_else(|| XaiOAuthError::AccountNotFound(account_id.to_string()))?; + if account.requires_reauth { + return Err(XaiOAuthError::ReauthRequired(account_id.to_string())); + } + + let tokens = match self.refresh_with_token(&account.refresh_token).await { + Ok(tokens) => tokens, + Err(XaiOAuthError::RefreshTokenInvalid) => { + self.mark_reauth_required(account_id).await?; + return Err(XaiOAuthError::ReauthRequired(account_id.to_string())); + } + Err(error) => return Err(error), + }; + + self.commit_refreshed_tokens(account_id, &account.refresh_token, tokens) + .await + } + + pub async fn get_valid_token(&self) -> Result { + match self.resolve_default_account_id().await { + Some(account_id) => self.get_valid_token_for_account(&account_id).await, + None => Err(XaiOAuthError::AccountNotFound( + "无可用的 xAI 账号,请登录或重新登录".to_string(), + )), + } + } + + pub async fn default_account_id(&self) -> Option { + self.resolve_default_account_id().await + } + + pub async fn get_status(&self) -> XaiOAuthStatus { + let accounts = self.accounts.read().await.clone(); + let default_account_id = self.resolve_default_account_id().await; + let account_list = Self::sorted_accounts(&accounts, default_account_id.as_deref()); + let username = default_account_id + .as_ref() + .and_then(|id| accounts.get(id)) + .and_then(|account| account.login.clone()); + XaiOAuthStatus { + authenticated: default_account_id.is_some(), + default_account_id, + accounts: account_list, + username, + } + } + + pub async fn list_accounts(&self) -> Vec { + let accounts = self.accounts.read().await.clone(); + let default_account_id = self.resolve_default_account_id().await; + Self::sorted_accounts(&accounts, default_account_id.as_deref()) + } + + pub async fn remove_account(&self, account_id: &str) -> Result<(), XaiOAuthError> { + let _mutation_guard = self.mutation_lock.lock().await; + let mut accounts = self.accounts.read().await.clone(); + if accounts.remove(account_id).is_none() { + return Err(XaiOAuthError::AccountNotFound(account_id.to_string())); + } + let stored_default = self.default_account_id.read().await.clone(); + let default_account_id = if stored_default.as_deref() == Some(account_id) { + Self::fallback_default_account_id(&accounts) + } else { + stored_default.filter(|id| Self::is_usable_account(&accounts, id)) + }; + self.persist_and_commit(accounts, default_account_id) + .await?; + self.access_tokens.write().await.remove(account_id); + self.refresh_locks.write().await.remove(account_id); + Ok(()) + } + + pub async fn set_default_account(&self, account_id: &str) -> Result<(), XaiOAuthError> { + let _mutation_guard = self.mutation_lock.lock().await; + let accounts = self.accounts.read().await.clone(); + let account = accounts + .get(account_id) + .ok_or_else(|| XaiOAuthError::AccountNotFound(account_id.to_string()))?; + if account.requires_reauth { + return Err(XaiOAuthError::ReauthRequired(account_id.to_string())); + } + self.persist_and_commit(accounts, Some(account_id.to_string())) + .await + } + + pub async fn clear_auth(&self) -> Result<(), XaiOAuthError> { + let _mutation_guard = self.mutation_lock.lock().await; + if self.storage_path.exists() { + fs::remove_file(&self.storage_path)?; + } + *self.accounts.write().await = HashMap::new(); + *self.default_account_id.write().await = None; + self.access_tokens.write().await.clear(); + self.refresh_locks.write().await.clear(); + self.pending_device_codes.write().await.clear(); + Ok(()) + } + + async fn discover_endpoints(&self) -> Result { + if let Some(endpoints) = self.discovered_endpoints.read().await.clone() { + return Ok(endpoints); + } + let response = crate::proxy::http_client::get() + .get(XAI_DISCOVERY_URL) + .header("User-Agent", XAI_USER_AGENT) + .send() + .await?; + let status = response.status(); + let value = read_json_response(response).await?; + if !status.is_success() { + return Err(XaiOAuthError::NetworkError(format!( + "xAI discovery 请求失败: HTTP {status}" + ))); + } + let document = parse_discovery_document(value)?; + if document.issuer.trim_end_matches('/') != XAI_ISSUER { + return Err(XaiOAuthError::ParseError( + "xAI discovery issuer 不匹配".to_string(), + )); + } + validate_xai_endpoint(&document.token_endpoint)?; + validate_xai_endpoint(&document.device_authorization_endpoint)?; + let endpoints = OAuthEndpoints { + token_endpoint: document.token_endpoint, + device_authorization_endpoint: document.device_authorization_endpoint, + }; + *self.discovered_endpoints.write().await = Some(endpoints.clone()); + Ok(endpoints) + } + + async fn refresh_with_token( + &self, + refresh_token: &str, + ) -> Result { + let endpoints = self.discover_endpoints().await?; + let response = crate::proxy::http_client::get() + .post(&endpoints.token_endpoint) + .header("User-Agent", XAI_USER_AGENT) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", XAI_CLIENT_ID), + ("refresh_token", refresh_token), + ("scope", XAI_SCOPE), + ]) + .send() + .await?; + let status = response.status(); + let value_result = read_json_response(response).await; + // Invalid credentials must transition the account to re-auth even when + // the provider sends an empty, HTML, or otherwise malformed error body. + if refresh_response_requires_reauth(status, value_result.is_err()) { + return Err(XaiOAuthError::RefreshTokenInvalid); + } + let value = value_result?; + let error_code = oauth_error_code(&value); + if matches!( + error_code.as_deref(), + Some("invalid_grant" | "invalid_token") + ) { + return Err(XaiOAuthError::RefreshTokenInvalid); + } + if !status.is_success() || error_code.is_some() { + return Err(XaiOAuthError::TokenFetchFailed(format_oauth_error( + status, &value, + ))); + } + let tokens = parse_token_response(value)?; + validate_access_token(&tokens.access_token)?; + Ok(tokens) + } + + async fn add_account_internal( + &self, + account_id: String, + login: Option, + refresh_token: String, + pending_device_code: Option<&str>, + cached_access_token: Option, + ) -> Result { + let _mutation_guard = self.mutation_lock.lock().await; + if let Some(device_code) = pending_device_code { + let login_is_pending = self + .pending_device_codes + .read() + .await + .contains_key(device_code); + if !login_is_pending { + return Err(XaiOAuthError::TokenFetchFailed( + "登录已取消,请重新启动登录".to_string(), + )); + } + } + let mut accounts = self.accounts.read().await.clone(); + let data = XaiAccountData { + account_id: account_id.clone(), + login, + refresh_token, + authenticated_at: chrono::Utc::now().timestamp(), + requires_reauth: false, + }; + let account = XaiOAuthAccount::from(&data); + accounts.insert(account_id.clone(), data); + let current_default = self.default_account_id.read().await.clone(); + let default_account_id = match current_default { + Some(id) if Self::is_usable_account(&accounts, &id) => Some(id), + _ => Some(account_id.clone()), + }; + self.persist_and_commit(accounts, default_account_id) + .await?; + if let Some(access_token) = cached_access_token { + self.access_tokens + .write() + .await + .insert(account_id, access_token); + } + if let Some(device_code) = pending_device_code { + self.pending_device_codes.write().await.remove(device_code); + } + Ok(account) + } + + async fn commit_refreshed_tokens( + &self, + account_id: &str, + expected_refresh_token: &str, + tokens: OAuthTokenResponse, + ) -> Result { + let _mutation_guard = self.mutation_lock.lock().await; + let mut accounts = self.accounts.read().await.clone(); + let account = accounts + .get_mut(account_id) + .ok_or_else(|| XaiOAuthError::AccountNotFound(account_id.to_string()))?; + if account.requires_reauth { + return Err(XaiOAuthError::ReauthRequired(account_id.to_string())); + } + if account.refresh_token != expected_refresh_token { + return Err(XaiOAuthError::TokenFetchFailed( + "账号认证状态已变化,请重试请求".to_string(), + )); + } + + let refresh_token_changed = tokens + .refresh_token + .as_deref() + .filter(|token| !token.trim().is_empty()) + .is_some_and(|refresh_token| { + if refresh_token == account.refresh_token { + false + } else { + account.refresh_token = refresh_token.to_string(); + true + } + }); + if refresh_token_changed { + let default_account_id = self.default_account_id.read().await.clone(); + self.persist_and_commit(accounts, default_account_id) + .await?; + } + + let access_token = tokens.access_token; + self.access_tokens.write().await.insert( + account_id.to_string(), + CachedAccessToken { + token: access_token.clone(), + expires_at_ms: compute_expires_at_ms(tokens.expires_in), + }, + ); + Ok(access_token) + } + + async fn mark_reauth_required(&self, account_id: &str) -> Result<(), XaiOAuthError> { + let _mutation_guard = self.mutation_lock.lock().await; + let mut accounts = self.accounts.read().await.clone(); + let account = accounts + .get_mut(account_id) + .ok_or_else(|| XaiOAuthError::AccountNotFound(account_id.to_string()))?; + account.requires_reauth = true; + let default_account_id = Self::fallback_default_account_id(&accounts); + self.persist_and_commit(accounts, default_account_id) + .await?; + self.access_tokens.write().await.remove(account_id); + Ok(()) + } + + async fn persist_and_commit( + &self, + accounts: HashMap, + default_account_id: Option, + ) -> Result<(), XaiOAuthError> { + let store = XaiOAuthStore { + version: 1, + accounts: accounts.clone(), + default_account_id: default_account_id.clone(), + }; + let content = serde_json::to_string_pretty(&store) + .map_err(|error| XaiOAuthError::ParseError(error.to_string()))?; + self.write_store_atomic(&content)?; + *self.accounts.write().await = accounts; + *self.default_account_id.write().await = default_account_id; + Ok(()) + } + + async fn cached_token(&self, account_id: &str) -> Option { + self.access_tokens + .read() + .await + .get(account_id) + .filter(|token| !token.is_expiring_soon()) + .map(|token| token.token.clone()) + } + + async fn cached_token_for_usable_account(&self, account_id: &str) -> Option { + let account_is_usable = { + let accounts = self.accounts.read().await; + Self::is_usable_account(&accounts, account_id) + }; + if !account_is_usable { + return None; + } + self.cached_token(account_id).await + } + + async fn schedule_next_poll(&self, device_code: &str, interval_secs: u64) { + if let Some(entry) = self.pending_device_codes.write().await.get_mut(device_code) { + entry.next_poll_at_ms = chrono::Utc::now().timestamp_millis().saturating_add( + i64::try_from(interval_secs) + .unwrap_or(i64::MAX) + .saturating_mul(1_000), + ); + } + } + + async fn increase_poll_interval(&self, device_code: &str) { + if let Some(entry) = self.pending_device_codes.write().await.get_mut(device_code) { + entry.interval_secs = entry + .interval_secs + .saturating_add(5) + .min(MAX_POLL_INTERVAL_SECS + POLLING_SAFETY_MARGIN_SECS); + entry.next_poll_at_ms = chrono::Utc::now().timestamp_millis().saturating_add( + i64::try_from(entry.interval_secs) + .unwrap_or(i64::MAX) + .saturating_mul(1_000), + ); + } + } + + async fn get_refresh_lock(&self, account_id: &str) -> Arc> { + if let Some(lock) = self.refresh_locks.read().await.get(account_id).cloned() { + return lock; + } + Arc::clone( + self.refresh_locks + .write() + .await + .entry(account_id.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))), + ) + } + + async fn resolve_default_account_id(&self) -> Option { + let stored = self.default_account_id.read().await.clone(); + let accounts = self.accounts.read().await; + match stored { + Some(id) if Self::is_usable_account(&accounts, &id) => Some(id), + _ => Self::fallback_default_account_id(&accounts), + } + } + + fn fallback_default_account_id(accounts: &HashMap) -> Option { + accounts + .iter() + .filter(|(_, account)| !account.requires_reauth) + .max_by(|(id_a, account_a), (id_b, account_b)| { + account_a + .authenticated_at + .cmp(&account_b.authenticated_at) + .then_with(|| id_b.cmp(id_a)) + }) + .map(|(id, _)| id.clone()) + } + + fn is_usable_account(accounts: &HashMap, id: &str) -> bool { + accounts + .get(id) + .is_some_and(|account| !account.requires_reauth) + } + + fn sorted_accounts( + accounts: &HashMap, + default_account_id: Option<&str>, + ) -> Vec { + let mut result: Vec<_> = accounts.values().map(XaiOAuthAccount::from).collect(); + result.sort_by(|a, b| { + let a_default = default_account_id == Some(a.id.as_str()); + let b_default = default_account_id == Some(b.id.as_str()); + b_default + .cmp(&a_default) + .then_with(|| a.requires_reauth.cmp(&b.requires_reauth)) + .then_with(|| b.authenticated_at.cmp(&a.authenticated_at)) + .then_with(|| a.login.cmp(&b.login)) + }); + result + } + + fn load_from_disk_sync(&self) -> Result<(), XaiOAuthError> { + if !self.storage_path.exists() { + return Ok(()); + } + let content = fs::read_to_string(&self.storage_path)?; + let store: XaiOAuthStore = serde_json::from_str(&content) + .map_err(|error| XaiOAuthError::ParseError(error.to_string()))?; + if let Ok(mut accounts) = self.accounts.try_write() { + *accounts = store.accounts; + } + if let Ok(mut default_account_id) = self.default_account_id.try_write() { + *default_account_id = store.default_account_id; + } + Ok(()) + } + + fn write_store_atomic(&self, content: &str) -> Result<(), XaiOAuthError> { + let parent = self + .storage_path + .parent() + .ok_or_else(|| XaiOAuthError::IoError("无效的存储路径".to_string()))?; + fs::create_dir_all(parent)?; + let file_name = self + .storage_path + .file_name() + .ok_or_else(|| XaiOAuthError::IoError("无效的存储文件名".to_string()))? + .to_string_lossy(); + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let temporary_path = parent.join(format!("{file_name}.tmp.{nonce}")); + + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let result = (|| -> Result<(), std::io::Error> { + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&temporary_path)?; + file.write_all(content.as_bytes())?; + file.flush()?; + fs::rename(&temporary_path, &self.storage_path)?; + fs::set_permissions(&self.storage_path, fs::Permissions::from_mode(0o600))?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary_path); + } + result?; + } + + #[cfg(windows)] + { + let result = (|| -> Result<(), std::io::Error> { + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&temporary_path)?; + file.write_all(content.as_bytes())?; + file.flush()?; + if self.storage_path.exists() { + fs::remove_file(&self.storage_path)?; + } + fs::rename(&temporary_path, &self.storage_path)?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary_path); + } + result?; + } + Ok(()) + } +} + +fn default_poll_interval() -> u64 { + 5 +} + +fn compute_expires_at_ms(expires_in: Option) -> i64 { + chrono::Utc::now().timestamp_millis().saturating_add( + expires_in + .unwrap_or(DEFAULT_TOKEN_LIFETIME_SECS) + .max(1) + .saturating_mul(1_000), + ) +} + +fn validate_access_token(access_token: &str) -> Result<(), XaiOAuthError> { + if access_token.trim().is_empty() { + return Err(XaiOAuthError::TokenFetchFailed( + "成功响应缺少 access_token".to_string(), + )); + } + Ok(()) +} + +fn parse_device_code_response( + value: serde_json::Value, +) -> Result { + serde_json::from_value(value) + .map_err(|_| XaiOAuthError::ParseError("设备授权响应字段无效".to_string())) +} + +fn parse_token_response(value: serde_json::Value) -> Result { + serde_json::from_value(value) + .map_err(|_| XaiOAuthError::ParseError("OAuth Token 响应字段无效".to_string())) +} + +fn parse_discovery_document(value: serde_json::Value) -> Result { + serde_json::from_value(value) + .map_err(|_| XaiOAuthError::ParseError("xAI discovery 响应字段无效".to_string())) +} + +fn refresh_response_requires_reauth( + status: reqwest::StatusCode, + response_body_is_invalid: bool, +) -> bool { + matches!( + status, + reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN + ) || (status == reqwest::StatusCode::BAD_REQUEST && response_body_is_invalid) +} + +fn parse_jwt_claims(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?; + serde_json::from_slice(&decoded).ok() +} + +fn extract_identity_from_tokens(tokens: &OAuthTokenResponse) -> Option<(String, Option)> { + let claims = tokens + .id_token + .as_deref() + .and_then(parse_jwt_claims) + .or_else(|| parse_jwt_claims(&tokens.access_token))?; + let account_id = claims.sub?.trim().to_string(); + if account_id.is_empty() { + return None; + } + let login = claims + .email + .or(claims.preferred_username) + .or(claims.name) + .filter(|value| !value.trim().is_empty()); + Some((account_id, login)) +} + +fn validate_xai_endpoint(value: &str) -> Result<(), XaiOAuthError> { + let url = reqwest::Url::parse(value) + .map_err(|_| XaiOAuthError::ParseError("xAI 认证端点 URL 无效".to_string()))?; + if url.scheme() != "https" + || url.host_str() != Some("auth.x.ai") + || url.port_or_known_default() != Some(443) + || !url.username().is_empty() + || url.password().is_some() + { + return Err(XaiOAuthError::ParseError( + "xAI discovery 返回了不受信任的认证端点".to_string(), + )); + } + Ok(()) +} + +async fn read_json_response( + response: reqwest::Response, +) -> Result { + if response + .content_length() + .is_some_and(|length| length > MAX_OAUTH_RESPONSE_BYTES as u64) + { + return Err(XaiOAuthError::ParseError( + "OAuth 响应超过大小限制".to_string(), + )); + } + let bytes = response.bytes().await?; + if bytes.len() > MAX_OAUTH_RESPONSE_BYTES { + return Err(XaiOAuthError::ParseError( + "OAuth 响应超过大小限制".to_string(), + )); + } + serde_json::from_slice(&bytes) + .map_err(|_| XaiOAuthError::ParseError("OAuth 响应不是有效 JSON".to_string())) +} + +fn oauth_error_code(value: &serde_json::Value) -> Option { + value + .get("error") + .and_then(serde_json::Value::as_str) + .map(sanitize_oauth_error_code) + .filter(|value| !value.is_empty()) +} + +fn sanitize_oauth_error_code(value: &str) -> String { + value + .chars() + .filter(|character| character.is_ascii_alphanumeric() || "_.-".contains(*character)) + .take(64) + .collect() +} + +fn format_oauth_error(status: reqwest::StatusCode, value: &serde_json::Value) -> String { + match oauth_error_code(value) { + Some(code) => format!("HTTP {status} ({code})"), + None => format!("HTTP {status}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unsigned_jwt(payload: &serde_json::Value) -> String { + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#); + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(payload).unwrap()); + format!("{header}.{payload}.") + } + + #[test] + fn identity_requires_nonempty_sub() { + let tokens = OAuthTokenResponse { + access_token: "opaque".to_string(), + refresh_token: Some("refresh".to_string()), + id_token: Some(unsigned_jwt(&serde_json::json!({"email":"a@b.c"}))), + expires_in: Some(3_600), + }; + assert!(extract_identity_from_tokens(&tokens).is_none()); + } + + #[test] + fn identity_uses_sub_without_shared_fallback_id() { + let tokens = OAuthTokenResponse { + access_token: "opaque".to_string(), + refresh_token: Some("refresh".to_string()), + id_token: Some(unsigned_jwt( + &serde_json::json!({"sub":"user-123","email":"a@b.c"}), + )), + expires_in: Some(3_600), + }; + assert_eq!( + extract_identity_from_tokens(&tokens), + Some(("user-123".to_string(), Some("a@b.c".to_string()))) + ); + } + + #[test] + fn oauth_error_never_embeds_upstream_body() { + let value = serde_json::json!({ + "error": "invalid_grant