feat: add Qwen (通义千问) OAuth provider

- 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
This commit is contained in:
coso
2025-12-14 03:13:04 +08:00
parent d41939ef42
commit 4c2840e08f
5 changed files with 400 additions and 1 deletions
+157
View File
@@ -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<i64>,
is_valid: bool,
creds_path: String,
}
#[tauri::command]
async fn get_qwen_credentials(state: tauri::State<'_, AppState>) -> Result<QwenCredentialStatus, String> {
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<String, String> {
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<String, String> {
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<Vec<EnvVariable>, 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<String, String> {
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<CheckResult, String> {
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<Vec<logger::LogEntry>, 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,
+2
View File
@@ -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;
+195
View File
@@ -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<String>,
pub refresh_token: Option<String>,
pub token_type: Option<String>,
pub resource_url: Option<String>,
pub expiry_date: Option<i64>,
}
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<dyn Error + Send + Sync>> {
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<dyn Error + Send + Sync>> {
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<String, Box<dyn Error + Send + Sync>> {
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<reqwest::Response, Box<dyn Error + Send + Sync>> {
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)
}
}
+10 -1
View File
@@ -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<std::time::Instant>,
pub kiro_provider: KiroProvider,
pub gemini_provider: GeminiProvider,
pub qwen_provider: QwenProvider,
shutdown_tx: Option<oneshot::Sender<()>>,
}
@@ -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"}
]
}))
}
+36
View File
@@ -181,3 +181,39 @@ export async function getGeminiTokenFileHash(): Promise<string> {
export async function checkAndReloadGeminiCredentials(lastHash: string): Promise<CheckResult> {
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<QwenCredentialStatus> {
return invoke("get_qwen_credentials");
}
export async function reloadQwenCredentials(): Promise<string> {
return invoke("reload_qwen_credentials");
}
export async function refreshQwenToken(): Promise<string> {
return invoke("refresh_qwen_token");
}
export async function getQwenEnvVariables(): Promise<EnvVariable[]> {
return invoke("get_qwen_env_variables");
}
export async function getQwenTokenFileHash(): Promise<string> {
return invoke("get_qwen_token_file_hash");
}
export async function checkAndReloadQwenCredentials(lastHash: string): Promise<CheckResult> {
return invoke("check_and_reload_qwen_credentials", { last_hash: lastHash });
}