From 4c2840e08ff4e81eafa6b214ca52b6d3a2a90d0d Mon Sep 17 00:00:00 2001 From: coso Date: Sun, 14 Dec 2025 03:13:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20Qwen=20(=E9=80=9A=E4=B9=89?= =?UTF-8?q?=E5=8D=83=E9=97=AE)=20OAuth=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add QwenProvider with OAuth token refresh support - Add Qwen credential management commands - Update Providers page with Qwen tab - Auto-detect credential file changes for all 3 providers - Add Qwen models to /v1/models endpoint --- src-tauri/src/lib.rs | 157 +++++++++++++++++++++++++ src-tauri/src/providers/mod.rs | 2 + src-tauri/src/providers/qwen.rs | 195 ++++++++++++++++++++++++++++++++ src-tauri/src/server.rs | 11 +- src/hooks/useTauri.ts | 36 ++++++ 5 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/providers/qwen.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7ad4bad98..28397b452 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -383,6 +383,156 @@ async fn check_and_reload_gemini_credentials( } } +// ============ Qwen Provider Commands ============ + +#[derive(serde::Serialize)] +struct QwenCredentialStatus { + loaded: bool, + has_access_token: bool, + has_refresh_token: bool, + expiry_date: Option, + is_valid: bool, + creds_path: String, +} + +#[tauri::command] +async fn get_qwen_credentials(state: tauri::State<'_, AppState>) -> Result { + let s = state.read().await; + let creds = &s.qwen_provider.credentials; + let path = providers::qwen::QwenProvider::default_creds_path(); + + Ok(QwenCredentialStatus { + loaded: creds.access_token.is_some() || creds.refresh_token.is_some(), + has_access_token: creds.access_token.is_some(), + has_refresh_token: creds.refresh_token.is_some(), + expiry_date: creds.expiry_date, + is_valid: s.qwen_provider.is_token_valid(), + creds_path: path.to_string_lossy().to_string(), + }) +} + +#[tauri::command] +async fn reload_qwen_credentials(state: tauri::State<'_, AppState>, logs: tauri::State<'_, LogState>) -> Result { + let mut s = state.write().await; + logs.write().await.add("info", "[Qwen] 正在加载凭证..."); + s.qwen_provider.load_credentials().map_err(|e| e.to_string())?; + logs.write().await.add("info", "[Qwen] 凭证加载成功"); + Ok("Qwen credentials reloaded".to_string()) +} + +#[tauri::command] +async fn refresh_qwen_token(state: tauri::State<'_, AppState>, logs: tauri::State<'_, LogState>) -> Result { + let mut s = state.write().await; + logs.write().await.add("info", "[Qwen] 正在刷新 Token..."); + let result = s.qwen_provider.refresh_token().await.map_err(|e| e.to_string()); + match &result { + Ok(_) => logs.write().await.add("info", "[Qwen] Token 刷新成功"), + Err(e) => logs.write().await.add("error", &format!("[Qwen] Token 刷新失败: {}", e)), + } + result +} + +#[tauri::command] +async fn get_qwen_env_variables(state: tauri::State<'_, AppState>) -> Result, String> { + let s = state.read().await; + let creds = &s.qwen_provider.credentials; + let mut vars = Vec::new(); + + if let Some(token) = &creds.access_token { + vars.push(EnvVariable { + key: "QWEN_ACCESS_TOKEN".to_string(), + value: token.clone(), + masked: mask_token(token), + }); + } + if let Some(token) = &creds.refresh_token { + vars.push(EnvVariable { + key: "QWEN_REFRESH_TOKEN".to_string(), + value: token.clone(), + masked: mask_token(token), + }); + } + if let Some(url) = &creds.resource_url { + vars.push(EnvVariable { + key: "QWEN_RESOURCE_URL".to_string(), + value: url.clone(), + masked: url.clone(), + }); + } + if let Some(expiry) = creds.expiry_date { + let expiry_str = expiry.to_string(); + vars.push(EnvVariable { + key: "QWEN_EXPIRY_DATE".to_string(), + value: expiry_str.clone(), + masked: expiry_str, + }); + } + + Ok(vars) +} + +#[tauri::command] +async fn get_qwen_token_file_hash() -> Result { + let path = providers::qwen::QwenProvider::default_creds_path(); + if !path.exists() { + return Ok("".to_string()); + } + + let content = std::fs::read(&path).map_err(|e| e.to_string())?; + let hash = format!("{:x}", md5::compute(&content)); + Ok(hash) +} + +#[tauri::command] +async fn check_and_reload_qwen_credentials( + state: tauri::State<'_, AppState>, + logs: tauri::State<'_, LogState>, + last_hash: String, +) -> Result { + let path = providers::qwen::QwenProvider::default_creds_path(); + + if !path.exists() { + return Ok(CheckResult { + changed: false, + new_hash: "".to_string(), + reloaded: false, + }); + } + + let content = std::fs::read(&path).map_err(|e| e.to_string())?; + let new_hash = format!("{:x}", md5::compute(&content)); + + if !last_hash.is_empty() && new_hash != last_hash { + logs.write().await.add("info", "[Qwen][自动检测] 凭证文件已变化,正在重新加载..."); + + let mut s = state.write().await; + match s.qwen_provider.load_credentials() { + Ok(_) => { + logs.write().await.add("info", "[Qwen][自动检测] 凭证重新加载成功"); + Ok(CheckResult { + changed: true, + new_hash, + reloaded: true, + }) + } + Err(e) => { + logs.write().await.add("error", &format!("[Qwen][自动检测] 凭证重新加载失败: {}", e)); + Ok(CheckResult { + changed: true, + new_hash, + reloaded: false, + }) + } + } + } else { + Ok(CheckResult { + changed: false, + new_hash, + reloaded: false, + }) + } +} + #[tauri::command] async fn get_logs(logs: tauri::State<'_, LogState>) -> Result, String> { Ok(logs.read().await.get_logs()) @@ -481,6 +631,13 @@ pub fn run() { get_gemini_env_variables, get_gemini_token_file_hash, check_and_reload_gemini_credentials, + // Qwen commands + get_qwen_credentials, + reload_qwen_credentials, + refresh_qwen_token, + get_qwen_env_variables, + get_qwen_token_file_hash, + check_and_reload_qwen_credentials, // Common get_logs, clear_logs, diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index 86d8340c2..8fcd92feb 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -1,5 +1,7 @@ pub mod kiro; pub mod gemini; +pub mod qwen; pub use kiro::KiroProvider; pub use gemini::GeminiProvider; +pub use qwen::QwenProvider; diff --git a/src-tauri/src/providers/qwen.rs b/src-tauri/src/providers/qwen.rs new file mode 100644 index 000000000..fcf858697 --- /dev/null +++ b/src-tauri/src/providers/qwen.rs @@ -0,0 +1,195 @@ +//! Qwen (通义千问) OAuth Provider +use serde::{Deserialize, Serialize}; +use std::error::Error; +use std::path::PathBuf; +use reqwest::Client; + +// Constants +const QWEN_DIR: &str = ".qwen"; +const CREDENTIALS_FILE: &str = "oauth_creds.json"; +const QWEN_BASE_URL: &str = "https://portal.qwen.ai/v1"; + +pub const QWEN_MODELS: &[&str] = &[ + "qwen3-coder-plus", + "qwen3-coder-flash", +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QwenCredentials { + pub access_token: Option, + pub refresh_token: Option, + pub token_type: Option, + pub resource_url: Option, + pub expiry_date: Option, +} + +impl Default for QwenCredentials { + fn default() -> Self { + Self { + access_token: None, + refresh_token: None, + token_type: Some("Bearer".to_string()), + resource_url: None, + expiry_date: None, + } + } +} + +pub struct QwenProvider { + pub credentials: QwenCredentials, + pub client: Client, +} + +impl QwenProvider { + pub fn new() -> Self { + Self { + credentials: QwenCredentials::default(), + client: Client::new(), + } + } + + pub fn default_creds_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(QWEN_DIR) + .join(CREDENTIALS_FILE) + } + + pub fn load_credentials(&mut self) -> Result<(), Box> { + let path = Self::default_creds_path(); + + if path.exists() { + let content = std::fs::read_to_string(&path)?; + let creds: QwenCredentials = serde_json::from_str(&content)?; + self.credentials = creds; + } + + Ok(()) + } + + pub fn save_credentials(&self) -> Result<(), Box> { + let path = Self::default_creds_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let content = serde_json::to_string_pretty(&self.credentials)?; + std::fs::write(&path, content)?; + Ok(()) + } + + pub fn is_token_valid(&self) -> bool { + if self.credentials.access_token.is_none() { + return false; + } + if let Some(expiry) = self.credentials.expiry_date { + let now = chrono::Utc::now().timestamp_millis(); + // Token valid if more than 30 seconds until expiry + return expiry > now + 30_000; + } + true + } + + pub fn get_base_url(&self) -> String { + self.credentials.resource_url + .as_ref() + .map(|url| { + let normalized = if url.starts_with("http") { + url.clone() + } else { + format!("https://{}", url) + }; + if normalized.ends_with("/v1") { + normalized + } else { + format!("{}/v1", normalized) + } + }) + .unwrap_or_else(|| QWEN_BASE_URL.to_string()) + } + + pub async fn refresh_token(&mut self) -> Result> { + let refresh_token = self.credentials.refresh_token.as_ref() + .ok_or("No refresh token available")?; + + let client_id = std::env::var("QWEN_OAUTH_CLIENT_ID") + .ok() + .or_else(|| Some("f0304373b74a44d2b584a3fb70ca9e56".to_string())) + .unwrap(); + + let body = serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id + }); + + let resp = self.client + .post("https://chat.qwen.ai/api/v1/oauth2/token") + .header("Content-Type", "application/json") + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Token refresh failed: {} - {}", status, body).into()); + } + + let data: serde_json::Value = resp.json().await?; + + let new_token = data["access_token"] + .as_str() + .ok_or("No access token in response")?; + + self.credentials.access_token = Some(new_token.to_string()); + + if let Some(rt) = data["refresh_token"].as_str() { + self.credentials.refresh_token = Some(rt.to_string()); + } + + if let Some(resource_url) = data["resource_url"].as_str() { + self.credentials.resource_url = Some(resource_url.to_string()); + } + + if let Some(expires_in) = data["expires_in"].as_i64() { + self.credentials.expiry_date = Some( + chrono::Utc::now().timestamp_millis() + expires_in * 1000 + ); + } + + // Save refreshed credentials + self.save_credentials()?; + + Ok(new_token.to_string()) + } + + pub async fn chat_completions( + &self, + request: &serde_json::Value, + ) -> Result> { + let token = self.credentials.access_token.as_ref() + .ok_or("No access token")?; + + let base_url = self.get_base_url(); + let url = format!("{}/chat/completions", base_url); + + // Ensure model is valid + let mut req_body = request.clone(); + if let Some(model) = req_body.get("model").and_then(|m| m.as_str()) { + if !QWEN_MODELS.contains(&model) { + req_body["model"] = serde_json::json!(QWEN_MODELS[0]); + } + } + + let resp = self.client + .post(&url) + .header("Authorization", format!("Bearer {}", token)) + .header("Content-Type", "application/json") + .header("X-DashScope-AuthType", "qwen-oauth") + .json(&req_body) + .send() + .await?; + + Ok(resp) + } +} diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs index 2552ecc7f..0203cc94b 100644 --- a/src-tauri/src/server.rs +++ b/src-tauri/src/server.rs @@ -5,6 +5,7 @@ use crate::models::anthropic::*; use crate::converter::anthropic_to_openai::convert_anthropic_to_openai; use crate::providers::kiro::KiroProvider; use crate::providers::gemini::GeminiProvider; +use crate::providers::qwen::QwenProvider; use crate::logger::LogStore; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -33,6 +34,7 @@ pub struct ServerState { pub start_time: Option, pub kiro_provider: KiroProvider, pub gemini_provider: GeminiProvider, + pub qwen_provider: QwenProvider, shutdown_tx: Option>, } @@ -44,6 +46,9 @@ impl ServerState { let mut gemini = GeminiProvider::new(); let _ = gemini.load_credentials(); + let mut qwen = QwenProvider::new(); + let _ = qwen.load_credentials(); + Self { config, running: false, @@ -51,6 +56,7 @@ impl ServerState { start_time: None, kiro_provider: kiro, gemini_provider: gemini, + qwen_provider: qwen, shutdown_tx: None, } } @@ -174,7 +180,10 @@ async fn models() -> impl IntoResponse { {"id": "gemini-2.5-flash-lite", "object": "model", "owned_by": "google"}, {"id": "gemini-2.5-pro", "object": "model", "owned_by": "google"}, {"id": "gemini-2.5-pro-preview-06-05", "object": "model", "owned_by": "google"}, - {"id": "gemini-3-pro-preview", "object": "model", "owned_by": "google"} + {"id": "gemini-3-pro-preview", "object": "model", "owned_by": "google"}, + // Qwen models + {"id": "qwen3-coder-plus", "object": "model", "owned_by": "alibaba"}, + {"id": "qwen3-coder-flash", "object": "model", "owned_by": "alibaba"} ] })) } diff --git a/src/hooks/useTauri.ts b/src/hooks/useTauri.ts index eeec3e458..8e1e9b8e8 100644 --- a/src/hooks/useTauri.ts +++ b/src/hooks/useTauri.ts @@ -181,3 +181,39 @@ export async function getGeminiTokenFileHash(): Promise { export async function checkAndReloadGeminiCredentials(lastHash: string): Promise { return invoke("check_and_reload_gemini_credentials", { last_hash: lastHash }); } + + +// ============ Qwen Provider ============ + +export interface QwenCredentialStatus { + loaded: boolean; + has_access_token: boolean; + has_refresh_token: boolean; + expiry_date: number | null; + is_valid: boolean; + creds_path: string; +} + +export async function getQwenCredentials(): Promise { + return invoke("get_qwen_credentials"); +} + +export async function reloadQwenCredentials(): Promise { + return invoke("reload_qwen_credentials"); +} + +export async function refreshQwenToken(): Promise { + return invoke("refresh_qwen_token"); +} + +export async function getQwenEnvVariables(): Promise { + return invoke("get_qwen_env_variables"); +} + +export async function getQwenTokenFileHash(): Promise { + return invoke("get_qwen_token_file_hash"); +} + +export async function checkAndReloadQwenCredentials(lastHash: string): Promise { + return invoke("check_and_reload_qwen_credentials", { last_hash: lastHash }); +}