diff --git a/docs/providers-reference.md b/docs/providers-reference.md index 40d1109..5b79b3d 100644 --- a/docs/providers-reference.md +++ b/docs/providers-reference.md @@ -43,7 +43,7 @@ credential is not reused for fallback providers. | `minimax` | `minimax-intl`, `minimax-io`, `minimax-global`, `minimax-cn`, `minimaxi`, `minimax-oauth`, `minimax-oauth-cn`, `minimax-portal`, `minimax-portal-cn` | No | `MINIMAX_OAUTH_TOKEN`, `MINIMAX_API_KEY` | | `bedrock` | `aws-bedrock` | No | `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (optional: `AWS_REGION`) | | `qianfan` | `baidu` | No | `QIANFAN_API_KEY` | -| `qwen` | `dashscope`, `qwen-intl`, `dashscope-intl`, `qwen-us`, `dashscope-us` | No | `DASHSCOPE_API_KEY` | +| `qwen` | `dashscope`, `qwen-intl`, `dashscope-intl`, `qwen-us`, `dashscope-us`, `qwen-code`, `qwen-oauth`, `qwen_oauth` | No | `QWEN_OAUTH_TOKEN`, `DASHSCOPE_API_KEY` | | `groq` | — | No | `GROQ_API_KEY` | | `mistral` | — | No | `MISTRAL_API_KEY` | | `xai` | `grok` | No | `XAI_API_KEY` | @@ -122,6 +122,28 @@ Optional: - `MINIMAX_OAUTH_REGION=global` or `cn` (defaults by provider alias) - `MINIMAX_OAUTH_CLIENT_ID` to override the default OAuth client id +## Qwen Code OAuth Setup (config.toml) + +Set Qwen Code OAuth mode in config: + +```toml +default_provider = "qwen-code" +api_key = "qwen-oauth" +``` + +Credential resolution for `qwen-code`: + +1. Explicit `api_key` value (if not the placeholder `qwen-oauth`) +2. `QWEN_OAUTH_TOKEN` +3. `~/.qwen/oauth_creds.json` (reuses Qwen Code cached OAuth credentials) +4. Optional refresh via `QWEN_OAUTH_REFRESH_TOKEN` (or cached refresh token) +5. If no OAuth placeholder is used, `DASHSCOPE_API_KEY` can still be used as fallback + +Optional endpoint override: + +- `QWEN_OAUTH_RESOURCE_URL` (normalized to `https://.../v1` if needed) +- If unset, `resource_url` from cached OAuth credentials is used when available + ## Model Routing (`hint:`) You can route model calls by hint using `[[model_routes]]`: diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 6618f13..db28394 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -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 = 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"); diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 9a6aead..bc61815 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -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, } +#[derive(Debug, Clone, Deserialize, Default)] +struct QwenOauthCredentials { + #[serde(default)] + access_token: Option, + #[serde(default)] + refresh_token: Option, + #[serde(default)] + resource_url: Option, + #[serde(default)] + expiry_date: Option, +} + +#[derive(Debug, Deserialize)] +struct QwenOauthTokenResponse { + #[serde(default)] + access_token: Option, + #[serde(default)] + refresh_token: Option, + #[serde(default)] + expires_in: Option, + #[serde(default)] + resource_url: Option, + #[serde(default)] + error: Option, + #[serde(default)] + error_description: Option, +} + +#[derive(Debug, Clone, Default)] +struct QwenOauthProviderContext { + credential: Option, + base_url: Option, +} + fn read_non_empty_env(name: &str) -> Option { 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 { + 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 { + 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 { + let path = qwen_oauth_credentials_file_path()?; + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str::(&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 { + 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(|_| "".to_string()); + + let parsed = serde_json::from_str::(&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 { 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> { + 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 { 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",