diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 5292d2fc..146e25a6 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -14,7 +14,8 @@ use codewhale_app_server::{ AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio, }; use codewhale_config::{ - CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource, + CliRuntimeOverrides, ConfigStore, ProviderKind, ProviderSource, ResolvedRuntimeOptions, + RuntimeApiKeySource, }; use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine}; use codewhale_mcp::{McpServerDefinition, run_stdio_server}; @@ -806,32 +807,6 @@ const PROVIDER_LIST: [ProviderKind; 20] = [ ProviderKind::OpenaiCodex, ]; -fn provider_is_supported_by_tui(provider: ProviderKind) -> bool { - matches!( - provider, - ProviderKind::Deepseek - | ProviderKind::NvidiaNim - | ProviderKind::Openai - | ProviderKind::Atlascloud - | ProviderKind::WanjieArk - | ProviderKind::Volcengine - | ProviderKind::Openrouter - | ProviderKind::XiaomiMimo - | ProviderKind::Novita - | ProviderKind::Fireworks - | ProviderKind::Siliconflow - | ProviderKind::SiliconflowCN - | ProviderKind::Arcee - | ProviderKind::Moonshot - | ProviderKind::Sglang - | ProviderKind::Vllm - | ProviderKind::Ollama - | ProviderKind::Huggingface - | ProviderKind::Together - | ProviderKind::OpenaiCodex - ) -} - #[cfg(test)] fn no_keyring_secrets() -> Secrets { Secrets::new(std::sync::Arc::new( @@ -1714,33 +1689,41 @@ fn build_tui_command( } cmd.args(passthrough); - if !provider_is_supported_by_tui(resolved_runtime.provider) { - let source_hint = if cli.provider.is_some() { - "set via --provider flag" - } else { - "resolved from config file or environment" - }; - bail!( - "The interactive TUI does not support provider '{}' ({}).\n\ - \n\ - Supported TUI providers: deepseek, openai, ollama, openrouter, nvidia-nim, \n\ - volcengine, siliconflow, moonshot, arcee, fireworks, novita, xiaomi-mimo,\n\ - huggingface, sglang, vllm, atlascloud, wanjie-ark, together, openai-codex.\n\ - \n\ - To fix:\n\ - - Set a supported provider in your config file (~/.codewhale/config.toml)\n\ - under [providers.] with an api_key, or\n\ - - Pass --provider on the command line, or\n\ - - Run `codewhale exec --provider \"your prompt\"` for a\n\ - one-shot non-interactive session with this provider.", - resolved_runtime.provider.as_str(), - source_hint, - ); + let mut resolved_runtime = resolved_runtime.clone(); + let mut fallback_provider = None; + if !resolved_runtime.provider.is_tui_capable() { + match resolved_runtime.provider_source { + ProviderSource::Cli => { + bail!( + "The interactive TUI supports {} providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.", + ProviderKind::tui_supported_providers_msg(), + resolved_runtime.provider.as_str() + ); + } + ProviderSource::Env(var_name) => { + bail!( + "The interactive TUI supports {} providers. Unset `{}` (currently `{}`) or use `codewhale model ...` for provider registry inspection.", + ProviderKind::tui_supported_providers_msg(), + var_name, + resolved_runtime.provider.as_str() + ); + } + ProviderSource::Config => { + eprintln!( + "warning: Unsupported provider '{}' configured in config.toml. Falling back to default 'deepseek'.", + resolved_runtime.provider.as_str() + ); + resolved_runtime.provider = ProviderKind::Deepseek; + fallback_provider = Some(resolved_runtime.provider); + } + } } if let Some(provider) = cli.provider { let provider: ProviderKind = provider.into(); cmd.env("DEEPSEEK_PROVIDER", provider.as_str()); + } else if let Some(provider) = fallback_provider { + cmd.env("DEEPSEEK_PROVIDER", provider.as_str()); } if matches!( resolved_runtime.api_key_source, @@ -3130,6 +3113,7 @@ mod tests { ]); let resolved = ResolvedRuntimeOptions { provider: ProviderKind::Openai, + provider_source: ProviderSource::Cli, model: "glm-5".to_string(), api_key: Some("resolved-openai-key".to_string()), api_key_source: Some(RuntimeApiKeySource::Keyring), @@ -3189,6 +3173,7 @@ mod tests { let cli = parse_ok(&["codewhale", "doctor"]); let resolved = ResolvedRuntimeOptions { provider: ProviderKind::OpenaiCodex, + provider_source: ProviderSource::Config, model: "gpt-5.5".to_string(), api_key: None, api_key_source: None, @@ -3229,6 +3214,7 @@ mod tests { let cli = parse_ok(&["codewhale", "--provider", "openai-codex", "doctor"]); let resolved = ResolvedRuntimeOptions { provider: ProviderKind::OpenaiCodex, + provider_source: ProviderSource::Cli, model: "gpt-5.5".to_string(), api_key: None, api_key_source: None, @@ -3269,6 +3255,7 @@ mod tests { resolved_headers.insert("X-From-Base".to_string(), "base".to_string()); let resolved = ResolvedRuntimeOptions { provider: ProviderKind::Deepseek, + provider_source: ProviderSource::Config, model: "deepseek-v4-pro".to_string(), api_key: Some("config-file-key".to_string()), api_key_source: Some(RuntimeApiKeySource::ConfigFile), @@ -3326,6 +3313,7 @@ mod tests { ]); let resolved = ResolvedRuntimeOptions { provider: ProviderKind::Moonshot, + provider_source: ProviderSource::Cli, model: "kimi-k2.6".to_string(), api_key: Some("resolved-kimi-key".to_string()), api_key_source: Some(RuntimeApiKeySource::Keyring), @@ -3392,6 +3380,7 @@ mod tests { ]); let resolved = ResolvedRuntimeOptions { provider: ProviderKind::Volcengine, + provider_source: ProviderSource::Cli, model: "DeepSeek-V4-Pro".to_string(), api_key: Some("resolved-ark-key".to_string()), api_key_source: Some(RuntimeApiKeySource::Keyring), @@ -3459,6 +3448,7 @@ mod tests { ]); let resolved = ResolvedRuntimeOptions { provider: ProviderKind::Openai, + provider_source: ProviderSource::Cli, model: "glm-5".to_string(), api_key: None, api_key_source: None, @@ -3556,6 +3546,7 @@ mod tests { ]); let resolved = ResolvedRuntimeOptions { provider, + provider_source: ProviderSource::Cli, model: "test-model".to_string(), api_key: Some("test-key".to_string()), api_key_source: Some(RuntimeApiKeySource::Keyring), @@ -3846,4 +3837,113 @@ mod tests { let resolved = locate_sibling_tui_binary().expect("override must resolve"); assert_eq!(resolved, custom); } + + #[test] + fn test_build_tui_command_unsupported_provider_cli() { + let _lock = env_lock(); + let dir = tempfile::TempDir::new().expect("tempdir"); + let custom = dir + .path() + .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&custom, b"").unwrap(); + let custom_str = custom.to_string_lossy().into_owned(); + let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str); + + let cli = parse_ok(&["deepseek"]); + let options = ResolvedRuntimeOptions { + provider: ProviderKind::Anthropic, + provider_source: ProviderSource::Cli, + model: "model".to_string(), + api_key: None, + api_key_source: None, + base_url: "url".to_string(), + auth_mode: None, + insecure_skip_tls_verify: false, + output_mode: None, + log_level: None, + telemetry: false, + approval_policy: None, + sandbox_mode: None, + yolo: None, + http_headers: std::collections::BTreeMap::new(), + }; + let err = build_tui_command(&cli, &options, vec![]).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("Remove --provider anthropic"), "msg: {}", msg); + assert!(msg.contains("The interactive TUI supports"), "msg: {}", msg); + } + + #[test] + fn test_build_tui_command_unsupported_provider_env() { + let _lock = env_lock(); + let dir = tempfile::TempDir::new().expect("tempdir"); + let custom = dir + .path() + .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&custom, b"").unwrap(); + let custom_str = custom.to_string_lossy().into_owned(); + let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str); + + let cli = parse_ok(&["deepseek"]); + let options = ResolvedRuntimeOptions { + provider: ProviderKind::Anthropic, + provider_source: ProviderSource::Env("CODEWHALE_PROVIDER"), + model: "model".to_string(), + api_key: None, + api_key_source: None, + base_url: "url".to_string(), + auth_mode: None, + insecure_skip_tls_verify: false, + output_mode: None, + log_level: None, + telemetry: false, + approval_policy: None, + sandbox_mode: None, + yolo: None, + http_headers: std::collections::BTreeMap::new(), + }; + let err = build_tui_command(&cli, &options, vec![]).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("Unset `CODEWHALE_PROVIDER` (currently `anthropic`)"), + "msg: {}", + msg + ); + } + + #[test] + fn test_build_tui_command_unsupported_provider_config_fallback() { + let _lock = env_lock(); + let dir = tempfile::TempDir::new().expect("tempdir"); + let custom = dir + .path() + .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&custom, b"").unwrap(); + let custom_str = custom.to_string_lossy().into_owned(); + let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str); + + let cli = parse_ok(&["deepseek"]); + let options = ResolvedRuntimeOptions { + provider: ProviderKind::Anthropic, + provider_source: ProviderSource::Config, + model: "model".to_string(), + api_key: None, + api_key_source: None, + base_url: "url".to_string(), + auth_mode: None, + insecure_skip_tls_verify: false, + output_mode: None, + log_level: None, + telemetry: false, + approval_policy: None, + sandbox_mode: None, + yolo: None, + http_headers: std::collections::BTreeMap::new(), + }; + let cmd = build_tui_command(&cli, &options, vec![]).expect("should graceful fallback"); + assert_eq!( + command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(), + Some("deepseek") + ); + } } diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index aad7432b..be7da8b6 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -159,6 +159,79 @@ pub enum ProviderKind { } impl ProviderKind { + #[must_use] + pub fn is_tui_capable(self) -> bool { + matches!( + self, + Self::Deepseek + | Self::NvidiaNim + | Self::Openai + | Self::Atlascloud + | Self::WanjieArk + | Self::Volcengine + | Self::Openrouter + | Self::XiaomiMimo + | Self::Novita + | Self::Fireworks + | Self::Siliconflow + | Self::SiliconflowCN + | Self::Arcee + | Self::Moonshot + | Self::Sglang + | Self::Vllm + | Self::Ollama + | Self::Huggingface + | Self::Together + | Self::OpenaiCodex + ) + } + + #[must_use] + pub fn display_name(self) -> &'static str { + match self { + Self::Deepseek => "DeepSeek", + Self::NvidiaNim => "NVIDIA NIM", + Self::Openai => "OpenAI-compatible", + Self::Atlascloud => "AtlasCloud", + Self::WanjieArk => "Wanjie Ark", + Self::Volcengine => "Volcengine Ark", + Self::Openrouter => "OpenRouter", + Self::XiaomiMimo => "Xiaomi MiMo", + Self::Novita => "Novita", + Self::Fireworks => "Fireworks", + Self::Siliconflow => "SiliconFlow", + Self::SiliconflowCN => "SiliconFlow (CN)", + Self::Arcee => "Arcee AI", + Self::Moonshot => "Moonshot/Kimi", + Self::Sglang => "SGLang", + Self::Vllm => "vLLM", + Self::Ollama => "Ollama", + Self::Huggingface => "Hugging Face", + Self::Together => "Together AI", + Self::OpenaiCodex => "OpenAI Codex", + Self::Anthropic => "Anthropic", + } + } + + #[must_use] + pub fn tui_supported_providers_msg() -> String { + let mut names = Vec::new(); + for p in Self::ALL { + if p.is_tui_capable() { + names.push(p.display_name()); + } + } + match names.as_slice() { + [] => String::new(), + [only] => (*only).to_string(), + [first, second] => format!("{first} and {second}"), + _ => { + let last = names.pop().expect("non-empty"); + format!("{}, and {}", names.join(", "), last) + } + } + } + pub const ALL: [Self; 21] = [ Self::Deepseek, Self::NvidiaNim, @@ -1968,7 +2041,18 @@ impl ConfigToml { secrets: &Secrets, ) -> ResolvedRuntimeOptions { let env = EnvRuntimeOverrides::load(); - let provider = cli.provider.or(env.provider).unwrap_or(self.provider); + let (provider, provider_source) = if let Some(p) = cli.provider { + (p, ProviderSource::Cli) + } else if let Some(p) = env.provider { + let var_name = if std::env::var("CODEWHALE_PROVIDER").is_ok() { + "CODEWHALE_PROVIDER" + } else { + "DEEPSEEK_PROVIDER" + }; + (p, ProviderSource::Env(var_name)) + } else { + (self.provider, ProviderSource::Config) + }; let mut provider_cfg = self.providers.for_provider(provider).clone(); if provider == ProviderKind::SiliconflowCN { @@ -2168,6 +2252,7 @@ impl ConfigToml { ResolvedRuntimeOptions { provider, + provider_source, model, api_key, api_key_source, @@ -2809,9 +2894,17 @@ impl RuntimeApiKeySource { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderSource { + Cli, + Env(&'static str), + Config, +} + #[derive(Debug, Clone)] pub struct ResolvedRuntimeOptions { pub provider: ProviderKind, + pub provider_source: ProviderSource, pub model: String, pub api_key: Option, pub api_key_source: Option,