mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-01 12:22:09 +08:00
feat(xai): add Grok OAuth device-flow backend and proxy routing
Add an xAI OAuth manager using the OAuth 2.0 Device Authorization Grant with endpoints resolved from xAI's OIDC discovery document. All HTTP goes through the app-managed proxy client. - Managed provider kind xai_oauth: forced openai_responses wire format, pinned api.x.ai base URL, bearer injection gated to the xAI origin, tokens registered for log redaction, single-auth-key takeover policy. - Token cache cannot bypass account state: cache hits re-validate account usability, refresh commits run under the mutation lock with a refresh-token CAS check, and pending logins are re-checked before an account is persisted. - Refresh classification: 401/403 with any body and 400 with a non-JSON body mark the account for re-auth; 429/5xx stay transient. - Shared auth_* commands dispatch to xAI with guard types mirroring the Copilot/Codex branches.
This commit is contained in:
@@ -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<String>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
xai_state: State<'_, XaiOAuthState>,
|
||||
) -> Result<ManagedAuthDeviceCodeResponse, String> {
|
||||
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<String>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
codex_state: State<'_, CodexOAuthState>,
|
||||
xai_state: State<'_, XaiOAuthState>,
|
||||
) -> Result<Option<ManagedAuthAccount>, 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<Vec<ManagedAuthAccount>, 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<ManagedAuthStatus, String> {
|
||||
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!(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<RwLock<XaiOAuthManager>>);
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelsResponse {
|
||||
#[serde(default)]
|
||||
data: Vec<ModelEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelEntry {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
owned_by: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn get_xai_oauth_models(
|
||||
account_id: Option<String>,
|
||||
state: State<'_, XaiOAuthState>,
|
||||
) -> Result<Vec<FetchedModel>, 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<FetchedModel> = 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)
|
||||
}
|
||||
Reference in New Issue
Block a user