Merge branch 'feat/v067-providers' (#52 OpenRouter + Novita providers)
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user