mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
]
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user