feat(provider): add qwen-code oauth credential support
This commit is contained in:
parent
e9c280324f
commit
bca58acdcb
3 changed files with 501 additions and 6 deletions
|
|
@ -12,7 +12,8 @@ use crate::memory::{
|
|||
};
|
||||
use crate::providers::{
|
||||
canonical_china_provider_name, is_glm_alias, is_glm_cn_alias, is_minimax_alias,
|
||||
is_moonshot_alias, is_qianfan_alias, is_qwen_alias, is_zai_alias, is_zai_cn_alias,
|
||||
is_moonshot_alias, is_qianfan_alias, is_qwen_alias, is_qwen_oauth_alias, is_zai_alias,
|
||||
is_zai_cn_alias,
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use console::style;
|
||||
|
|
@ -497,6 +498,10 @@ pub async fn run_quick_setup(
|
|||
}
|
||||
|
||||
fn canonical_provider_name(provider_name: &str) -> &str {
|
||||
if is_qwen_oauth_alias(provider_name) {
|
||||
return "qwen-code";
|
||||
}
|
||||
|
||||
if let Some(canonical) = canonical_china_provider_name(provider_name) {
|
||||
return canonical;
|
||||
}
|
||||
|
|
@ -547,6 +552,7 @@ fn default_model_for_provider(provider: &str) -> String {
|
|||
"glm" | "zai" => "glm-5".into(),
|
||||
"minimax" => "MiniMax-M2.5".into(),
|
||||
"qwen" => "qwen-plus".into(),
|
||||
"qwen-code" => "qwen3-coder-plus".into(),
|
||||
"ollama" => "llama3.2".into(),
|
||||
"gemini" => "gemini-2.5-pro".into(),
|
||||
"kimi-code" => "kimi-for-coding".into(),
|
||||
|
|
@ -823,6 +829,20 @@ fn curated_models_for_provider(provider_name: &str) -> Vec<(String, String)> {
|
|||
"Qwen Turbo (fast and cost-efficient)".to_string(),
|
||||
),
|
||||
],
|
||||
"qwen-code" => vec![
|
||||
(
|
||||
"qwen3-coder-plus".to_string(),
|
||||
"Qwen3 Coder Plus (recommended for coding workflows)".to_string(),
|
||||
),
|
||||
(
|
||||
"qwen3.5-plus".to_string(),
|
||||
"Qwen3.5 Plus (reasoning + coding)".to_string(),
|
||||
),
|
||||
(
|
||||
"qwen3-max-2026-01-23".to_string(),
|
||||
"Qwen3 Max (high-capability coding model)".to_string(),
|
||||
),
|
||||
],
|
||||
"nvidia" => vec![
|
||||
(
|
||||
"meta/llama-3.3-70b-instruct".to_string(),
|
||||
|
|
@ -1601,6 +1621,10 @@ fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, Optio
|
|||
"kimi-code",
|
||||
"Kimi Code — coding-optimized Kimi API (KimiCLI)",
|
||||
),
|
||||
(
|
||||
"qwen-code",
|
||||
"Qwen Code — OAuth tokens reused from ~/.qwen/oauth_creds.json",
|
||||
),
|
||||
("moonshot", "Moonshot — Kimi API (China endpoint)"),
|
||||
(
|
||||
"moonshot-intl",
|
||||
|
|
@ -1809,11 +1833,48 @@ fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, Optio
|
|||
|
||||
key
|
||||
}
|
||||
} else if canonical_provider_name(provider_name) == "qwen-code" {
|
||||
if std::env::var("QWEN_OAUTH_TOKEN").is_ok() {
|
||||
print_bullet(&format!(
|
||||
"{} QWEN_OAUTH_TOKEN environment variable detected!",
|
||||
style("✓").green().bold()
|
||||
));
|
||||
"qwen-oauth".to_string()
|
||||
} else {
|
||||
print_bullet(
|
||||
"Qwen Code OAuth credentials are usually stored in ~/.qwen/oauth_creds.json.",
|
||||
);
|
||||
print_bullet(
|
||||
"Run `qwen` once and complete OAuth login to populate cached credentials.",
|
||||
);
|
||||
print_bullet("You can also set QWEN_OAUTH_TOKEN directly.");
|
||||
println!();
|
||||
|
||||
let key: String = Input::new()
|
||||
.with_prompt(
|
||||
" Paste your Qwen OAuth token (or press Enter to auto-detect cached OAuth)",
|
||||
)
|
||||
.allow_empty(true)
|
||||
.interact_text()?;
|
||||
|
||||
if key.trim().is_empty() {
|
||||
print_bullet(&format!(
|
||||
"Using OAuth auto-detection. Set {} and optional {} if needed.",
|
||||
style("QWEN_OAUTH_TOKEN").yellow(),
|
||||
style("QWEN_OAUTH_RESOURCE_URL").yellow()
|
||||
));
|
||||
"qwen-oauth".to_string()
|
||||
} else {
|
||||
key
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let key_url = if is_moonshot_alias(provider_name)
|
||||
|| canonical_provider_name(provider_name) == "kimi-code"
|
||||
{
|
||||
"https://platform.moonshot.cn/console/api-keys"
|
||||
} else if canonical_provider_name(provider_name) == "qwen-code" {
|
||||
"https://qwen.readthedocs.io/en/latest/getting_started/installation.html"
|
||||
} else if is_glm_cn_alias(provider_name) || is_zai_cn_alias(provider_name) {
|
||||
"https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys"
|
||||
} else if is_glm_alias(provider_name) || is_zai_alias(provider_name) {
|
||||
|
|
@ -2066,6 +2127,10 @@ fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, Optio
|
|||
|
||||
/// Map provider name to its conventional env var
|
||||
fn provider_env_var(name: &str) -> &'static str {
|
||||
if canonical_provider_name(name) == "qwen-code" {
|
||||
return "QWEN_OAUTH_TOKEN";
|
||||
}
|
||||
|
||||
match canonical_provider_name(name) {
|
||||
"openrouter" => "OPENROUTER_API_KEY",
|
||||
"anthropic" => "ANTHROPIC_API_KEY",
|
||||
|
|
@ -5044,6 +5109,7 @@ mod tests {
|
|||
);
|
||||
assert_eq!(default_model_for_provider("qwen"), "qwen-plus");
|
||||
assert_eq!(default_model_for_provider("qwen-intl"), "qwen-plus");
|
||||
assert_eq!(default_model_for_provider("qwen-code"), "qwen3-coder-plus");
|
||||
assert_eq!(default_model_for_provider("glm-cn"), "glm-5");
|
||||
assert_eq!(default_model_for_provider("minimax-cn"), "MiniMax-M2.5");
|
||||
assert_eq!(default_model_for_provider("zai-cn"), "glm-5");
|
||||
|
|
@ -5078,6 +5144,8 @@ mod tests {
|
|||
fn canonical_provider_name_normalizes_regional_aliases() {
|
||||
assert_eq!(canonical_provider_name("qwen-intl"), "qwen");
|
||||
assert_eq!(canonical_provider_name("dashscope-us"), "qwen");
|
||||
assert_eq!(canonical_provider_name("qwen-code"), "qwen-code");
|
||||
assert_eq!(canonical_provider_name("qwen-oauth"), "qwen-code");
|
||||
assert_eq!(canonical_provider_name("moonshot-intl"), "moonshot");
|
||||
assert_eq!(canonical_provider_name("kimi-cn"), "moonshot");
|
||||
assert_eq!(canonical_provider_name("kimi_coding"), "kimi-code");
|
||||
|
|
@ -5188,6 +5256,18 @@ mod tests {
|
|||
assert!(ids.contains(&"kimi-k2.5".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curated_models_for_qwen_code_include_coding_plan_models() {
|
||||
let ids: Vec<String> = curated_models_for_provider("qwen-code")
|
||||
.into_iter()
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
|
||||
assert!(ids.contains(&"qwen3-coder-plus".to_string()));
|
||||
assert!(ids.contains(&"qwen3.5-plus".to_string()));
|
||||
assert!(ids.contains(&"qwen3-max-2026-01-23".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_live_model_fetch_for_supported_and_unsupported_providers() {
|
||||
assert!(supports_live_model_fetch("openai"));
|
||||
|
|
@ -5459,6 +5539,8 @@ mod tests {
|
|||
assert_eq!(provider_env_var("qwen"), "DASHSCOPE_API_KEY");
|
||||
assert_eq!(provider_env_var("qwen-intl"), "DASHSCOPE_API_KEY");
|
||||
assert_eq!(provider_env_var("dashscope-us"), "DASHSCOPE_API_KEY");
|
||||
assert_eq!(provider_env_var("qwen-code"), "QWEN_OAUTH_TOKEN");
|
||||
assert_eq!(provider_env_var("qwen-oauth"), "QWEN_OAUTH_TOKEN");
|
||||
assert_eq!(provider_env_var("glm-cn"), "GLM_API_KEY");
|
||||
assert_eq!(provider_env_var("minimax-cn"), "MINIMAX_API_KEY");
|
||||
assert_eq!(provider_env_var("kimi-code"), "KIMI_CODE_API_KEY");
|
||||
|
|
|
|||
|
|
@ -42,6 +42,15 @@ const MOONSHOT_CN_BASE_URL: &str = "https://api.moonshot.cn/v1";
|
|||
const QWEN_CN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||
const QWEN_INTL_BASE_URL: &str = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
|
||||
const QWEN_US_BASE_URL: &str = "https://dashscope-us.aliyuncs.com/compatible-mode/v1";
|
||||
const QWEN_OAUTH_BASE_FALLBACK_URL: &str = QWEN_CN_BASE_URL;
|
||||
const QWEN_OAUTH_TOKEN_ENDPOINT: &str = "https://chat.qwen.ai/api/v1/oauth2/token";
|
||||
const QWEN_OAUTH_PLACEHOLDER: &str = "qwen-oauth";
|
||||
const QWEN_OAUTH_TOKEN_ENV: &str = "QWEN_OAUTH_TOKEN";
|
||||
const QWEN_OAUTH_REFRESH_TOKEN_ENV: &str = "QWEN_OAUTH_REFRESH_TOKEN";
|
||||
const QWEN_OAUTH_RESOURCE_URL_ENV: &str = "QWEN_OAUTH_RESOURCE_URL";
|
||||
const QWEN_OAUTH_CLIENT_ID_ENV: &str = "QWEN_OAUTH_CLIENT_ID";
|
||||
const QWEN_OAUTH_DEFAULT_CLIENT_ID: &str = "f0304373b74a44d2b584a3fb70ca9e56";
|
||||
const QWEN_OAUTH_CREDENTIAL_FILE: &str = ".qwen/oauth_creds.json";
|
||||
const ZAI_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
|
||||
const ZAI_CN_BASE_URL: &str = "https://open.bigmodel.cn/api/coding/paas/v4";
|
||||
|
||||
|
|
@ -112,8 +121,15 @@ pub(crate) fn is_qwen_us_alias(name: &str) -> bool {
|
|||
matches!(name, "qwen-us" | "dashscope-us")
|
||||
}
|
||||
|
||||
pub(crate) fn is_qwen_oauth_alias(name: &str) -> bool {
|
||||
matches!(name, "qwen-code" | "qwen-oauth" | "qwen_oauth")
|
||||
}
|
||||
|
||||
pub(crate) fn is_qwen_alias(name: &str) -> bool {
|
||||
is_qwen_cn_alias(name) || is_qwen_intl_alias(name) || is_qwen_us_alias(name)
|
||||
is_qwen_cn_alias(name)
|
||||
|| is_qwen_intl_alias(name)
|
||||
|| is_qwen_us_alias(name)
|
||||
|| is_qwen_oauth_alias(name)
|
||||
}
|
||||
|
||||
pub(crate) fn is_zai_global_alias(name: &str) -> bool {
|
||||
|
|
@ -163,6 +179,40 @@ struct MinimaxOauthBaseResponse {
|
|||
status_msg: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
struct QwenOauthCredentials {
|
||||
#[serde(default)]
|
||||
access_token: Option<String>,
|
||||
#[serde(default)]
|
||||
refresh_token: Option<String>,
|
||||
#[serde(default)]
|
||||
resource_url: Option<String>,
|
||||
#[serde(default)]
|
||||
expiry_date: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct QwenOauthTokenResponse {
|
||||
#[serde(default)]
|
||||
access_token: Option<String>,
|
||||
#[serde(default)]
|
||||
refresh_token: Option<String>,
|
||||
#[serde(default)]
|
||||
expires_in: Option<i64>,
|
||||
#[serde(default)]
|
||||
resource_url: Option<String>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
#[serde(default)]
|
||||
error_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct QwenOauthProviderContext {
|
||||
credential: Option<String>,
|
||||
base_url: Option<String>,
|
||||
}
|
||||
|
||||
fn read_non_empty_env(name: &str) -> Option<String> {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
|
|
@ -198,6 +248,233 @@ fn minimax_oauth_client_id() -> String {
|
|||
.unwrap_or_else(|| MINIMAX_OAUTH_DEFAULT_CLIENT_ID.to_string())
|
||||
}
|
||||
|
||||
fn qwen_oauth_client_id() -> String {
|
||||
read_non_empty_env(QWEN_OAUTH_CLIENT_ID_ENV)
|
||||
.unwrap_or_else(|| QWEN_OAUTH_DEFAULT_CLIENT_ID.to_string())
|
||||
}
|
||||
|
||||
fn qwen_oauth_credentials_file_path() -> Option<PathBuf> {
|
||||
std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
|
||||
.map(|home| home.join(QWEN_OAUTH_CREDENTIAL_FILE))
|
||||
}
|
||||
|
||||
fn normalize_qwen_oauth_base_url(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim().trim_end_matches('/');
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("https://{trimmed}")
|
||||
};
|
||||
|
||||
let normalized = with_scheme.trim_end_matches('/').to_string();
|
||||
if normalized.ends_with("/v1") {
|
||||
Some(normalized)
|
||||
} else {
|
||||
Some(format!("{normalized}/v1"))
|
||||
}
|
||||
}
|
||||
|
||||
fn read_qwen_oauth_cached_credentials() -> Option<QwenOauthCredentials> {
|
||||
let path = qwen_oauth_credentials_file_path()?;
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str::<QwenOauthCredentials>(&content).ok()
|
||||
}
|
||||
|
||||
fn normalized_qwen_expiry_millis(raw: i64) -> i64 {
|
||||
if raw < 10_000_000_000 {
|
||||
raw.saturating_mul(1000)
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
}
|
||||
|
||||
fn qwen_oauth_token_expired(credentials: &QwenOauthCredentials) -> bool {
|
||||
let Some(expiry) = credentials.expiry_date else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let expiry_millis = normalized_qwen_expiry_millis(expiry);
|
||||
let now_millis = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|duration| i64::try_from(duration.as_millis()).ok())
|
||||
.unwrap_or(i64::MAX);
|
||||
|
||||
expiry_millis <= now_millis.saturating_add(30_000)
|
||||
}
|
||||
|
||||
fn refresh_qwen_oauth_access_token(refresh_token: &str) -> anyhow::Result<QwenOauthCredentials> {
|
||||
let client_id = qwen_oauth_client_id();
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.connect_timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::blocking::Client::new());
|
||||
|
||||
let response = client
|
||||
.post(QWEN_OAUTH_TOKEN_ENDPOINT)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Accept", "application/json")
|
||||
.form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
("refresh_token", refresh_token),
|
||||
("client_id", client_id.as_str()),
|
||||
])
|
||||
.send()
|
||||
.map_err(|error| anyhow::anyhow!("Qwen OAuth refresh request failed: {error}"))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.unwrap_or_else(|_| "<failed to read Qwen OAuth response body>".to_string());
|
||||
|
||||
let parsed = serde_json::from_str::<QwenOauthTokenResponse>(&body).ok();
|
||||
|
||||
if !status.is_success() {
|
||||
let detail = parsed
|
||||
.as_ref()
|
||||
.and_then(|payload| payload.error_description.as_deref())
|
||||
.or_else(|| parsed.as_ref().and_then(|payload| payload.error.as_deref()))
|
||||
.filter(|msg| !msg.trim().is_empty())
|
||||
.unwrap_or(body.as_str());
|
||||
anyhow::bail!("Qwen OAuth refresh failed (HTTP {status}): {detail}");
|
||||
}
|
||||
|
||||
let payload =
|
||||
parsed.ok_or_else(|| anyhow::anyhow!("Qwen OAuth refresh response is not JSON"))?;
|
||||
|
||||
if let Some(error_code) = payload
|
||||
.error
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
let detail = payload.error_description.as_deref().unwrap_or(error_code);
|
||||
anyhow::bail!("Qwen OAuth refresh failed: {detail}");
|
||||
}
|
||||
|
||||
let access_token = payload
|
||||
.access_token
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|token| !token.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("Qwen OAuth refresh response missing access_token"))?
|
||||
.to_string();
|
||||
|
||||
let expiry_date = payload.expires_in.and_then(|seconds| {
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|duration| i64::try_from(duration.as_secs()).ok())?;
|
||||
now_secs
|
||||
.checked_add(seconds)
|
||||
.and_then(|unix_secs| unix_secs.checked_mul(1000))
|
||||
});
|
||||
|
||||
Ok(QwenOauthCredentials {
|
||||
access_token: Some(access_token),
|
||||
refresh_token: payload
|
||||
.refresh_token
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string),
|
||||
resource_url: payload
|
||||
.resource_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string),
|
||||
expiry_date,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_qwen_oauth_context(credential_override: Option<&str>) -> QwenOauthProviderContext {
|
||||
let override_value = credential_override
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let placeholder_requested = override_value
|
||||
.map(|value| value.eq_ignore_ascii_case(QWEN_OAUTH_PLACEHOLDER))
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Some(explicit) = override_value {
|
||||
if !placeholder_requested {
|
||||
return QwenOauthProviderContext {
|
||||
credential: Some(explicit.to_string()),
|
||||
base_url: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let mut cached = read_qwen_oauth_cached_credentials();
|
||||
|
||||
let env_token = read_non_empty_env(QWEN_OAUTH_TOKEN_ENV);
|
||||
let env_refresh_token = read_non_empty_env(QWEN_OAUTH_REFRESH_TOKEN_ENV);
|
||||
let env_resource_url = read_non_empty_env(QWEN_OAUTH_RESOURCE_URL_ENV);
|
||||
|
||||
if env_token.is_none() {
|
||||
let refresh_token = env_refresh_token.clone().or_else(|| {
|
||||
cached
|
||||
.as_ref()
|
||||
.and_then(|credentials| credentials.refresh_token.clone())
|
||||
});
|
||||
|
||||
let should_refresh = cached.as_ref().is_some_and(qwen_oauth_token_expired)
|
||||
|| cached
|
||||
.as_ref()
|
||||
.and_then(|credentials| credentials.access_token.as_deref())
|
||||
.is_none_or(|value| value.trim().is_empty());
|
||||
|
||||
if should_refresh {
|
||||
if let Some(refresh_token) = refresh_token.as_deref() {
|
||||
match refresh_qwen_oauth_access_token(refresh_token) {
|
||||
Ok(refreshed) => {
|
||||
cached = Some(refreshed);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error, "Qwen OAuth refresh failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut credential = env_token.or_else(|| {
|
||||
cached
|
||||
.as_ref()
|
||||
.and_then(|credentials| credentials.access_token.clone())
|
||||
});
|
||||
credential = credential
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string);
|
||||
|
||||
if credential.is_none() && !placeholder_requested {
|
||||
credential = read_non_empty_env("DASHSCOPE_API_KEY");
|
||||
}
|
||||
|
||||
let base_url = env_resource_url
|
||||
.as_deref()
|
||||
.and_then(normalize_qwen_oauth_base_url)
|
||||
.or_else(|| {
|
||||
cached
|
||||
.as_ref()
|
||||
.and_then(|credentials| credentials.resource_url.as_deref())
|
||||
.and_then(normalize_qwen_oauth_base_url)
|
||||
});
|
||||
|
||||
QwenOauthProviderContext {
|
||||
credential,
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_minimax_static_credential() -> Option<String> {
|
||||
read_non_empty_env(MINIMAX_OAUTH_TOKEN_ENV).or_else(|| read_non_empty_env(MINIMAX_API_KEY_ENV))
|
||||
}
|
||||
|
|
@ -599,11 +876,17 @@ pub fn create_provider_with_url(
|
|||
api_key: Option<&str>,
|
||||
api_url: Option<&str>,
|
||||
) -> anyhow::Result<Box<dyn Provider>> {
|
||||
let qwen_oauth_context = is_qwen_oauth_alias(name).then(|| resolve_qwen_oauth_context(api_key));
|
||||
|
||||
// Resolve credential and break static-analysis taint chain from the
|
||||
// `api_key` parameter so that downstream provider storage of the value
|
||||
// is not linked to the original sensitive-named source.
|
||||
let resolved_credential = resolve_provider_credential(name, api_key)
|
||||
.map(|v| String::from_utf8(v.into_bytes()).unwrap_or_default());
|
||||
let resolved_credential = if let Some(context) = qwen_oauth_context.as_ref() {
|
||||
context.credential.clone()
|
||||
} else {
|
||||
resolve_provider_credential(name, api_key)
|
||||
}
|
||||
.map(|v| String::from_utf8(v.into_bytes()).unwrap_or_default());
|
||||
#[allow(clippy::option_as_ref_deref)]
|
||||
let key = resolved_credential.as_ref().map(String::as_str);
|
||||
match name {
|
||||
|
|
@ -674,6 +957,22 @@ pub fn create_provider_with_url(
|
|||
)
|
||||
)),
|
||||
"bedrock" | "aws-bedrock" => Ok(Box::new(bedrock::BedrockProvider::new())),
|
||||
name if is_qwen_oauth_alias(name) => {
|
||||
let base_url = api_url
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| qwen_oauth_context.as_ref().and_then(|context| context.base_url.clone()))
|
||||
.unwrap_or_else(|| QWEN_OAUTH_BASE_FALLBACK_URL.to_string());
|
||||
|
||||
Ok(Box::new(OpenAiCompatibleProvider::new_with_user_agent(
|
||||
"Qwen Code",
|
||||
&base_url,
|
||||
key,
|
||||
AuthStyle::Bearer,
|
||||
"QwenCode/1.0",
|
||||
)))
|
||||
}
|
||||
name if is_qianfan_alias(name) => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"Qianfan", "https://aip.baidubce.com", key, AuthStyle::Bearer,
|
||||
))),
|
||||
|
|
@ -1063,13 +1362,16 @@ pub fn list_providers() -> Vec<ProviderInfo> {
|
|||
},
|
||||
ProviderInfo {
|
||||
name: "qwen",
|
||||
display_name: "Qwen (DashScope)",
|
||||
display_name: "Qwen (DashScope / Qwen Code OAuth)",
|
||||
aliases: &[
|
||||
"dashscope",
|
||||
"qwen-intl",
|
||||
"dashscope-intl",
|
||||
"qwen-us",
|
||||
"dashscope-us",
|
||||
"qwen-code",
|
||||
"qwen-oauth",
|
||||
"qwen_oauth",
|
||||
],
|
||||
local: false,
|
||||
},
|
||||
|
|
@ -1232,6 +1534,87 @@ mod tests {
|
|||
assert!(resolve_provider_credential("aws-bedrock", None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_qwen_oauth_context_prefers_explicit_override() {
|
||||
let fake_home = format!("/tmp/zeroclaw-qwen-oauth-home-{}", std::process::id());
|
||||
let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
|
||||
let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, Some("oauth-token"));
|
||||
let _resource_guard = EnvGuard::set(
|
||||
QWEN_OAUTH_RESOURCE_URL_ENV,
|
||||
Some("coding-intl.dashscope.aliyuncs.com"),
|
||||
);
|
||||
|
||||
let context = resolve_qwen_oauth_context(Some(" explicit-qwen-token "));
|
||||
|
||||
assert_eq!(context.credential.as_deref(), Some("explicit-qwen-token"));
|
||||
assert!(context.base_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_qwen_oauth_context_uses_env_token_and_resource_url() {
|
||||
let fake_home = format!("/tmp/zeroclaw-qwen-oauth-home-{}-env", std::process::id());
|
||||
let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
|
||||
let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, Some("oauth-token"));
|
||||
let _refresh_guard = EnvGuard::set(QWEN_OAUTH_REFRESH_TOKEN_ENV, None);
|
||||
let _resource_guard = EnvGuard::set(
|
||||
QWEN_OAUTH_RESOURCE_URL_ENV,
|
||||
Some("coding-intl.dashscope.aliyuncs.com"),
|
||||
);
|
||||
let _dashscope_guard = EnvGuard::set("DASHSCOPE_API_KEY", Some("dashscope-fallback"));
|
||||
|
||||
let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
|
||||
|
||||
assert_eq!(context.credential.as_deref(), Some("oauth-token"));
|
||||
assert_eq!(
|
||||
context.base_url.as_deref(),
|
||||
Some("https://coding-intl.dashscope.aliyuncs.com/v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_qwen_oauth_context_reads_cached_credentials_file() {
|
||||
let fake_home = format!("/tmp/zeroclaw-qwen-oauth-home-{}-file", std::process::id());
|
||||
let creds_dir = PathBuf::from(&fake_home).join(".qwen");
|
||||
std::fs::create_dir_all(&creds_dir).unwrap();
|
||||
let creds_path = creds_dir.join("oauth_creds.json");
|
||||
std::fs::write(
|
||||
&creds_path,
|
||||
r#"{"access_token":"cached-token","refresh_token":"cached-refresh","resource_url":"https://resource.example.com","expiry_date":4102444800000}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
|
||||
let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, None);
|
||||
let _refresh_guard = EnvGuard::set(QWEN_OAUTH_REFRESH_TOKEN_ENV, None);
|
||||
let _resource_guard = EnvGuard::set(QWEN_OAUTH_RESOURCE_URL_ENV, None);
|
||||
let _dashscope_guard = EnvGuard::set("DASHSCOPE_API_KEY", None);
|
||||
|
||||
let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
|
||||
|
||||
assert_eq!(context.credential.as_deref(), Some("cached-token"));
|
||||
assert_eq!(
|
||||
context.base_url.as_deref(),
|
||||
Some("https://resource.example.com/v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_qwen_oauth_context_placeholder_does_not_use_dashscope_fallback() {
|
||||
let fake_home = format!(
|
||||
"/tmp/zeroclaw-qwen-oauth-home-{}-placeholder",
|
||||
std::process::id()
|
||||
);
|
||||
let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
|
||||
let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, None);
|
||||
let _refresh_guard = EnvGuard::set(QWEN_OAUTH_REFRESH_TOKEN_ENV, None);
|
||||
let _resource_guard = EnvGuard::set(QWEN_OAUTH_RESOURCE_URL_ENV, None);
|
||||
let _dashscope_guard = EnvGuard::set("DASHSCOPE_API_KEY", Some("dashscope-fallback"));
|
||||
|
||||
let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
|
||||
|
||||
assert!(context.credential.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regional_alias_predicates_cover_expected_variants() {
|
||||
assert!(is_moonshot_alias("moonshot"));
|
||||
|
|
@ -1244,6 +1627,9 @@ mod tests {
|
|||
assert!(is_minimax_alias("minimax-portal-cn"));
|
||||
assert!(is_qwen_alias("dashscope"));
|
||||
assert!(is_qwen_alias("qwen-us"));
|
||||
assert!(is_qwen_alias("qwen-code"));
|
||||
assert!(is_qwen_oauth_alias("qwen-code"));
|
||||
assert!(is_qwen_oauth_alias("qwen_oauth"));
|
||||
assert!(is_zai_alias("z.ai"));
|
||||
assert!(is_zai_alias("zai-cn"));
|
||||
assert!(is_qianfan_alias("qianfan"));
|
||||
|
|
@ -1266,6 +1652,7 @@ mod tests {
|
|||
assert_eq!(canonical_china_provider_name("minimax-cn"), Some("minimax"));
|
||||
assert_eq!(canonical_china_provider_name("qwen"), Some("qwen"));
|
||||
assert_eq!(canonical_china_provider_name("dashscope-us"), Some("qwen"));
|
||||
assert_eq!(canonical_china_provider_name("qwen-code"), Some("qwen"));
|
||||
assert_eq!(canonical_china_provider_name("zai"), Some("zai"));
|
||||
assert_eq!(canonical_china_provider_name("z.ai-cn"), Some("zai"));
|
||||
assert_eq!(canonical_china_provider_name("qianfan"), Some("qianfan"));
|
||||
|
|
@ -1296,6 +1683,7 @@ mod tests {
|
|||
assert_eq!(qwen_base_url("qwen-cn"), Some(QWEN_CN_BASE_URL));
|
||||
assert_eq!(qwen_base_url("qwen-intl"), Some(QWEN_INTL_BASE_URL));
|
||||
assert_eq!(qwen_base_url("qwen-us"), Some(QWEN_US_BASE_URL));
|
||||
assert_eq!(qwen_base_url("qwen-code"), Some(QWEN_CN_BASE_URL));
|
||||
|
||||
assert_eq!(zai_base_url("zai"), Some(ZAI_GLOBAL_BASE_URL));
|
||||
assert_eq!(zai_base_url("z.ai"), Some(ZAI_GLOBAL_BASE_URL));
|
||||
|
|
@ -1454,6 +1842,8 @@ mod tests {
|
|||
assert!(create_provider("dashscope-international", Some("key")).is_ok());
|
||||
assert!(create_provider("qwen-us", Some("key")).is_ok());
|
||||
assert!(create_provider("dashscope-us", Some("key")).is_ok());
|
||||
assert!(create_provider("qwen-code", Some("key")).is_ok());
|
||||
assert!(create_provider("qwen-oauth", Some("key")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1809,6 +2199,7 @@ mod tests {
|
|||
"qwen-intl",
|
||||
"qwen-cn",
|
||||
"qwen-us",
|
||||
"qwen-code",
|
||||
"lmstudio",
|
||||
"groq",
|
||||
"mistral",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue