diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index dbab9e2e..10c8bd35 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -87,6 +87,50 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: false, }, + ModelInfo { + id: "deepseek/deepseek-v4-pro".to_string(), + provider: ProviderKind::Openrouter, + aliases: vec![ + "deepseek-v4-pro".to_string(), + "openrouter-deepseek-v4-pro".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, + ModelInfo { + id: "deepseek/deepseek-v4-flash".to_string(), + provider: ProviderKind::Openrouter, + aliases: vec![ + "deepseek-v4-flash".to_string(), + "deepseek-chat".to_string(), + "deepseek-reasoner".to_string(), + "openrouter-deepseek-v4-flash".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, + ModelInfo { + id: "deepseek/deepseek-v4-pro".to_string(), + provider: ProviderKind::Novita, + aliases: vec![ + "deepseek-v4-pro".to_string(), + "novita-deepseek-v4-pro".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, + ModelInfo { + id: "deepseek/deepseek-v4-flash".to_string(), + provider: ProviderKind::Novita, + aliases: vec![ + "deepseek-v4-flash".to_string(), + "deepseek-chat".to_string(), + "deepseek-reasoner".to_string(), + "novita-deepseek-v4-flash".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, ]; Self::new(models) } @@ -224,4 +268,40 @@ mod tests { assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim); assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash"); } + + #[test] + fn openrouter_default_uses_namespaced_model_id() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(None, Some(ProviderKind::Openrouter)); + + assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter); + assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro"); + } + + #[test] + fn novita_default_uses_namespaced_model_id() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(None, Some(ProviderKind::Novita)); + + assert_eq!(resolved.resolved.provider, ProviderKind::Novita); + assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro"); + } + + #[test] + fn deepseek_v4_flash_alias_resolves_to_openrouter_when_provider_hinted() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Openrouter)); + + assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter); + assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash"); + } + + #[test] + fn deepseek_v4_flash_alias_resolves_to_novita_when_provider_hinted() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Novita)); + + assert_eq!(resolved.resolved.provider, ProviderKind::Novita); + assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash"); + } } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 09637d43..e80f1acb 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -22,6 +22,8 @@ enum ProviderArg { Deepseek, NvidiaNim, Openai, + Openrouter, + Novita, } impl From for ProviderKind { @@ -30,6 +32,8 @@ impl From for ProviderKind { ProviderArg::Deepseek => ProviderKind::Deepseek, ProviderArg::NvidiaNim => ProviderKind::NvidiaNim, ProviderArg::Openai => ProviderKind::Openai, + ProviderArg::Openrouter => ProviderKind::Openrouter, + ProviderArg::Novita => ProviderKind::Novita, } } } @@ -817,10 +821,13 @@ fn delegate_to_tui( if !matches!( resolved_runtime.provider, - ProviderKind::Deepseek | ProviderKind::NvidiaNim + ProviderKind::Deepseek + | ProviderKind::NvidiaNim + | ProviderKind::Openrouter + | ProviderKind::Novita ) { bail!( - "The interactive TUI supports DeepSeek and NVIDIA NIM providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.", + "The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, and Novita providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.", resolved_runtime.provider.as_str() ); } diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index f047f467..85346d69 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -696,18 +696,14 @@ impl EnvRuntimeOverrides { } fn base_url_for(&self, provider: ProviderKind) -> Option { + // Defaults belong in the resolver's final fallback so config-file + // values (`providers..base_url`) still win when env is unset. match provider { ProviderKind::Deepseek => self.deepseek_base_url.clone(), ProviderKind::NvidiaNim => self.nvidia_base_url.clone(), ProviderKind::Openai => self.openai_base_url.clone(), - ProviderKind::Openrouter => self - .openrouter_base_url - .clone() - .or_else(|| Some("https://openrouter.ai/api/v1".to_string())), - ProviderKind::Novita => self - .novita_base_url - .clone() - .or_else(|| Some("https://api.novita.ai/v1".to_string())), + ProviderKind::Openrouter => self.openrouter_base_url.clone(), + ProviderKind::Novita => self.novita_base_url.clone(), } } } @@ -734,6 +730,10 @@ mod tests { nim_base_url: Option, nvidia_base_url: Option, nvidia_nim_base_url: Option, + openrouter_api_key: Option, + openrouter_base_url: Option, + novita_api_key: Option, + novita_base_url: Option, } impl EnvGuard { @@ -748,6 +748,10 @@ mod tests { nim_base_url: env::var_os("NIM_BASE_URL"), nvidia_base_url: env::var_os("NVIDIA_BASE_URL"), nvidia_nim_base_url: env::var_os("NVIDIA_NIM_BASE_URL"), + openrouter_api_key: env::var_os("OPENROUTER_API_KEY"), + openrouter_base_url: env::var_os("OPENROUTER_BASE_URL"), + novita_api_key: env::var_os("NOVITA_API_KEY"), + novita_base_url: env::var_os("NOVITA_BASE_URL"), }; // Safety: test-only environment mutation guarded by a module mutex. unsafe { @@ -760,6 +764,10 @@ mod tests { env::remove_var("NIM_BASE_URL"); env::remove_var("NVIDIA_BASE_URL"); env::remove_var("NVIDIA_NIM_BASE_URL"); + env::remove_var("OPENROUTER_API_KEY"); + env::remove_var("OPENROUTER_BASE_URL"); + env::remove_var("NOVITA_API_KEY"); + env::remove_var("NOVITA_BASE_URL"); } guard } @@ -786,6 +794,10 @@ mod tests { Self::restore_var("NIM_BASE_URL", self.nim_base_url.take()); Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take()); Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take()); + Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take()); + Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take()); + Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take()); + Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take()); } } } @@ -951,4 +963,135 @@ mod tests { Some("sk-d***cret") ); } + + #[test] + fn provider_kind_parses_openrouter_and_novita_aliases() { + assert_eq!( + ProviderKind::parse("openrouter"), + Some(ProviderKind::Openrouter) + ); + assert_eq!( + ProviderKind::parse("OPEN_ROUTER"), + Some(ProviderKind::Openrouter) + ); + assert_eq!(ProviderKind::parse("novita"), Some(ProviderKind::Novita)); + assert_eq!(ProviderKind::parse("Novita"), Some(ProviderKind::Novita)); + } + + #[test] + fn openrouter_provider_defaults_to_canonical_endpoint_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Openrouter, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Openrouter); + assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL); + assert_eq!(resolved.model, DEFAULT_OPENROUTER_MODEL); + } + + #[test] + fn novita_provider_defaults_to_canonical_endpoint_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Novita, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Novita); + assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL); + assert_eq!(resolved.model, DEFAULT_NOVITA_MODEL); + } + + #[test] + fn openrouter_env_api_key_falls_back_when_config_missing() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "openrouter"); + env::set_var("OPENROUTER_API_KEY", "or-env-key"); + } + + let resolved = + ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Openrouter); + assert_eq!(resolved.api_key.as_deref(), Some("or-env-key")); + assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL); + } + + #[test] + fn novita_env_api_key_falls_back_when_config_missing() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "novita"); + env::set_var("NOVITA_API_KEY", "novita-env-key"); + } + + let resolved = + ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Novita); + assert_eq!(resolved.api_key.as_deref(), Some("novita-env-key")); + assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL); + } + + #[test] + fn openrouter_provider_normalizes_flash_aliases() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let cli = CliRuntimeOverrides { + provider: Some(ProviderKind::Openrouter), + model: Some("deepseek-v4-flash".to_string()), + ..CliRuntimeOverrides::default() + }; + + let resolved = ConfigToml::default().resolve_runtime_options(&cli); + + assert_eq!(resolved.provider, ProviderKind::Openrouter); + assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL); + } + + #[test] + fn novita_provider_normalizes_flash_aliases() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let cli = CliRuntimeOverrides { + provider: Some(ProviderKind::Novita), + model: Some("deepseek-v4-flash".to_string()), + ..CliRuntimeOverrides::default() + }; + + let resolved = ConfigToml::default().resolve_runtime_options(&cli); + + assert_eq!(resolved.provider, ProviderKind::Novita); + assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL); + } + + #[test] + fn openrouter_provider_specific_config_overrides_env() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Openrouter, + ..ConfigToml::default() + }; + config.providers.openrouter.api_key = Some("file-key".to_string()); + config.providers.openrouter.base_url = Some("https://or-mirror.example/v1".to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.api_key.as_deref(), Some("file-key")); + assert_eq!(resolved.base_url, "https://or-mirror.example/v1"); + } } diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 5b5ad5eb..c5cead59 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -748,7 +748,11 @@ pub(super) fn apply_reasoning_effort( let normalized = effort.trim().to_ascii_lowercase(); match normalized.as_str() { "off" | "disabled" | "none" | "false" => match provider { - ApiProvider::Deepseek => body["thinking"] = json!({ "type": "disabled" }), + // OpenRouter / Novita relay the same DeepSeek V4 payload shape + // as DeepSeek native; they pass through `thinking` / `reasoning_effort`. + ApiProvider::Deepseek | ApiProvider::Openrouter | ApiProvider::Novita => { + body["thinking"] = json!({ "type": "disabled" }); + } ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ "thinking": false, @@ -756,7 +760,7 @@ pub(super) fn apply_reasoning_effort( } }, "low" | "minimal" | "medium" | "mid" | "high" | "" => match provider { - ApiProvider::Deepseek => { + ApiProvider::Deepseek | ApiProvider::Openrouter | ApiProvider::Novita => { body["reasoning_effort"] = json!("high"); body["thinking"] = json!({ "type": "enabled" }); } @@ -768,7 +772,7 @@ pub(super) fn apply_reasoning_effort( } }, "xhigh" | "max" | "highest" => match provider { - ApiProvider::Deepseek => { + ApiProvider::Deepseek | ApiProvider::Openrouter | ApiProvider::Novita => { body["reasoning_effort"] = json!("max"); body["thinking"] = json!({ "type": "enabled" }); } diff --git a/crates/tui/src/commands/provider.rs b/crates/tui/src/commands/provider.rs index f759b56e..10582076 100644 --- a/crates/tui/src/commands/provider.rs +++ b/crates/tui/src/commands/provider.rs @@ -1,4 +1,8 @@ -//! Provider switching: flip between DeepSeek and NVIDIA NIM at runtime. +//! Provider switching: flip between DeepSeek, NVIDIA NIM, OpenRouter, and +//! Novita AI at runtime. +//! +//! `/provider` with no args opens the picker modal (#52). `/provider ` +//! keeps the v0.6.6 CLI form for muscle-memory + scripted use. use crate::config::{ApiProvider, normalize_model_name}; use crate::tui::app::{App, AppAction}; @@ -7,25 +11,14 @@ use super::CommandResult; /// Switch or view the current LLM backend. /// -/// Accepts ` [model]` so you can flip backend and model in one -/// shot, e.g. `/provider nim flash` lands you on -/// `deepseek-ai/deepseek-v4-flash`. The optional model accepts shorthand +/// With no args, opens the picker modal. With ` [model]`, performs +/// the switch directly (e.g. `/provider nim flash` lands on +/// `deepseek-ai/deepseek-v4-flash`). The optional model accepts shorthand /// (`flash`, `pro`, `v4-flash`, `v4-pro`) or any normal DeepSeek model ID. pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { let trimmed = args.map(str::trim).filter(|s| !s.is_empty()); let Some(args) = trimmed else { - return CommandResult::message(format!( - "Current provider: {}\n\ - Active model: {}\n\ - Available: deepseek, nvidia-nim\n\ - Usage: /provider [model]\n\ - Examples: /provider nim flash → NIM v4-flash (recommended)\n\ - /provider nim pro → NIM v4-pro (currently DEGRADED)\n\ - /provider deepseek → DeepSeek native, default model\n\ - Tip: NIM needs NVIDIA_API_KEY (or [providers.nvidia_nim].api_key in config.toml).", - app.api_provider.as_str(), - app.model - )); + return CommandResult::action(AppAction::OpenProviderPicker); }; let mut parts = args.split_whitespace(); @@ -34,7 +27,7 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { let Some(target) = ApiProvider::parse(name) else { return CommandResult::error(format!( - "Unknown provider '{name}'. Expected: deepseek, nvidia-nim." + "Unknown provider '{name}'. Expected: deepseek, nvidia-nim, openrouter, novita." )); }; @@ -98,27 +91,50 @@ mod tests { } #[test] - fn no_args_shows_current_provider_and_usage() { + fn no_args_opens_picker_modal() { let mut app = create_test_app(); let result = provider(&mut app, None); - let msg = result.message.expect("expected info message"); - assert!(msg.contains("Current provider:")); - assert!(msg.contains("deepseek")); - assert!(msg.contains("Available:")); - assert!(msg.contains("nvidia-nim")); - assert!(msg.contains("/provider nim flash")); - assert!(result.action.is_none()); + assert!(result.message.is_none()); + assert_eq!(result.action, Some(AppAction::OpenProviderPicker)); } #[test] fn unknown_provider_returns_error() { let mut app = create_test_app(); - let result = provider(&mut app, Some("openai")); + let result = provider(&mut app, Some("anthropic")); let msg = result.message.expect("expected error message"); assert!(msg.contains("Unknown provider")); + assert!(msg.contains("openrouter")); + assert!(msg.contains("novita")); assert!(result.action.is_none()); } + #[test] + fn switch_to_openrouter_emits_action() { + let mut app = create_test_app(); + let result = provider(&mut app, Some("openrouter")); + match result.action { + Some(AppAction::SwitchProvider { provider, model }) => { + assert_eq!(provider, ApiProvider::Openrouter); + assert_eq!(model, None); + } + other => panic!("expected SwitchProvider, got {other:?}"), + } + } + + #[test] + fn switch_to_novita_emits_action() { + let mut app = create_test_app(); + let result = provider(&mut app, Some("novita")); + match result.action { + Some(AppAction::SwitchProvider { provider, model }) => { + assert_eq!(provider, ApiProvider::Novita); + assert_eq!(model, None); + } + other => panic!("expected SwitchProvider, got {other:?}"), + } + } + #[test] fn switching_to_active_provider_without_model_is_a_noop() { let mut app = create_test_app(); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 36c8e111..b547b78f 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -19,18 +19,28 @@ pub const DEFAULT_TEXT_MODEL: &str = "deepseek-v4-pro"; pub const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro"; pub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash"; pub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1"; +pub const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro"; +pub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; +pub const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; +pub const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro"; +pub const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; +pub const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/v1"; const API_KEYRING_SENTINEL: &str = "__KEYRING__"; pub const COMMON_DEEPSEEK_MODELS: &[&str] = &[ "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-ai/deepseek-v4-pro", "deepseek-ai/deepseek-v4-flash", + "deepseek/deepseek-v4-pro", + "deepseek/deepseek-v4-flash", ]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ApiProvider { Deepseek, NvidiaNim, + Openrouter, + Novita, } impl ApiProvider { @@ -39,6 +49,8 @@ impl ApiProvider { match value.trim().to_ascii_lowercase().as_str() { "deepseek" | "deep-seek" => Some(Self::Deepseek), "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => Some(Self::NvidiaNim), + "openrouter" | "open_router" => Some(Self::Openrouter), + "novita" => Some(Self::Novita), _ => None, } } @@ -48,8 +60,32 @@ impl ApiProvider { match self { Self::Deepseek => "deepseek", Self::NvidiaNim => "nvidia-nim", + Self::Openrouter => "openrouter", + Self::Novita => "novita", } } + + /// Human-friendly label for picker UIs / status chips. + #[must_use] + pub fn display_name(self) -> &'static str { + match self { + Self::Deepseek => "DeepSeek", + Self::NvidiaNim => "NVIDIA NIM", + Self::Openrouter => "OpenRouter", + Self::Novita => "Novita AI", + } + } + + /// All providers, in the order shown in the picker. + #[must_use] + pub fn all() -> &'static [Self] { + &[ + Self::Deepseek, + Self::NvidiaNim, + Self::Openrouter, + Self::Novita, + ] + } } /// Canonicalize common model aliases to stable DeepSeek IDs. @@ -209,6 +245,10 @@ pub struct ProvidersConfig { pub deepseek: ProviderConfig, #[serde(default)] pub nvidia_nim: ProviderConfig, + #[serde(default)] + pub openrouter: ProviderConfig, + #[serde(default)] + pub novita: ProviderConfig, } #[derive(Debug, Clone, Deserialize, Default)] @@ -267,7 +307,9 @@ impl Config { if let Some(provider) = self.provider.as_deref() && ApiProvider::parse(provider).is_none() { - anyhow::bail!("Invalid provider '{provider}': expected deepseek or nvidia-nim."); + anyhow::bail!( + "Invalid provider '{provider}': expected deepseek, nvidia-nim, openrouter, or novita." + ); } if let Some(ref key) = self.api_key && key.trim().is_empty() @@ -372,6 +414,8 @@ impl Config { Some(match provider { ApiProvider::Deepseek => &providers.deepseek, ApiProvider::NvidiaNim => &providers.nvidia_nim, + ApiProvider::Openrouter => &providers.openrouter, + ApiProvider::Novita => &providers.novita, }) } @@ -398,6 +442,8 @@ impl Config { match provider { ApiProvider::Deepseek => DEFAULT_TEXT_MODEL, ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL, + ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL, + ApiProvider::Novita => DEFAULT_NOVITA_MODEL, } .to_string() } @@ -409,6 +455,10 @@ impl Config { let provider_base = self .provider_config_for(provider) .and_then(|provider| provider.base_url.clone()); + // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a + // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita + // were added in v0.6.7 and require explicit `[providers.]` + // entries or the corresponding `*_BASE_URL` env var. let root_base = match provider { ApiProvider::Deepseek => self.base_url.clone(), ApiProvider::NvidiaNim => self @@ -416,11 +466,14 @@ impl Config { .as_ref() .filter(|base| base.contains("integrate.api.nvidia.com")) .cloned(), + ApiProvider::Openrouter | ApiProvider::Novita => None, }; let base = provider_base.or(root_base).unwrap_or_else(|| { match provider { ApiProvider::Deepseek => "https://api.deepseek.com", ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL, + ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL, + ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL, } .to_string() }); @@ -448,6 +501,20 @@ impl Config { } } } + ApiProvider::Openrouter => { + if let Ok(key) = std::env::var("OPENROUTER_API_KEY") + && !key.trim().is_empty() + { + return Ok(key); + } + } + ApiProvider::Novita => { + if let Ok(key) = std::env::var("NOVITA_API_KEY") + && !key.trim().is_empty() + { + return Ok(key); + } + } } // Then check config file @@ -477,6 +544,14 @@ impl Config { "NVIDIA NIM API key not found. Set NVIDIA_API_KEY, NVIDIA_NIM_API_KEY, \ or save api_key in ~/.deepseek/config.toml with provider = \"nvidia-nim\"." ), + ApiProvider::Openrouter => anyhow::bail!( + "OpenRouter API key not found. Set OPENROUTER_API_KEY \ + or add [providers.openrouter] api_key in ~/.deepseek/config.toml." + ), + ApiProvider::Novita => anyhow::bail!( + "Novita API key not found. Set NOVITA_API_KEY \ + or add [providers.novita] api_key in ~/.deepseek/config.toml." + ), } } @@ -757,6 +832,28 @@ fn apply_env_overrides(config: &mut Config) { .nvidia_nim .base_url = Some(value); } + // OpenRouter / Novita are scoped only on their own provider entry — the + // legacy root `base_url` keeps DeepSeek-only semantics. + if matches!(config.api_provider(), ApiProvider::Openrouter) + && let Ok(value) = std::env::var("OPENROUTER_BASE_URL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .openrouter + .base_url = Some(value); + } + if matches!(config.api_provider(), ApiProvider::Novita) + && let Ok(value) = std::env::var("NOVITA_BASE_URL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .novita + .base_url = Some(value); + } if let Ok(value) = std::env::var("DEEPSEEK_MODEL").or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL")) { @@ -932,6 +1029,16 @@ fn normalize_model_config(config: &mut Config) { { providers.nvidia_nim.model = Some(normalized); } + if let Some(model) = providers.openrouter.model.as_deref() + && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model) + { + providers.openrouter.model = Some(normalized); + } + if let Some(model) = providers.novita.model.as_deref() + && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model) + { + providers.novita.model = Some(normalized); + } } } @@ -943,6 +1050,12 @@ fn model_for_provider(provider: ApiProvider, normalized: String) -> String { match (provider, normalized.as_str()) { (ApiProvider::NvidiaNim, "deepseek-v4-pro") => DEFAULT_NVIDIA_NIM_MODEL.to_string(), (ApiProvider::NvidiaNim, "deepseek-v4-flash") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(), + (ApiProvider::Openrouter, "deepseek-v4-pro") => DEFAULT_OPENROUTER_MODEL.to_string(), + (ApiProvider::Openrouter, "deepseek-v4-flash") => { + DEFAULT_OPENROUTER_FLASH_MODEL.to_string() + } + (ApiProvider::Novita, "deepseek-v4-pro") => DEFAULT_NOVITA_MODEL.to_string(), + (ApiProvider::Novita, "deepseek-v4-flash") => DEFAULT_NOVITA_FLASH_MODEL.to_string(), _ => normalized, } } @@ -1036,6 +1149,8 @@ fn merge_providers( (Some(base), Some(override_cfg)) => Some(ProvidersConfig { deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek), nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim), + openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter), + novita: merge_provider_config(base.novita, override_cfg.novita), }), } } @@ -1229,6 +1344,119 @@ pub fn has_api_key(config: &Config) -> bool { .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL) } +/// Check whether the given provider has any usable API key — either via env +/// var or the corresponding `[providers.]` config entry. Used by the +/// `/provider` picker to decide whether to prompt for a key inline. +#[must_use] +pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { + let env_var = match provider { + ApiProvider::Deepseek => "DEEPSEEK_API_KEY", + ApiProvider::NvidiaNim => "NVIDIA_API_KEY", + ApiProvider::Openrouter => "OPENROUTER_API_KEY", + ApiProvider::Novita => "NOVITA_API_KEY", + }; + if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) { + return true; + } + if matches!(provider, ApiProvider::NvidiaNim) + && std::env::var("NVIDIA_NIM_API_KEY").is_ok_and(|k| !k.trim().is_empty()) + { + return true; + } + + if let Some(providers) = config.providers.as_ref() { + let entry = match provider { + ApiProvider::Deepseek => &providers.deepseek, + ApiProvider::NvidiaNim => &providers.nvidia_nim, + ApiProvider::Openrouter => &providers.openrouter, + ApiProvider::Novita => &providers.novita, + }; + if entry + .api_key + .as_ref() + .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL) + { + return true; + } + } + + // Legacy root field is DeepSeek-only. + matches!(provider, ApiProvider::Deepseek) + && config + .api_key + .as_ref() + .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL) +} + +/// Save an API key to the appropriate place in `~/.deepseek/config.toml` for +/// the given provider. DeepSeek writes the legacy root `api_key`; other +/// providers write `[providers.] api_key = "..."` (creating the table +/// if needed). Returns the config file path. +pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result { + if matches!(provider, ApiProvider::Deepseek) { + return save_api_key(api_key); + } + + let config_path = default_config_path() + .context("Failed to resolve config path: home directory not found.")?; + ensure_parent_dir(&config_path)?; + + let table_name = match provider { + ApiProvider::Deepseek => unreachable!(), + ApiProvider::NvidiaNim => "providers.nvidia_nim", + ApiProvider::Openrouter => "providers.openrouter", + ApiProvider::Novita => "providers.novita", + }; + + // Parse existing TOML (or start fresh) so we can edit the right table + // without disturbing other sections. + let mut doc: toml::Value = if config_path.exists() { + let raw = fs::read_to_string(&config_path)?; + toml::from_str(&raw) + .with_context(|| format!("Failed to parse config at {}", config_path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + + let table = doc + .as_table_mut() + .context("Config root must be a TOML table.")?; + let providers = table + .entry("providers".to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) + .as_table_mut() + .context("`providers` must be a table.")?; + let key_inside = match provider { + ApiProvider::Deepseek => unreachable!(), + ApiProvider::NvidiaNim => "nvidia_nim", + ApiProvider::Openrouter => "openrouter", + ApiProvider::Novita => "novita", + }; + let entry = providers + .entry(key_inside.to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) + .as_table_mut() + .with_context(|| format!("`{table_name}` must be a table."))?; + entry.insert( + "api_key".to_string(), + toml::Value::String(api_key.to_string()), + ); + + let serialized = toml::to_string_pretty(&doc).context("failed to serialize updated config")?; + fs::write(&config_path, serialized) + .with_context(|| format!("Failed to write config to {}", config_path.display()))?; + log_sensitive_event( + "credential.save", + json!({ + "backend": "config_file", + "provider": provider.as_str(), + "config_path": config_path.display().to_string(), + }), + ); + + Ok(config_path) +} + /// Clear the API key from the config file pub fn clear_api_key() -> Result<()> { // Don't clear keychain - we're not using it anymore @@ -1288,6 +1516,10 @@ mod tests { nvidia_base_url: Option, nvidia_nim_base_url: Option, nvidia_nim_model: Option, + openrouter_api_key: Option, + openrouter_base_url: Option, + novita_api_key: Option, + novita_base_url: Option, } impl EnvGuard { @@ -1309,6 +1541,10 @@ mod tests { let nvidia_base_url_prev = env::var_os("NVIDIA_BASE_URL"); let nvidia_nim_base_url_prev = env::var_os("NVIDIA_NIM_BASE_URL"); let nvidia_nim_model_prev = env::var_os("NVIDIA_NIM_MODEL"); + let openrouter_api_key_prev = env::var_os("OPENROUTER_API_KEY"); + let openrouter_base_url_prev = env::var_os("OPENROUTER_BASE_URL"); + let novita_api_key_prev = env::var_os("NOVITA_API_KEY"); + let novita_base_url_prev = env::var_os("NOVITA_BASE_URL"); // Safety: test-only environment mutation guarded by a global mutex. unsafe { env::set_var("HOME", &home_str); @@ -1325,6 +1561,10 @@ mod tests { env::remove_var("NVIDIA_BASE_URL"); env::remove_var("NVIDIA_NIM_BASE_URL"); env::remove_var("NVIDIA_NIM_MODEL"); + env::remove_var("OPENROUTER_API_KEY"); + env::remove_var("OPENROUTER_BASE_URL"); + env::remove_var("NOVITA_API_KEY"); + env::remove_var("NOVITA_BASE_URL"); } Self { home: home_prev, @@ -1341,6 +1581,10 @@ mod tests { nvidia_base_url: nvidia_base_url_prev, nvidia_nim_base_url: nvidia_nim_base_url_prev, nvidia_nim_model: nvidia_nim_model_prev, + openrouter_api_key: openrouter_api_key_prev, + openrouter_base_url: openrouter_base_url_prev, + novita_api_key: novita_api_key_prev, + novita_base_url: novita_base_url_prev, } } } @@ -1366,6 +1610,10 @@ mod tests { Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take()); Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take()); Self::restore_var("NVIDIA_NIM_MODEL", self.nvidia_nim_model.take()); + Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take()); + Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take()); + Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take()); + Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take()); } } } @@ -1818,6 +2066,292 @@ mod tests { Ok(()) } + #[test] + fn openrouter_provider_uses_canonical_defaults() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-or-defaults-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config = Config { + provider: Some("openrouter".to_string()), + ..Default::default() + }; + config.validate()?; + assert_eq!(config.api_provider(), ApiProvider::Openrouter); + assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL); + assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL); + Ok(()) + } + + #[test] + fn novita_provider_uses_canonical_defaults() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-novita-defaults-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config = Config { + provider: Some("novita".to_string()), + ..Default::default() + }; + config.validate()?; + assert_eq!(config.api_provider(), ApiProvider::Novita); + assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL); + assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL); + Ok(()) + } + + #[test] + fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-or-env-key-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "openrouter"); + env::set_var("OPENROUTER_API_KEY", "or-env-key"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Openrouter); + assert_eq!(config.deepseek_api_key()?, "or-env-key"); + Ok(()) + } + + #[test] + fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-novita-env-key-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "novita"); + env::set_var("NOVITA_API_KEY", "novita-env-key"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Novita); + assert_eq!(config.deepseek_api_key()?, "novita-env-key"); + Ok(()) + } + + #[test] + fn openrouter_base_url_env_overrides_default() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-or-base-url-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "openrouter"); + env::set_var("OPENROUTER_BASE_URL", "https://or-mirror.example/v1"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Openrouter); + assert_eq!(config.deepseek_base_url(), "https://or-mirror.example/v1"); + Ok(()) + } + + #[test] + fn openrouter_reads_provider_table_from_config_file() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-or-table-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join(".deepseek").join("config.toml"); + ensure_parent_dir(&config_path)?; + fs::write( + &config_path, + r#"provider = "openrouter" + +[providers.openrouter] +api_key = "or-table-key" +base_url = "https://or-table.example/v1" +"#, + )?; + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Openrouter); + assert_eq!(config.deepseek_api_key()?, "or-table-key"); + assert_eq!(config.deepseek_base_url(), "https://or-table.example/v1"); + Ok(()) + } + + #[test] + fn novita_reads_provider_table_from_config_file() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-novita-table-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join(".deepseek").join("config.toml"); + ensure_parent_dir(&config_path)?; + fs::write( + &config_path, + r#"provider = "novita" + +[providers.novita] +api_key = "novita-table-key" +"#, + )?; + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Novita); + assert_eq!(config.deepseek_api_key()?, "novita-table-key"); + assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL); + Ok(()) + } + + #[test] + fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-has-key-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let mut config = Config::default(); + assert!(!has_api_key_for(&config, ApiProvider::Openrouter)); + + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("OPENROUTER_API_KEY", "or-env"); + } + assert!(has_api_key_for(&config, ApiProvider::Openrouter)); + assert!(!has_api_key_for(&config, ApiProvider::Novita)); + + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::remove_var("OPENROUTER_API_KEY"); + } + let mut providers = ProvidersConfig::default(); + providers.novita.api_key = Some("file-novita".to_string()); + config.providers = Some(providers); + assert!(has_api_key_for(&config, ApiProvider::Novita)); + assert!(!has_api_key_for(&config, ApiProvider::Openrouter)); + Ok(()) + } + + #[test] + fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-save-key-or-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let path = save_api_key_for(ApiProvider::Openrouter, "or-saved-key")?; + let contents = fs::read_to_string(&path)?; + let parsed: toml::Value = toml::from_str(&contents)?; + assert_eq!( + parsed + .get("providers") + .and_then(|p| p.get("openrouter")) + .and_then(|t| t.get("api_key")) + .and_then(toml::Value::as_str), + Some("or-saved-key") + ); + // Re-saving must not duplicate or wipe sibling tables. + save_api_key_for(ApiProvider::Novita, "novita-saved-key")?; + let contents = fs::read_to_string(&path)?; + let parsed: toml::Value = toml::from_str(&contents)?; + assert_eq!( + parsed + .get("providers") + .and_then(|p| p.get("openrouter")) + .and_then(|t| t.get("api_key")) + .and_then(toml::Value::as_str), + Some("or-saved-key") + ); + assert_eq!( + parsed + .get("providers") + .and_then(|p| p.get("novita")) + .and_then(|t| t.get("api_key")) + .and_then(toml::Value::as_str), + Some("novita-saved-key") + ); + Ok(()) + } + #[test] fn nvidia_nim_reads_facade_provider_table() -> Result<()> { let _lock = lock_test_env(); diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 4e083795..ecbf75a2 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1128,6 +1128,14 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { "NVIDIA_API_KEY", "deepseek auth set --provider nvidia-nim --api-key \"...\"", ), + crate::config::ApiProvider::Openrouter => ( + "OPENROUTER_API_KEY", + "deepseek auth set --provider openrouter --api-key \"...\"", + ), + crate::config::ApiProvider::Novita => ( + "NOVITA_API_KEY", + "deepseek auth set --provider novita --api-key \"...\"", + ), crate::config::ApiProvider::Deepseek => { ("DEEPSEEK_API_KEY", "deepseek login --api-key \"...\"") } @@ -1137,6 +1145,8 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { "✗".truecolor(red_r, red_g, red_b), match config.api_provider() { crate::config::ApiProvider::NvidiaNim => "nvidia_nim", + crate::config::ApiProvider::Openrouter => "openrouter", + crate::config::ApiProvider::Novita => "novita", crate::config::ApiProvider::Deepseek => "deepseek", } ); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index e73889a1..b7881689 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -2057,6 +2057,9 @@ pub enum AppAction { OpenConfigView, /// Open the `/model` two-pane picker (Pro/Flash + Off/High/Max). OpenModelPicker, + /// Open the `/provider` picker modal — DeepSeek / NVIDIA NIM / OpenRouter + /// / Novita with inline API-key prompt for un-configured providers (#52). + OpenProviderPicker, /// Send a message to the AI (normal chat mode). SendMessage(String), /// Run a Recursive Language Model (RLM) turn — Algorithm 1 from diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index fc6855db..7265dacd 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -21,6 +21,7 @@ pub mod pager; pub mod paste; pub mod paste_burst; pub mod plan_prompt; +pub mod provider_picker; pub mod scrolling; pub mod selection; pub mod session_picker; diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs new file mode 100644 index 00000000..fd75f367 --- /dev/null +++ b/crates/tui/src/tui/provider_picker.rs @@ -0,0 +1,453 @@ +//! `/provider` picker modal — pick a provider (DeepSeek / NVIDIA NIM / +//! OpenRouter / Novita) and, if it lacks credentials, type the API key +//! inline before completing the switch (#52). +//! +//! The picker is intentionally a single modal with two visible states: +//! +//! 1. **List** — pick a provider; each row shows the active provider arrow +//! and an "API key configured" / "needs API key" hint. Enter on a +//! configured provider applies the switch immediately +//! ([`ViewEvent::ProviderPickerApplied`]). Enter on an un-configured one +//! transitions the same modal into the key-entry state. +//! 2. **Key entry** — masked input box pre-filled with the provider's +//! canonical env-var name as a hint. Enter submits +//! [`ViewEvent::ProviderPickerApiKeySubmitted`], which the UI handler +//! persists via `save_api_key_for` before switching. +//! +//! Pressing Esc backs out: from key entry returns to the list; from the +//! list closes the modal without changes. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + buffer::Buffer, + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Widget}, +}; + +use crate::config::{ApiProvider, Config, has_api_key_for}; +use crate::palette; +use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Stage { + List, + KeyEntry, +} + +pub struct ProviderPickerView { + providers: Vec<(ApiProvider, bool)>, + active_provider: ApiProvider, + selected_idx: usize, + stage: Stage, + api_key_input: String, +} + +impl ProviderPickerView { + #[must_use] + pub fn new(active: ApiProvider, config: &Config) -> Self { + let providers: Vec<(ApiProvider, bool)> = ApiProvider::all() + .iter() + .map(|p| (*p, has_api_key_for(config, *p))) + .collect(); + let selected_idx = providers + .iter() + .position(|(p, _)| *p == active) + .unwrap_or(0); + Self { + providers, + active_provider: active, + selected_idx, + stage: Stage::List, + api_key_input: String::new(), + } + } + + fn move_up(&mut self) { + if self.selected_idx > 0 { + self.selected_idx -= 1; + } + } + + fn move_down(&mut self) { + if self.selected_idx + 1 < self.providers.len() { + self.selected_idx += 1; + } + } + + fn selected_provider(&self) -> ApiProvider { + self.providers[self.selected_idx].0 + } + + fn selected_has_key(&self) -> bool { + self.providers[self.selected_idx].1 + } + + fn env_var_for(provider: ApiProvider) -> &'static str { + match provider { + ApiProvider::Deepseek => "DEEPSEEK_API_KEY", + ApiProvider::NvidiaNim => "NVIDIA_API_KEY", + ApiProvider::Openrouter => "OPENROUTER_API_KEY", + ApiProvider::Novita => "NOVITA_API_KEY", + } + } + + fn render_list(&self, area: Rect, buf: &mut Buffer) { + let outer = Block::default() + .title(Line::from(Span::styled( + " Provider ", + Style::default() + .fg(palette::DEEPSEEK_SKY) + .add_modifier(Modifier::BOLD), + ))) + .title_bottom(Line::from(vec![ + Span::styled(" ↑↓ ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw("move "), + Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw("apply "), + Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw("cancel "), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)); + let inner = outer.inner(area); + outer.render(area, buf); + + let mut lines: Vec = Vec::with_capacity(self.providers.len()); + for (idx, (provider, has_key)) in self.providers.iter().enumerate() { + let is_selected = idx == self.selected_idx; + let is_active = *provider == self.active_provider; + let arrow = if is_selected { "▸" } else { " " }; + let active_dot = if is_active { " *" } else { " " }; + let label_style = if is_selected { + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::SELECTION_BG) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette::TEXT_PRIMARY) + }; + let hint_style = if is_selected { + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::SELECTION_BG) + } else if *has_key { + Style::default().fg(palette::TEXT_MUTED) + } else { + Style::default().fg(palette::STATUS_WARNING) + }; + let hint = if *has_key { + "(configured)".to_string() + } else { + "(needs API key)".to_string() + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(arrow, label_style), + Span::raw(" "), + Span::styled(provider.display_name().to_string(), label_style), + Span::styled(active_dot, label_style), + Span::raw(" "), + Span::styled(hint, hint_style), + ])); + } + Paragraph::new(lines).render(inner, buf); + } + + fn render_key_entry(&self, area: Rect, buf: &mut Buffer) { + let provider = self.selected_provider(); + let outer = Block::default() + .title(Line::from(Span::styled( + format!(" API key — {} ", provider.display_name()), + Style::default() + .fg(palette::DEEPSEEK_SKY) + .add_modifier(Modifier::BOLD), + ))) + .title_bottom(Line::from(vec![ + Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw("save & switch "), + Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw("back "), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)); + let inner = outer.inner(area); + outer.render(area, buf); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(2), + Constraint::Min(1), + ]) + .split(inner); + + let masked = mask_key(&self.api_key_input); + let display = if masked.is_empty() { + "(paste key here)".to_string() + } else { + masked + }; + let key_lines = vec![Line::from(vec![ + Span::styled("Key: ", Style::default().fg(palette::TEXT_MUTED)), + Span::styled( + display, + Style::default() + .fg(palette::TEXT_PRIMARY) + .add_modifier(Modifier::BOLD), + ), + ])]; + Paragraph::new(key_lines).render(layout[0], buf); + + let hint = format!( + "Or set the {} environment variable and re-open /provider.", + Self::env_var_for(provider), + ); + Paragraph::new(Line::from(Span::styled( + hint, + Style::default().fg(palette::TEXT_MUTED), + ))) + .render(layout[1], buf); + } +} + +fn mask_key(input: &str) -> String { + let trimmed = input.trim(); + let len = trimmed.chars().count(); + if len == 0 { + return String::new(); + } + if len <= 4 { + return "*".repeat(len); + } + let visible: String = trimmed + .chars() + .rev() + .take(4) + .collect::() + .chars() + .rev() + .collect(); + format!("{}{}", "*".repeat(len - 4), visible) +} + +impl ModalView for ProviderPickerView { + fn kind(&self) -> ModalKind { + ModalKind::ProviderPicker + } + + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + match self.stage { + Stage::List => match key.code { + KeyCode::Esc => ViewAction::Close, + KeyCode::Up => { + self.move_up(); + ViewAction::None + } + KeyCode::Down => { + self.move_down(); + ViewAction::None + } + KeyCode::Enter => { + let provider = self.selected_provider(); + if self.selected_has_key() { + ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied { provider }) + } else { + self.stage = Stage::KeyEntry; + self.api_key_input.clear(); + ViewAction::None + } + } + _ => ViewAction::None, + }, + Stage::KeyEntry => match key.code { + KeyCode::Esc => { + self.stage = Stage::List; + self.api_key_input.clear(); + ViewAction::None + } + KeyCode::Backspace => { + self.api_key_input.pop(); + ViewAction::None + } + KeyCode::Enter => { + let key = self.api_key_input.trim().to_string(); + if key.is_empty() { + // Stay in key-entry; the user can press Esc to abort. + ViewAction::None + } else { + let provider = self.selected_provider(); + ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted { + provider, + api_key: key, + }) + } + } + KeyCode::Char(c) => { + // Reject ASCII whitespace so a stray space/tab doesn't slip + // into a credential; bracketed paste happens via the input + // path that already trims on submit. + if !c.is_whitespace() { + self.api_key_input.push(c); + } + ViewAction::None + } + _ => ViewAction::None, + }, + } + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + let popup_width = 64.min(area.width.saturating_sub(4)).max(40); + let popup_height = match self.stage { + Stage::List => 12, + Stage::KeyEntry => 10, + } + .min(area.height.saturating_sub(4)) + .max(8); + let popup_area = Rect { + x: area.x + (area.width.saturating_sub(popup_width)) / 2, + y: area.y + (area.height.saturating_sub(popup_height)) / 2, + width: popup_width, + height: popup_height, + }; + + Clear.render(popup_area, buf); + + match self.stage { + Stage::List => self.render_list(popup_area, buf), + Stage::KeyEntry => self.render_key_entry(popup_area, buf), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyEvent, KeyModifiers}; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + #[test] + fn picker_lists_all_four_providers() { + let config = Config::default(); + let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); + let names: Vec<_> = picker + .providers + .iter() + .map(|(p, _)| p.display_name()) + .collect(); + assert_eq!( + names, + vec!["DeepSeek", "NVIDIA NIM", "OpenRouter", "Novita AI"] + ); + } + + #[test] + fn picker_marks_active_provider_as_initial_selection() { + let config = Config::default(); + let picker = ProviderPickerView::new(ApiProvider::Openrouter, &config); + assert_eq!(picker.selected_provider(), ApiProvider::Openrouter); + assert_eq!(picker.active_provider, ApiProvider::Openrouter); + } + + #[test] + fn enter_with_no_key_transitions_to_key_entry_stage() { + let config = Config::default(); + let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); + // Move to OpenRouter (index 2), which has no key in default config. + picker.handle_key(key(KeyCode::Down)); + picker.handle_key(key(KeyCode::Down)); + assert_eq!(picker.selected_provider(), ApiProvider::Openrouter); + let action = picker.handle_key(key(KeyCode::Enter)); + assert!(matches!(action, ViewAction::None)); + assert_eq!(picker.stage, Stage::KeyEntry); + } + + #[test] + fn enter_with_existing_key_emits_apply_and_closes() { + let config = Config { + api_key: Some("existing-deepseek-key".to_string()), + ..Config::default() + }; + let mut picker = ProviderPickerView::new(ApiProvider::NvidiaNim, &config); + // Move up to DeepSeek (index 0), which has a key from the config. + picker.handle_key(key(KeyCode::Up)); + let action = picker.handle_key(key(KeyCode::Enter)); + match action { + ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied { provider }) => { + assert_eq!(provider, ApiProvider::Deepseek); + } + other => panic!("expected ProviderPickerApplied, got {other:?}"), + } + } + + #[test] + fn key_entry_enter_submits_after_typing() { + let config = Config::default(); + let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); + // Navigate to Novita (index 3) and trigger key entry. + for _ in 0..3 { + picker.handle_key(key(KeyCode::Down)); + } + picker.handle_key(key(KeyCode::Enter)); + assert_eq!(picker.stage, Stage::KeyEntry); + for c in "novita-key".chars() { + picker.handle_key(key(KeyCode::Char(c))); + } + let action = picker.handle_key(key(KeyCode::Enter)); + match action { + ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted { + provider, + api_key, + }) => { + assert_eq!(provider, ApiProvider::Novita); + assert_eq!(api_key, "novita-key"); + } + other => panic!("expected ProviderPickerApiKeySubmitted, got {other:?}"), + } + } + + #[test] + fn key_entry_esc_returns_to_list_without_emitting() { + let config = Config::default(); + let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); + picker.handle_key(key(KeyCode::Down)); + picker.handle_key(key(KeyCode::Down)); + picker.handle_key(key(KeyCode::Enter)); + assert_eq!(picker.stage, Stage::KeyEntry); + picker.handle_key(key(KeyCode::Char('a'))); + let action = picker.handle_key(key(KeyCode::Esc)); + assert!(matches!(action, ViewAction::None)); + assert_eq!(picker.stage, Stage::List); + assert!(picker.api_key_input.is_empty()); + } + + #[test] + fn list_esc_closes_without_emitting() { + let config = Config::default(); + let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); + let action = picker.handle_key(key(KeyCode::Esc)); + assert!(matches!(action, ViewAction::Close)); + } + + #[test] + fn key_entry_strips_whitespace_chars() { + let config = Config::default(); + let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); + picker.handle_key(key(KeyCode::Down)); + picker.handle_key(key(KeyCode::Down)); + picker.handle_key(key(KeyCode::Enter)); + assert_eq!(picker.stage, Stage::KeyEntry); + for c in "abc def".chars() { + picker.handle_key(key(KeyCode::Char(c))); + } + assert_eq!(picker.api_key_input, "abcdef"); + } +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 6e24d126..c5e2afac 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2535,6 +2535,15 @@ async fn apply_command_result( .push(crate::tui::model_picker::ModelPickerView::new(app)); } } + AppAction::OpenProviderPicker => { + if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) { + app.view_stack + .push(crate::tui::provider_picker::ProviderPickerView::new( + app.api_provider, + config, + )); + } + } AppAction::CompactContext => { app.status_message = Some("Compacting context...".to_string()); let _ = engine_handle.send(Op::CompactContext).await; @@ -2960,6 +2969,8 @@ fn render(f: &mut Frame, app: &mut App) { let provider_label = match app.api_provider { crate::config::ApiProvider::Deepseek => None, crate::config::ApiProvider::NvidiaNim => Some("NIM"), + crate::config::ApiProvider::Openrouter => Some("OR"), + crate::config::ApiProvider::Novita => Some("Novita"), }; let header_data = HeaderData::new( app.mode, @@ -3272,12 +3283,68 @@ async fn handle_view_events( ) .await; } + ViewEvent::ProviderPickerApplied { provider } => { + switch_provider(app, engine_handle, config, provider, None).await; + } + ViewEvent::ProviderPickerApiKeySubmitted { provider, api_key } => { + apply_provider_picker_api_key(app, engine_handle, config, provider, api_key).await; + } } } Ok(false) } +/// Persist the typed API key to `~/.deepseek/config.toml`, refresh the +/// in-memory config so the engine can see it, then switch to the provider. +async fn apply_provider_picker_api_key( + app: &mut App, + engine_handle: &mut EngineHandle, + config: &mut Config, + provider: ApiProvider, + api_key: String, +) { + use crate::config::{ProviderConfig, ProvidersConfig, save_api_key_for}; + + match save_api_key_for(provider, &api_key) { + Ok(path) => { + app.status_message = Some(format!( + "Saved {} API key to {}", + provider.as_str(), + path.display() + )); + } + Err(err) => { + app.add_message(HistoryCell::System { + content: format!( + "Failed to save {} API key: {err}\nProvider unchanged.", + provider.as_str() + ), + }); + return; + } + } + + // Mirror the saved key into the in-memory config so the engine sees it + // immediately without a reload — `save_api_key_for` only touches disk. + if matches!(provider, ApiProvider::Deepseek) { + config.api_key = Some(api_key); + } else { + let providers = config + .providers + .get_or_insert_with(ProvidersConfig::default); + let entry: &mut ProviderConfig = match provider { + ApiProvider::Deepseek => unreachable!(), + ApiProvider::NvidiaNim => &mut providers.nvidia_nim, + ApiProvider::Openrouter => &mut providers.openrouter, + ApiProvider::Novita => &mut providers.novita, + }; + entry.api_key = Some(api_key); + } + + switch_provider(app, engine_handle, config, provider, None).await; +} + fn apply_loaded_session(app: &mut App, session: &SavedSession) { app.api_messages.clone_from(&session.messages); app.clear_history(); diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index d30abfac..068cac8d 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -27,6 +27,7 @@ pub enum ModalKind { SessionPicker, Config, ModelPicker, + ProviderPicker, FilePicker, } @@ -100,6 +101,19 @@ pub enum ViewEvent { previous_model: String, previous_effort: crate::tui::app::ReasoningEffort, }, + /// Emitted by the `/provider` picker when the user selects a provider + /// that already has credentials — the handler should perform the same + /// switch as `AppAction::SwitchProvider`. + ProviderPickerApplied { + provider: crate::config::ApiProvider, + }, + /// Emitted by the `/provider` picker after the user types an API key + /// inline for a provider that lacked one. The handler should persist + /// the key via `save_api_key_for` and then perform the provider switch. + ProviderPickerApiKeySubmitted { + provider: crate::config::ApiProvider, + api_key: String, + }, } #[derive(Debug, Clone)]