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

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

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