diff --git a/crates/openfang-cli/src/tui/screens/wizard.rs b/crates/openfang-cli/src/tui/screens/wizard.rs index 338e4d85c..4798dc498 100644 --- a/crates/openfang-cli/src/tui/screens/wizard.rs +++ b/crates/openfang-cli/src/tui/screens/wizard.rs @@ -85,6 +85,12 @@ const PROVIDERS: &[ProviderInfo] = &[ default_model: "qwen-plus", needs_key: true, }, + ProviderInfo { + name: "qwen_coding_intl", + env_var: "DASHSCOPE_API_KEY", + default_model: "qwen3.5-plus", + needs_key: true, + }, ProviderInfo { name: "perplexity", env_var: "PERPLEXITY_API_KEY", diff --git a/crates/openfang-runtime/src/browser.rs b/crates/openfang-runtime/src/browser.rs index 4bb0f2e79..a16074104 100644 --- a/crates/openfang-runtime/src/browser.rs +++ b/crates/openfang-runtime/src/browser.rs @@ -258,6 +258,12 @@ impl BrowserSession { args.insert(0, "--headless=new".to_string()); args.push("--disable-gpu".to_string()); } + // Chromium refuses to run as root without --no-sandbox. Detect this + // without adding a libc dependency by reading the effective UID from + // /proc/self/status (Linux) or falling back to the HOME env var. + if is_running_as_root() { + args.push("--no-sandbox".to_string()); + } let mut cmd = tokio::process::Command::new(&chrome_path); cmd.args(&args); @@ -1154,6 +1160,36 @@ const EXTRACT_CONTENT_JS: &str = r#"(() => { return JSON.stringify({title, url, content}); })()"#; +// ── Root detection ───────────────────────────────────────────────────────── + +/// Returns true if the current process is running as root (UID 0). +/// +/// On Linux, reads `/proc/self/status` to get the effective UID without +/// requiring a `libc` dependency. Falls back to checking the `HOME` env var +/// on systems where `/proc` is not available. +fn is_running_as_root() -> bool { + #[cfg(unix)] + { + // Primary: read effective UID from /proc/self/status (Linux) + if let Ok(status) = std::fs::read_to_string("/proc/self/status") { + for line in status.lines() { + if let Some(rest) = line.strip_prefix("Uid:") { + // Format: "Uid: " + if let Some(euid_str) = rest.split_whitespace().nth(1) { + return euid_str == "0"; + } + } + } + } + // Fallback: HOME=/root is a reliable indicator on most Unix systems + std::env::var("HOME").map(|h| h == "/root").unwrap_or(false) + } + #[cfg(not(unix))] + { + false + } +} + // ── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -1290,6 +1326,12 @@ mod tests { assert!(mgr.sessions.is_empty()); } + #[test] + fn test_is_running_as_root_returns_bool() { + // Just verify it doesn't panic and returns a bool. + let _ = is_running_as_root(); + } + #[test] fn test_chromium_candidates_not_empty() { let paths = chromium_candidates(); diff --git a/crates/openfang-runtime/src/drivers/mod.rs b/crates/openfang-runtime/src/drivers/mod.rs index a6fd2f65f..eb87dbf8c 100644 --- a/crates/openfang-runtime/src/drivers/mod.rs +++ b/crates/openfang-runtime/src/drivers/mod.rs @@ -18,7 +18,8 @@ use openfang_types::model_catalog::{ KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, - REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL, + QWEN_CODING_BASE_URL, QWEN_CODING_INTL_BASE_URL, QWEN_INTL_BASE_URL, REPLICATE_BASE_URL, + SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL, }; @@ -165,6 +166,12 @@ fn provider_defaults(provider: &str) -> Option { api_key_env: "DASHSCOPE_API_KEY", key_required: true, }), + // DashScope Coding Plan — multi-brand (Qwen, Zhipu/GLM, Kimi, MiniMax) + "qwen_coding_intl" | "dashscope_coding" | "dashscope_coding_intl" => Some(ProviderDefaults { + base_url: QWEN_CODING_INTL_BASE_URL, + api_key_env: "DASHSCOPE_API_KEY", + key_required: true, + }), "minimax" => Some(ProviderDefaults { base_url: MINIMAX_BASE_URL, api_key_env: "MINIMAX_API_KEY", @@ -332,6 +339,32 @@ pub fn create_driver(config: &DriverConfig) -> Result, LlmErr ))); } + // DashScope Coding Plan — requires User-Agent: OpenClaw/1.0 + if provider == "qwen_coding_intl" || provider == "dashscope_coding" || provider == "dashscope_coding_intl" { + let api_key = config + .api_key + .clone() + .or_else(|| std::env::var("DASHSCOPE_API_KEY").ok()) + .or_else(|| std::env::var("QWEN_API_KEY").ok()) + .unwrap_or_default(); + + if api_key.is_empty() { + return Err(LlmError::MissingApiKey( + "Set DASHSCOPE_API_KEY environment variable for DashScope Coding Plan".to_string(), + )); + } + + let base_url = config + .base_url + .clone() + .unwrap_or_else(|| QWEN_CODING_INTL_BASE_URL.to_string()); + + return Ok(Arc::new( + openai::OpenAIDriver::new(api_key, base_url) + .with_extra_headers(vec![("User-Agent".to_string(), "OpenClaw/1.0".to_string())]), + )); + } + // Kimi for Code — Anthropic-compatible endpoint if provider == "kimi_coding" { let api_key = config @@ -475,6 +508,7 @@ pub fn known_providers() -> &'static [&'static str] { "github-copilot", "moonshot", "qwen", + "qwen_coding_intl", "minimax", "zhipu", "zhipu_coding", @@ -577,6 +611,7 @@ mod tests { assert!(providers.contains(&"github-copilot")); assert!(providers.contains(&"moonshot")); assert!(providers.contains(&"qwen")); + assert!(providers.contains(&"qwen_coding_intl")); assert!(providers.contains(&"minimax")); assert!(providers.contains(&"zhipu")); assert!(providers.contains(&"zhipu_coding")); diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index 8822c460e..c1029ee16 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -10,9 +10,10 @@ use openfang_types::model_catalog::{ HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, - REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL, - VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL, - ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL, + QWEN_CODING_BASE_URL, QWEN_CODING_INTL_BASE_URL, QWEN_INTL_BASE_URL, REPLICATE_BASE_URL, + SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL, + VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, + ZHIPU_CODING_BASE_URL, }; use std::collections::HashMap; @@ -59,12 +60,11 @@ impl ModelCatalog { // Claude Code is special: no API key needed, but we probe for CLI // installation so the dashboard shows "Configured" vs "Not Installed". if provider.id == "claude-code" { - provider.auth_status = - if crate::drivers::claude_code::claude_code_available() { - AuthStatus::Configured - } else { - AuthStatus::Missing - }; + provider.auth_status = if crate::drivers::claude_code::claude_code_available() { + AuthStatus::Configured + } else { + AuthStatus::Missing + }; continue; } @@ -80,8 +80,7 @@ impl ModelCatalog { let has_fallback = match provider.id.as_str() { "gemini" => std::env::var("GOOGLE_API_KEY").is_ok(), "codex" => { - std::env::var("OPENAI_API_KEY").is_ok() - || read_codex_credential().is_some() + std::env::var("OPENAI_API_KEY").is_ok() || read_codex_credential().is_some() } // claude-code is handled above (before key_required check) _ => false, @@ -624,7 +623,7 @@ fn builtin_providers() -> Vec { auth_status: AuthStatus::Missing, model_count: 0, }, - // ── Chinese providers (5) ──────────────────────────────────── + // ── DashScope / Qwen (Alibaba Cloud) ──────────────────────── ProviderInfo { id: "qwen".into(), display_name: "Qwen (Alibaba)".into(), @@ -634,6 +633,17 @@ fn builtin_providers() -> Vec { auth_status: AuthStatus::Missing, model_count: 0, }, + // DashScope Coding Plan: single API key, multi-brand models + // (Qwen, Zhipu/GLM, Kimi, MiniMax — all via Alibaba Cloud) + ProviderInfo { + id: "qwen_coding_intl".into(), + display_name: "DashScope Coding Plan".into(), + api_key_env: "DASHSCOPE_API_KEY".into(), + base_url: QWEN_CODING_INTL_BASE_URL.into(), + key_required: true, + auth_status: AuthStatus::Missing, + model_count: 0, + }, ProviderInfo { id: "minimax".into(), display_name: "MiniMax".into(), @@ -2744,7 +2754,7 @@ fn builtin_models() -> Vec { provider: "qwen".into(), tier: ModelTier::Frontier, context_window: 131_072, - max_output_tokens: 8_192, + max_output_tokens: 32_768, input_cost_per_m: 4.00, output_cost_per_m: 12.00, supports_tools: true, @@ -2809,6 +2819,133 @@ fn builtin_models() -> Vec { aliases: vec![], }, // ══════════════════════════════════════════════════════════════ + // DashScope Coding Plan — International + // All accessed via DASHSCOPE_API_KEY on qwen_coding_intl provider. + // Model IDs use "qwen_coding_intl/" format — the provider prefix is + // stripped automatically by strip_provider_prefix() before the API call, + // so the API receives the bare model name (e.g. "glm-5", "kimi-k2.5"). + // This mirrors the OpenRouter pattern and avoids collisions with native providers. + // ══════════════════════════════════════════════════════════════ + // ── Qwen (4) ──────────────────────────────────────────────── + ModelCatalogEntry { + id: "qwen3.5-plus".into(), + display_name: "Qwen 3.5 Plus".into(), + provider: "qwen_coding_intl".into(), + tier: ModelTier::Smart, + context_window: 1_000_000, + max_output_tokens: 65_536, + input_cost_per_m: 0.40, + output_cost_per_m: 2.40, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec![], + }, + ModelCatalogEntry { + id: "qwen3-max-2026-01-23".into(), + display_name: "Qwen 3 Max (2026-01-23)".into(), + provider: "qwen_coding_intl".into(), + tier: ModelTier::Frontier, + context_window: 131_072, + max_output_tokens: 32_768, + input_cost_per_m: 4.00, + output_cost_per_m: 16.00, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec![], + }, + ModelCatalogEntry { + id: "qwen3-coder-plus".into(), + display_name: "Qwen 3 Coder Plus".into(), + provider: "qwen_coding_intl".into(), + tier: ModelTier::Smart, + context_window: 131_072, + max_output_tokens: 32_768, + input_cost_per_m: 0.80, + output_cost_per_m: 3.20, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec!["qwen3-coder".into()], + }, + ModelCatalogEntry { + id: "qwen3-coder-next".into(), + display_name: "Qwen 3 Coder Next".into(), + provider: "qwen_coding_intl".into(), + tier: ModelTier::Frontier, + context_window: 131_072, + max_output_tokens: 32_768, + input_cost_per_m: 4.00, + output_cost_per_m: 16.00, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec![], + }, + // ── Zhipu / GLM via Coding Plan (2) ───────────────────────── + // API receives "glm-5" / "glm-4.7" after prefix stripping. + ModelCatalogEntry { + id: "qwen_coding_intl/glm-5".into(), + display_name: "GLM-5 (Coding Plan)".into(), + provider: "qwen_coding_intl".into(), + tier: ModelTier::Frontier, + context_window: 128_000, + max_output_tokens: 32_768, + input_cost_per_m: 4.00, + output_cost_per_m: 16.00, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec![], + }, + ModelCatalogEntry { + id: "qwen_coding_intl/glm-4.7".into(), + display_name: "GLM-4.7 (Coding Plan)".into(), + provider: "qwen_coding_intl".into(), + tier: ModelTier::Smart, + context_window: 128_000, + max_output_tokens: 32_768, + input_cost_per_m: 0.80, + output_cost_per_m: 3.20, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec![], + }, + // ── Moonshot / Kimi via Coding Plan (1) ───────────────────── + // API receives "kimi-k2.5" after prefix stripping. + ModelCatalogEntry { + id: "qwen_coding_intl/kimi-k2.5".into(), + display_name: "Kimi K2.5 (Coding Plan)".into(), + provider: "qwen_coding_intl".into(), + tier: ModelTier::Smart, + context_window: 128_000, + max_output_tokens: 32_768, + input_cost_per_m: 0.80, + output_cost_per_m: 3.20, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec![], + }, + // ── MiniMax via Coding Plan (1) ────────────────────────────── + // API receives "MiniMax-M2.5" after prefix stripping. + ModelCatalogEntry { + id: "qwen_coding_intl/MiniMax-M2.5".into(), + display_name: "MiniMax M2.5 (Coding Plan)".into(), + provider: "qwen_coding_intl".into(), + tier: ModelTier::Smart, + context_window: 1_000_000, + max_output_tokens: 32_768, + input_cost_per_m: 0.80, + output_cost_per_m: 3.20, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec![], + }, + // ══════════════════════════════════════════════════════════════ // MiniMax (6) // ══════════════════════════════════════════════════════════════ ModelCatalogEntry { @@ -3549,7 +3686,7 @@ mod tests { #[test] fn test_catalog_has_providers() { let catalog = ModelCatalog::new(); - assert_eq!(catalog.list_providers().len(), 38); + assert_eq!(catalog.list_providers().len(), 39); } #[test] @@ -3584,10 +3721,7 @@ mod tests { #[test] fn test_resolve_alias() { let catalog = ModelCatalog::new(); - assert_eq!( - catalog.resolve_alias("sonnet"), - Some("claude-sonnet-4-6") - ); + assert_eq!(catalog.resolve_alias("sonnet"), Some("claude-sonnet-4-6")); assert_eq!( catalog.resolve_alias("haiku"), Some("claude-haiku-4-5-20251001") diff --git a/crates/openfang-types/src/model_catalog.rs b/crates/openfang-types/src/model_catalog.rs index b38e51b6c..8dc47bbaf 100644 --- a/crates/openfang-types/src/model_catalog.rs +++ b/crates/openfang-types/src/model_catalog.rs @@ -36,6 +36,12 @@ pub const GITHUB_COPILOT_BASE_URL: &str = "https://api.githubcopilot.com"; // ── Chinese providers ───────────────────────────────────────────── pub const QWEN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1"; +/// DashScope international endpoint (ap-southeast-1 and other international regions). +pub const QWEN_INTL_BASE_URL: &str = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"; +/// DashScope Coding Plan — China endpoint. Multi-brand: Qwen, Zhipu, Kimi, MiniMax. +pub const QWEN_CODING_BASE_URL: &str = "https://coding.dashscope.aliyuncs.com/compatible-mode/v1"; +/// DashScope Coding Plan — International endpoint. Multi-brand: Qwen, Zhipu, Kimi, MiniMax. +pub const QWEN_CODING_INTL_BASE_URL: &str = "https://coding-intl.dashscope.aliyuncs.com/v1"; pub const MINIMAX_BASE_URL: &str = "https://api.minimaxi.chat/v1"; pub const ZHIPU_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4"; pub const ZHIPU_CODING_BASE_URL: &str = "https://open.bigmodel.cn/api/coding/paas/v4";