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