feat(providers): #52 OpenRouter + Novita as first-class providers

ProviderKind gains Openrouter + Novita variants; ModelRegistry registers
deepseek/deepseek-v4-{pro,flash} against both. /provider opens a picker
modal with inline API-key prompt for un-configured providers. Env
fallbacks: OPENROUTER_API_KEY, NOVITA_API_KEY.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-04-27 21:58:51 -05:00
parent 4ac7219d77
commit f118db8201
12 changed files with 1372 additions and 40 deletions
+80
View File
@@ -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");
}
}
+9 -2
View File
@@ -22,6 +22,8 @@ enum ProviderArg {
Deepseek,
NvidiaNim,
Openai,
Openrouter,
Novita,
}
impl From<ProviderArg> for ProviderKind {
@@ -30,6 +32,8 @@ impl From<ProviderArg> 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()
);
}
+151 -8
View File
@@ -696,18 +696,14 @@ impl EnvRuntimeOverrides {
}
fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
// Defaults belong in the resolver's final fallback so config-file
// values (`providers.<name>.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<OsString>,
nvidia_base_url: Option<OsString>,
nvidia_nim_base_url: Option<OsString>,
openrouter_api_key: Option<OsString>,
openrouter_base_url: Option<OsString>,
novita_api_key: Option<OsString>,
novita_base_url: Option<OsString>,
}
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");
}
}
+7 -3
View File
@@ -730,7 +730,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,
@@ -738,7 +742,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" });
}
@@ -750,7 +754,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" });
}
+42 -26
View File
@@ -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 <name>`
//! 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 `<provider> [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 `<provider> [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 <name> [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();
+535 -1
View File
@@ -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.<name>]`
// 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.<name>]` 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.<name>] api_key = "..."` (creating the table
/// if needed). Returns the config file path.
pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result<PathBuf> {
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<OsString>,
nvidia_nim_base_url: Option<OsString>,
nvidia_nim_model: Option<OsString>,
openrouter_api_key: Option<OsString>,
openrouter_base_url: Option<OsString>,
novita_api_key: Option<OsString>,
novita_base_url: Option<OsString>,
}
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();
+10
View File
@@ -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",
}
);
+3
View File
@@ -2041,6 +2041,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
+1
View File
@@ -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;
+453
View File
@@ -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<Line> = 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::<String>()
.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");
}
}
+67
View File
@@ -2503,6 +2503,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;
@@ -2928,6 +2937,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,
@@ -3240,12 +3251,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();
+14
View File
@@ -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)]