diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index e8920172d..b41cca1fa 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -142,6 +142,7 @@ pub async fn run_wizard(force: bool) -> Result { default_temperature: 0.7, provider_timeout_secs: 120, extra_headers: std::collections::HashMap::new(), + observability: ObservabilityConfig::default(), autonomy: AutonomyConfig::default(), backup: crate::config::BackupConfig::default(), @@ -582,6 +583,7 @@ async fn run_quick_setup_with_home( default_temperature: 0.7, provider_timeout_secs: 120, extra_headers: std::collections::HashMap::new(), + observability: ObservabilityConfig::default(), autonomy: AutonomyConfig::default(), backup: crate::config::BackupConfig::default(), @@ -833,6 +835,10 @@ const MINIMAX_ONBOARD_MODELS: [(&str, &str); 7] = [ ]; fn default_model_for_provider(provider: &str) -> String { + if provider == "qwen-coding-plan" { + return "qwen3-coder-plus".into(); + } + match canonical_provider_name(provider) { "anthropic" => "claude-sonnet-4-5-20250929".into(), "openai" => "gpt-5.2".into(), @@ -865,6 +871,23 @@ fn default_model_for_provider(provider: &str) -> String { } fn curated_models_for_provider(provider_name: &str) -> Vec<(String, String)> { + if provider_name == "qwen-coding-plan" { + return 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(), + ), + ]; + } + match canonical_provider_name(provider_name) { "openrouter" => vec![ ( @@ -1343,6 +1366,7 @@ fn supports_live_model_fetch(provider_name: &str) -> bool { fn models_endpoint_for_provider(provider_name: &str) -> Option<&'static str> { match provider_name { + "qwen-coding-plan" => Some("https://coding.dashscope.aliyuncs.com/v1/models"), "qwen-intl" => Some("https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models"), "dashscope-us" => Some("https://dashscope-us.aliyuncs.com/compatible-mode/v1/models"), "moonshot-cn" | "kimi-cn" => Some("https://api.moonshot.cn/v1/models"), @@ -2305,7 +2329,7 @@ async fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, "⭐ Recommended (OpenRouter, Venice, Anthropic, OpenAI, Gemini)", "⚡ Fast inference (Groq, Fireworks, Together AI, NVIDIA NIM)", "🌐 Gateway / proxy (Vercel AI, Cloudflare AI, Amazon Bedrock)", - "🔬 Specialized (Moonshot/Kimi, GLM/Zhipu, MiniMax, Qwen/DashScope, Qianfan, Z.AI, Synthetic, OpenCode Zen, Cohere)", + "🔬 Specialized (Moonshot/Kimi, GLM/Zhipu, MiniMax, Qwen/DashScope, Qwen Coding, Qianfan, Z.AI, Synthetic, OpenCode Zen, Cohere)", "🏠 Local / private (Ollama, llama.cpp server, vLLM — no API key needed)", "🔧 Custom — bring your own OpenAI-compatible API", ]; @@ -2380,8 +2404,17 @@ async fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, ), ("minimax-cn", "MiniMax — China endpoint (api.minimaxi.com)"), ("qwen", "Qwen — DashScope China endpoint"), + ( + "qwen-coding-plan", + "Qwen — DashScope coding plan endpoint (coding.dashscope.aliyuncs.com)", + ), ("qwen-intl", "Qwen — DashScope international endpoint"), ("qwen-us", "Qwen — DashScope US endpoint"), + ( + "qwen-coding", + "Qwen Coding — ModelStudio / coding-intl (Coding Plan)", + ), + ("qwen-coding-cn", "Qwen Coding — ModelStudio China endpoint"), ("qianfan", "Qianfan — Baidu AI models (China endpoint)"), ("zai", "Z.AI — global coding endpoint"), ("zai-cn", "Z.AI — China coding endpoint (open.bigmodel.cn)"), @@ -6828,6 +6861,10 @@ 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-coding-plan"), + "qwen3-coder-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.7"); @@ -6873,6 +6910,7 @@ 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-coding-plan"), "qwen"); assert_eq!(canonical_provider_name("qwen-code"), "qwen-code"); assert_eq!(canonical_provider_name("qwen-oauth"), "qwen-code"); assert_eq!(canonical_provider_name("codex"), "openai-codex"); @@ -7017,6 +7055,18 @@ mod tests { assert!(ids.contains(&"minimax/minimax-m2.5".to_string())); } + #[test] + fn curated_models_for_qwen_coding_plan_include_coding_models() { + let ids: Vec = curated_models_for_provider("qwen-coding-plan") + .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")); @@ -7038,6 +7088,7 @@ mod tests { assert!(supports_live_model_fetch("venice")); assert!(supports_live_model_fetch("glm-cn")); assert!(supports_live_model_fetch("qwen-intl")); + assert!(supports_live_model_fetch("qwen-coding-plan")); assert!(!supports_live_model_fetch("minimax-cn")); assert!(!supports_live_model_fetch("unknown-provider")); } @@ -7068,6 +7119,10 @@ mod tests { curated_models_for_provider("qwen"), curated_models_for_provider("dashscope-us") ); + assert_eq!( + curated_models_for_provider("qwen-coding-plan"), + curated_models_for_provider("qwen-code") + ); assert_eq!( curated_models_for_provider("minimax"), curated_models_for_provider("minimax-cn") @@ -7120,6 +7175,10 @@ mod tests { models_endpoint_for_provider("qwen-intl"), Some("https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models") ); + assert_eq!( + models_endpoint_for_provider("qwen-coding-plan"), + Some("https://coding.dashscope.aliyuncs.com/v1/models") + ); } #[test] @@ -7430,6 +7489,9 @@ 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-coding-plan"), "DASHSCOPE_API_KEY"); + assert_eq!(provider_env_var("qwen-coding"), "DASHSCOPE_API_KEY"); + assert_eq!(provider_env_var("qwen-coding-cn"), "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"); diff --git a/src/providers/anthropic.rs b/src/providers/anthropic.rs index 70e0b45e4..5b00c1fee 100644 --- a/src/providers/anthropic.rs +++ b/src/providers/anthropic.rs @@ -190,6 +190,13 @@ impl AnthropicProvider { } } + /// The user-agent string used in all Anthropic API requests. + /// Some Anthropic-compatible endpoints (e.g. Alibaba Coding Plan) require + /// a non-empty User-Agent header. + fn user_agent() -> &'static str { + "zeroclaw/0.5" + } + fn is_setup_token(token: &str) -> bool { token.starts_with("sk-ant-oat01-") } @@ -549,7 +556,21 @@ impl AnthropicProvider { } fn http_client(&self) -> Client { - crate::config::build_runtime_proxy_client_with_timeouts("provider.anthropic", 120, 10) + // Build via the standard proxy-aware builder, but ensure a non-empty + // User-Agent is always set. Some Anthropic-compatible endpoints + // (e.g. Alibaba Coding Plan) reject requests without one. + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .connect_timeout(std::time::Duration::from_secs(10)) + .user_agent(Self::user_agent()) + .build() + .unwrap_or_else(|_| { + crate::config::build_runtime_proxy_client_with_timeouts( + "provider.anthropic", + 120, + 10, + ) + }) } } @@ -591,18 +612,38 @@ impl Provider for AnthropicProvider { tool_choice: None, }; + let url = format!("{}/v1/messages", self.base_url); + let request_body = serde_json::to_string(&request).unwrap_or_default(); + tracing::debug!( + base_url = %self.base_url, + url = %url, + model = %model, + has_credential = !credential.is_empty(), + credential_prefix = %&credential[..credential.len().min(10)], + request_body_len = request_body.len(), + "Anthropic chat_with_system: sending request" + ); + tracing::debug!(request_body = %request_body, "Anthropic request body"); let mut request = self .http_client() - .post(format!("{}/v1/messages", self.base_url)) + .post(&url) .header("anthropic-version", "2023-06-01") .header("content-type", "application/json") - .json(&request); + .body(request_body); request = self.apply_auth(request, credential); let response = request.send().await?; if !response.status().is_success() { + let status = response.status(); + tracing::warn!( + base_url = %self.base_url, + url = %url, + status = %status, + credential_prefix = %&credential[..credential.len().min(10)], + "Anthropic chat_with_system: request failed" + ); return Err(super::api_error("Anthropic", response).await); } @@ -661,15 +702,31 @@ impl Provider for AnthropicProvider { tool_choice, }; + let url = format!("{}/v1/messages", self.base_url); + tracing::debug!( + base_url = %self.base_url, + url = %url, + model = %model, + has_credential = !credential.is_empty(), + credential_prefix = %&credential[..credential.len().min(10)], + "Anthropic provider: sending request" + ); let req = self .http_client() - .post(format!("{}/v1/messages", self.base_url)) + .post(&url) .header("anthropic-version", "2023-06-01") .header("content-type", "application/json") .json(&native_request); let response = self.apply_auth(req, credential).send().await?; if !response.status().is_success() { + let status = response.status(); + tracing::warn!( + base_url = %self.base_url, + url = %url, + status = %status, + "Anthropic provider: request failed" + ); return Err(super::api_error("Anthropic", response).await); } diff --git a/src/providers/mod.rs b/src/providers/mod.rs index c36fe48a7..4e14203ad 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -66,6 +66,8 @@ 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_CODING_INTL_BASE_URL: &str = "https://coding-intl.dashscope.aliyuncs.com/v1"; +const QWEN_CODING_CN_BASE_URL: &str = "https://coding.dashscope.aliyuncs.com/v1"; const QWEN_OAUTH_BASE_FALLBACK_URL: &str = QWEN_CN_BASE_URL; const BAILIAN_BASE_URL: &str = "https://coding.dashscope.aliyuncs.com/v1"; const QWEN_OAUTH_TOKEN_ENDPOINT: &str = "https://chat.qwen.ai/api/v1/oauth2/token"; @@ -155,11 +157,31 @@ pub(crate) fn is_bailian_alias(name: &str) -> bool { matches!(name, "bailian" | "aliyun-bailian" | "aliyun") } +pub(crate) fn is_qwen_coding_intl_alias(name: &str) -> bool { + matches!( + name, + "qwen-coding" + | "qwen-coding-plan" + | "modelstudio" + | "qwen-coding-intl" + | "modelstudio-intl" + ) +} + +pub(crate) fn is_qwen_coding_cn_alias(name: &str) -> bool { + matches!(name, "qwen-coding-cn" | "modelstudio-cn") +} + +pub(crate) fn is_qwen_coding_alias(name: &str) -> bool { + is_qwen_coding_intl_alias(name) || is_qwen_coding_cn_alias(name) +} + 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_oauth_alias(name) + || is_qwen_coding_alias(name) } pub(crate) fn is_zai_global_alias(name: &str) -> bool { @@ -659,7 +681,11 @@ fn moonshot_base_url(name: &str) -> Option<&'static str> { } fn qwen_base_url(name: &str) -> Option<&'static str> { - if is_qwen_cn_alias(name) || is_qwen_oauth_alias(name) { + if is_qwen_coding_intl_alias(name) { + Some(QWEN_CODING_INTL_BASE_URL) + } else if is_qwen_coding_cn_alias(name) { + Some(QWEN_CODING_CN_BASE_URL) + } else if is_qwen_cn_alias(name) || is_qwen_oauth_alias(name) { Some(QWEN_CN_BASE_URL) } else if is_qwen_intl_alias(name) { Some(QWEN_INTL_BASE_URL) @@ -843,7 +869,11 @@ fn resolve_provider_credential(name: &str, credential_override: Option<&str>) -> if let Some(credential) = resolve_minimax_oauth_refresh_token(name) { return Some(credential); } - } else if name == "anthropic" || name == "openai" || name == "groq" { + } else if name == "anthropic" + || name == "openai" + || name == "groq" + || is_qwen_coding_alias(name) + { // For well-known providers, prefer provider-specific env vars over the // global api_key override, since the global key may belong to a different // provider (e.g. a custom: gateway). This enables multi-provider setups @@ -852,6 +882,11 @@ fn resolve_provider_credential(name: &str, credential_override: Option<&str>) -> "anthropic" => &["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], "openai" => &["OPENAI_API_KEY"], "groq" => &["GROQ_API_KEY"], + name if is_qwen_coding_alias(name) => &[ + "MODELSTUDIO_API_KEY", + "DASHSCOPE_API_KEY", + "BAILIAN_API_KEY", + ], _ => &[], }; for env_var in env_candidates { @@ -1300,6 +1335,20 @@ fn create_provider_with_url_and_options( true, ) )), + name if is_qwen_coding_alias(name) => { + let base_url = if is_qwen_coding_cn_alias(name) { + QWEN_CODING_CN_BASE_URL + } else { + QWEN_CODING_INTL_BASE_URL + }; + Ok(compat(OpenAiCompatibleProvider::new_with_vision( + "Qwen Coding", + base_url, + key, + AuthStyle::Bearer, + true, + ))) + } name if qwen_base_url(name).is_some() => Ok(compat(OpenAiCompatibleProvider::new_with_vision( "Qwen", qwen_base_url(name).expect("checked in guard"), @@ -1932,6 +1981,7 @@ pub fn list_providers() -> Vec { "dashscope-intl", "qwen-us", "dashscope-us", + "qwen-coding-plan", "qwen-code", "qwen-oauth", "qwen_oauth", @@ -2404,6 +2454,7 @@ 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-coding-plan")); assert!(is_qwen_alias("qwen-code")); assert!(is_qwen_oauth_alias("qwen-code")); assert!(is_qwen_oauth_alias("qwen_oauth")); @@ -2434,6 +2485,10 @@ 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-coding-plan"), + 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")); @@ -2473,6 +2528,14 @@ 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-coding"), + Some(QWEN_CODING_INTL_BASE_URL) + ); + assert_eq!( + qwen_base_url("qwen-coding-cn"), + Some(QWEN_CODING_CN_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)); @@ -2684,6 +2747,7 @@ 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-coding-plan", Some("key")).is_ok()); assert!(create_provider("qwen-code", Some("key")).is_ok()); assert!(create_provider("qwen-oauth", Some("key")).is_ok()); } @@ -3222,6 +3286,7 @@ mod tests { "qwen-intl", "qwen-cn", "qwen-us", + "qwen-coding-plan", "qwen-code", "lmstudio", "llamacpp",