fix(auth): use config-backed setup without credential prompts

This commit is contained in:
Hunter Bown
2026-05-03 21:12:15 -05:00
parent 190729972b
commit fc1970fa55
16 changed files with 1027 additions and 349 deletions
+10
View File
@@ -369,6 +369,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
automatically on `arm64` Linux hosts, so HarmonyOS thin-and-light,
openEuler/Kylin, Asahi Linux, Raspberry Pi, AWS Graviton, etc. now work
with a plain `npm i -g deepseek-tui`.
- **Interactive TUI hangs on `working.` at 100% CPU (#549)** — the event
loop's blocking terminal poll starved the tokio runtime, preventing the
engine task from dispatching the API request. Fixed by yielding to the
scheduler before each poll cycle and clamping the event-poll timeout to
a minimum of 1ms so a zero-timeout hot-loop can't monopolize the thread.
- **Backspace key inserts "h" instead of deleting (#550)** — terminals
that send `^H` (Ctrl+H) for Backspace were not recognized. Added
`is_ctrl_h_backspace()` guard in both the composer and API-key input
handlers so Ctrl+H is treated as a delete, matching the existing
`KeyCode::Backspace` behavior.
### Changed
- **npm `postinstall` failure messages** — when no prebuilt is available for
+26 -5
View File
@@ -121,17 +121,30 @@ GitHub release assets are reachable. TUNA, rsproxy, Tencent COS, or Aliyun OSS
mirrors can also be used with `DEEPSEEK_TUI_RELEASE_BASE_URL` when a mirrored
release-asset directory is available.
On first launch you'll be prompted for your [DeepSeek API key](https://platform.deepseek.com/api_keys). You can also set it ahead of time:
On first launch you'll be prompted for your [DeepSeek API key](https://platform.deepseek.com/api_keys). The TUI saves it to your user config at `~/.deepseek/config.toml` so it works from every folder without OS credential prompts.
You can also set it ahead of time:
```bash
# via CLI
deepseek login --api-key "YOUR_DEEPSEEK_API_KEY"
# Recommended — saves to ~/.deepseek/config.toml; works everywhere
# (interactive shells, IDE terminals, scripts, cron):
deepseek auth set --provider deepseek
# via env var
# Env var alternative — note that on zsh, exports in ~/.zshrc only
# reach interactive shells. Put it in ~/.zshenv if you want it in
# every context (login shells, IDEs, scripts):
export DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY"
deepseek
# Verify which source the binary is reading:
deepseek doctor
```
> To rotate or remove a saved key, run
> `deepseek auth clear --provider deepseek` (or `deepseek logout` for
> the legacy alias), then run `deepseek auth set --provider deepseek`
> again.
### Using NVIDIA NIM
```bash
@@ -240,6 +253,14 @@ A stabilization-focused release: a thick band of UX polish on top of the v0.8.6
- **`display_path` test race + Windows separator** — tests no longer mutate `$HOME`; home-relative suffix joins with `MAIN_SEPARATOR_STR` so Windows shows `~\projects\foo` ([#506](https://github.com/Hmbown/DeepSeek-TUI/issues/506)).
- **Footer reads statusline colours from `app.ui_theme`** ([#449](https://github.com/Hmbown/DeepSeek-TUI/issues/449)).
### 🔑 Auth & onboarding
- **No automatic OS credential prompts** — startup, `doctor`, `doctor --json`, and normal dispatcher setup now use CLI flag → `~/.deepseek/config.toml` → env.
- **One setup command works everywhere**`deepseek auth set --provider deepseek` and the in-TUI onboarding screen both write the shared user config file, so the key is available from any folder without relying on `~/.zshrc` propagation.
- **Onboarding screen wording rewritten** — "Step 1: open https://platform.deepseek.com/api_keys" / "Step 2: paste below and press Enter" with an explicit note showing where the key is saved.
- **Missing-key error is now actionable** — the `DeepSeek API key not found` bail message lists the config-backed CLI command first and the env-var alternative second, with a `~/.zshrc` vs `~/.zshenv` note for zsh users whose env var only reaches interactive shells.
- **Dispatcher provider/auth parity** — the canonical `deepseek` entry point now accepts the same DeepSeek V4 providers advertised by the TUI (`fireworks`, `sglang` included), and legacy `deepseek login --api-key` / `deepseek logout` now share the same config-backed path.
Full changelog: [CHANGELOG.md](CHANGELOG.md).
---
@@ -509,7 +530,7 @@ deepseek # interactive TUI
deepseek "explain this function" # one-shot prompt
deepseek --model deepseek-v4-flash "summarize" # model override
deepseek --yolo # YOLO mode (auto-approve tools)
deepseek login --api-key "..." # save API key
deepseek auth set --provider deepseek # save API key to ~/.deepseek/config.toml
deepseek doctor # check setup & connectivity
deepseek doctor --json # machine-readable diagnostics
deepseek setup --status # read-only setup status
+17 -5
View File
@@ -51,17 +51,28 @@ deepseek
下方的 [从源码安装](#从源码安装) 章节,或参考完整的
[docs/INSTALL.md](docs/INSTALL.md)。
首次启动时会提示输入 [DeepSeek API key](https://platform.deepseek.com/api_keys)。也可以提前配置:
首次启动时会提示输入 [DeepSeek API key](https://platform.deepseek.com/api_keys)。TUI 会把它保存到用户配置 `~/.deepseek/config.toml`,因此在任意目录、IDE 终端和脚本里都能读到,并且不会触发系统密钥环弹窗。
也可以提前配置:
```bash
# 通过 CLI 保存
deepseek login --api-key "YOUR_DEEPSEEK_API_KEY"
# 推荐 —— 保存到 ~/.deepseek/config.toml;适用于所有场景
# (交互式 shell、IDE 终端、脚本、cron):
deepseek auth set --provider deepseek
# 或通过环境变量
# 环境变量备选 —— 注意 zsh 中 ~/.zshrc 的 export 只对交互式 shell 生效。
# 需要在登录 shell / IDE / 脚本中也生效,请放进 ~/.zshenv:
export DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY"
deepseek
# 检查二进制实际读到的源:
deepseek doctor
```
> 轮换或移除已保存的密钥:`deepseek auth clear --provider deepseek`
> (旧别名 `deepseek logout` 也可用),然后重新运行
> `deepseek auth set --provider deepseek`
### Linux ARM64HarmonyOS 轻薄本、openEuler、Kylin、树莓派、Graviton 等)
**v0.8.8** 起,`npm i -g deepseek-tui` 直接支持 glibc 系的 ARM64 Linux。
@@ -163,7 +174,7 @@ deepseek # 交互式 TUI
deepseek "explain this function" # 一次性提示
deepseek --model deepseek-v4-flash "summarize" # 指定模型
deepseek --yolo # YOLO 模式,自动批准工具
deepseek login --api-key "..." # 保存 API key
deepseek auth set --provider deepseek # 保存 API key 到 ~/.deepseek/config.toml
deepseek doctor # 检查配置和连接
deepseek doctor --json # 机器可读诊断
deepseek setup --status # 只读安装状态检查
@@ -298,6 +309,7 @@ DeepSeek TUI 默认面向带 100 万 token 上下文窗口的 **DeepSeek V4**
- **安全**:项目级配置不能再通过覆盖 `api_key` / `base_url` / `provider` / `mcp_config_path` 做权限提升,也不能把 `approval_policy` 设为 `auto``sandbox_mode` 设为 `danger-full-access``SSL_CERT_FILE` 环境变量在 HTTPS 客户端被识别(PEM bundle + DER 兜底),方便企业 CA / MITM 代理用户。execpolicy 在 shlex 之前剥离 heredoc 主体,所以 `auto_allow = ["cat > file.txt"]` 也能匹配 heredoc 形式 `cat <<EOF > file.txt\nbody\nEOF`
- **打包**:新增 **Linux ARM64** 预编译包(鲲鹏、HarmonyOS PC、Kylin、openEuler、树莓派、Graviton 等),`npm i -g deepseek-tui` 直接可用;新增 `docs/INSTALL.md` 覆盖所有安装方式。`deepseek update` 修复了 v0.8.7 在所有平台都失败的架构名映射 bug。CI 删了三个重复 / 失效的 workflow。
- **Bug 修复**composer 的 `Option+Backspace` 现在按词删除;离线撰写队列绑定到当前会话 ID,老的无作用域队列会被关闭式回退;`display_path` 测试不再竞争 `$HOME`,并在 Windows 用 `\` 拼接。
- **认证与引导**:v0.8.8 默认不会触发系统凭据弹窗;启动、`doctor` 和普通请求路径使用 CLI 参数 → `~/.deepseek/config.toml` → 环境变量。`deepseek auth set --provider deepseek` 与 TUI 内引导现在都写入同一个用户配置文件。引导页文案改写为 "Step 1: 打开 https://platform.deepseek.com/api_keys / Step 2: 粘贴并回车"。"DeepSeek API key not found" 错误会优先给出 `deepseek auth set`,再说明环境变量方案(含 `~/.zshrc` vs `~/.zshenv` 提示)。规范入口 `deepseek` 现在接受 TUI 已声明支持的 `fireworks` / `sglang` provider;旧的 `deepseek login --api-key` / `deepseek logout` 也走同一套配置文件路径。
完整列表见 [CHANGELOG.md](CHANGELOG.md)。
+62
View File
@@ -131,6 +131,38 @@ impl Default for ModelRegistry {
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "accounts/fireworks/models/deepseek-v4-pro".to_string(),
provider: ProviderKind::Fireworks,
aliases: vec![
"deepseek-v4-pro".to_string(),
"fireworks-deepseek-v4-pro".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
provider: ProviderKind::Sglang,
aliases: vec![
"deepseek-v4-pro".to_string(),
"sglang-deepseek-v4-pro".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek-ai/DeepSeek-V4-Flash".to_string(),
provider: ProviderKind::Sglang,
aliases: vec![
"deepseek-v4-flash".to_string(),
"deepseek-chat".to_string(),
"deepseek-reasoner".to_string(),
"sglang-deepseek-v4-flash".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
];
Self::new(models)
}
@@ -287,6 +319,27 @@ mod tests {
assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
}
#[test]
fn fireworks_default_uses_canonical_model_id() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(None, Some(ProviderKind::Fireworks));
assert_eq!(resolved.resolved.provider, ProviderKind::Fireworks);
assert_eq!(
resolved.resolved.id,
"accounts/fireworks/models/deepseek-v4-pro"
);
}
#[test]
fn sglang_default_uses_canonical_model_id() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(None, Some(ProviderKind::Sglang));
assert_eq!(resolved.resolved.provider, ProviderKind::Sglang);
assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro");
}
#[test]
fn deepseek_v4_flash_alias_resolves_to_openrouter_when_provider_hinted() {
let registry = ModelRegistry::default();
@@ -304,4 +357,13 @@ mod tests {
assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
}
#[test]
fn deepseek_v4_flash_alias_resolves_to_sglang_when_provider_hinted() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Sglang));
assert_eq!(resolved.resolved.provider, ProviderKind::Sglang);
assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash");
}
}
+190 -136
View File
@@ -26,6 +26,8 @@ enum ProviderArg {
Openai,
Openrouter,
Novita,
Fireworks,
Sglang,
}
impl From<ProviderArg> for ProviderKind {
@@ -36,6 +38,8 @@ impl From<ProviderArg> for ProviderKind {
ProviderArg::Openai => ProviderKind::Openai,
ProviderArg::Openrouter => ProviderKind::Openrouter,
ProviderArg::Novita => ProviderKind::Novita,
ProviderArg::Fireworks => ProviderKind::Fireworks,
ProviderArg::Sglang => ProviderKind::Sglang,
}
}
}
@@ -129,7 +133,7 @@ enum Commands {
Serve(TuiPassthroughArgs),
/// Generate shell completions for the TUI binary.
Completions(TuiPassthroughArgs),
/// Save a DeepSeek API key to the shared config.
/// Save a provider API key to the shared user config file.
Login(LoginArgs),
/// Remove saved authentication state.
Logout,
@@ -204,9 +208,9 @@ struct AuthArgs {
enum AuthCommand {
/// Show current provider, env vars, and config-file presence.
Status,
/// Save an API key to the OS keyring (never written to disk in
/// plaintext). Reads from `--api-key`, `--api-key-stdin`, or
/// prompts on stdin when neither is given. Does not echo the key.
/// Save an API key to the shared user config file. Reads from
/// `--api-key`, `--api-key-stdin`, or prompts on stdin when
/// neither is given. Does not echo the key.
Set {
#[arg(long, value_enum)]
provider: ProviderArg,
@@ -223,8 +227,7 @@ enum AuthCommand {
#[arg(long, value_enum)]
provider: ProviderArg,
},
/// Delete a provider's key from the OS keyring (and from the
/// plaintext config slot, if present, for parity).
/// Delete a provider's key from the shared user config file.
Clear {
#[arg(long, value_enum)]
provider: ProviderArg,
@@ -232,8 +235,8 @@ enum AuthCommand {
/// List all known providers with their auth state, without
/// revealing keys.
List,
/// Migrate plaintext `api_key` values from `~/.deepseek/config.toml`
/// into the OS keyring, then strip them from the file.
/// Advanced: migrate config-file keys into a platform credential store.
#[command(hide = true)]
Migrate {
/// Don't actually write anything; print what would change.
#[arg(long, default_value_t = false)]
@@ -470,6 +473,14 @@ fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
}
fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
run_login_command_with_secrets(store, args, &no_keyring_secrets())
}
fn run_login_command_with_secrets(
store: &mut ConfigStore,
args: LoginArgs,
_secrets: &Secrets,
) -> Result<()> {
let provider: ProviderKind = args.provider.into();
store.config.provider = provider;
@@ -506,10 +517,80 @@ fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
Some(v) => v,
None => read_api_key_from_stdin()?,
};
store.config.auth_mode = Some("api_key".to_string());
store.config.providers.for_provider_mut(provider).api_key = Some(api_key);
write_provider_api_key_to_config(store, provider, &api_key);
store.save()?;
if provider == ProviderKind::Deepseek {
store.config.api_key = store.config.providers.deepseek.api_key.clone();
println!(
"logged in using API key mode (deepseek); saved key to {}",
store.path().display()
);
} else {
println!(
"logged in using API key mode ({}); saved key to {}",
provider.as_str(),
store.path().display()
);
}
Ok(())
}
fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
run_logout_command_with_secrets(store, &no_keyring_secrets())
}
fn run_logout_command_with_secrets(store: &mut ConfigStore, _secrets: &Secrets) -> Result<()> {
store.config.api_key = None;
for provider in PROVIDER_LIST {
clear_provider_api_key_from_config(store, provider);
}
store.config.auth_mode = None;
store.config.chatgpt_access_token = None;
store.config.device_code_session = None;
store.save()?;
println!("logged out");
Ok(())
}
/// Map [`ProviderKind`] to the canonical provider credential slot.
fn provider_slot(provider: ProviderKind) -> &'static str {
match provider {
ProviderKind::Deepseek => "deepseek",
ProviderKind::NvidiaNim => "nvidia-nim",
ProviderKind::Openai => "openai",
ProviderKind::Openrouter => "openrouter",
ProviderKind::Novita => "novita",
ProviderKind::Fireworks => "fireworks",
ProviderKind::Sglang => "sglang",
}
}
/// Provider order used by the `auth list` and `auth status` outputs.
const PROVIDER_LIST: [ProviderKind; 7] = [
ProviderKind::Deepseek,
ProviderKind::NvidiaNim,
ProviderKind::Openrouter,
ProviderKind::Novita,
ProviderKind::Fireworks,
ProviderKind::Sglang,
ProviderKind::Openai,
];
fn no_keyring_secrets() -> Secrets {
Secrets::new(std::sync::Arc::new(
deepseek_secrets::InMemoryKeyringStore::new(),
))
}
fn write_provider_api_key_to_config(
store: &mut ConfigStore,
provider: ProviderKind,
api_key: &str,
) {
store.config.provider = provider;
store.config.auth_mode = Some("api_key".to_string());
store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());
if provider == ProviderKind::Deepseek {
store.config.api_key = Some(api_key.to_string());
if store.config.default_text_model.is_none() {
store.config.default_text_model = Some(
store
@@ -522,53 +603,17 @@ fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
);
}
}
store.save()?;
}
fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
store.config.providers.for_provider_mut(provider).api_key = None;
if provider == ProviderKind::Deepseek {
println!(
"logged in using API key mode (deepseek). This also updates the shared deepseek-tui config."
);
} else {
println!("logged in using API key mode ({})", provider.as_str());
}
Ok(())
}
fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
store.config.api_key = None;
store.config.providers.deepseek.api_key = None;
store.config.providers.nvidia_nim.api_key = None;
store.config.providers.openai.api_key = None;
store.config.auth_mode = None;
store.config.chatgpt_access_token = None;
store.config.device_code_session = None;
store.save()?;
println!("logged out");
Ok(())
}
/// Map [`ProviderKind`] to the canonical keyring slot name (`-a` arg
/// in `security find-generic-password`).
fn keyring_slot(provider: ProviderKind) -> &'static str {
match provider {
ProviderKind::Deepseek => "deepseek",
ProviderKind::NvidiaNim => "nvidia-nim",
ProviderKind::Openai => "openai",
ProviderKind::Openrouter => "openrouter",
ProviderKind::Novita => "novita",
store.config.api_key = None;
}
}
/// Provider order used by the `auth list` and `auth status` outputs.
const PROVIDER_LIST: [ProviderKind; 5] = [
ProviderKind::Deepseek,
ProviderKind::NvidiaNim,
ProviderKind::Openrouter,
ProviderKind::Novita,
ProviderKind::Openai,
];
fn provider_env_set(provider: ProviderKind) -> bool {
deepseek_secrets::env_for(keyring_slot(provider)).is_some()
deepseek_secrets::env_for(provider_slot(provider)).is_some()
}
fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
@@ -585,7 +630,14 @@ fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
}
fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {
run_auth_command_with_secrets(store, command, &Secrets::auto_detect())
match command {
AuthCommand::Migrate { dry_run } => run_auth_command_with_secrets(
store,
AuthCommand::Migrate { dry_run },
&Secrets::auto_detect(),
),
other => run_auth_command_with_secrets(store, other, &no_keyring_secrets()),
}
}
fn run_auth_command_with_secrets(
@@ -596,20 +648,11 @@ fn run_auth_command_with_secrets(
match command {
AuthCommand::Status => {
println!("provider: {}", store.config.provider.as_str());
println!("keyring backend: {}", secrets.backend_name());
for provider in PROVIDER_LIST {
let slot = keyring_slot(provider);
let keyring_set = secrets
.get(slot)
.ok()
.flatten()
.is_some_and(|v| !v.trim().is_empty());
let slot = provider_slot(provider);
let env_set = provider_env_set(provider);
let file_set = provider_config_set(store, provider);
println!(
"{slot} auth: keyring={}, env={}, config={}",
keyring_set, env_set, file_set
);
println!("{slot} auth: env={}, config={}", env_set, file_set);
}
Ok(())
}
@@ -619,39 +662,27 @@ fn run_auth_command_with_secrets(
api_key_stdin,
} => {
let provider: ProviderKind = provider.into();
let slot = keyring_slot(provider);
let slot = provider_slot(provider);
let api_key = match (api_key, api_key_stdin) {
(Some(v), _) => v,
(None, true) => read_api_key_from_stdin()?,
(None, false) => prompt_api_key(slot)?,
};
secrets
.set(slot, &api_key)
.with_context(|| format!("failed to write {slot} key to keyring"))?;
write_provider_api_key_to_config(store, provider, &api_key);
store.save()?;
// Don't print the key. Don't echo length.
println!("saved API key for {slot} to {}", secrets.backend_name());
println!("saved API key for {slot} to {}", store.path().display());
Ok(())
}
AuthCommand::Get { provider } => {
let provider: ProviderKind = provider.into();
let slot = keyring_slot(provider);
let in_keyring = secrets
.get(slot)
.ok()
.flatten()
.is_some_and(|v| !v.trim().is_empty());
let slot = provider_slot(provider);
let in_env = provider_env_set(provider);
let in_file = provider_config_set(store, provider);
// Report the highest-priority source that has it.
let resolved = secrets.resolve(slot).is_some() || in_file;
let resolved = in_env || in_file;
if resolved {
let source = if in_keyring {
"keyring"
} else if in_env {
"env"
} else {
"config-file"
};
let source = if in_file { "config-file" } else { "env" };
println!("{slot}: set (source: {source})");
} else {
println!("{slot}: not set");
@@ -660,37 +691,19 @@ fn run_auth_command_with_secrets(
}
AuthCommand::Clear { provider } => {
let provider: ProviderKind = provider.into();
let slot = keyring_slot(provider);
secrets
.delete(slot)
.with_context(|| format!("failed to delete {slot} key from keyring"))?;
// Also clear the plaintext slot in config.toml for parity.
store.config.providers.for_provider_mut(provider).api_key = None;
if provider == ProviderKind::Deepseek {
store.config.api_key = None;
}
let slot = provider_slot(provider);
clear_provider_api_key_from_config(store, provider);
store.save()?;
println!("cleared API key for {slot}");
Ok(())
}
AuthCommand::List => {
println!("keyring backend: {}", secrets.backend_name());
println!("provider keyring env config");
println!("provider env config");
for provider in PROVIDER_LIST {
let slot = keyring_slot(provider);
let kr = secrets
.get(slot)
.ok()
.flatten()
.is_some_and(|v| !v.trim().is_empty());
let slot = provider_slot(provider);
let env = provider_env_set(provider);
let file = provider_config_set(store, provider);
println!(
"{slot:<12} {} {} {}",
yes_no(kr),
yes_no(env),
yes_no(file)
);
println!("{slot:<12} {} {}", yes_no(env), yes_no(file));
}
Ok(())
}
@@ -721,14 +734,14 @@ fn prompt_api_key(slot: &str) -> Result<String> {
Ok(key)
}
/// Move plaintext keys from config.toml into the keyring. Stays
/// idempotent: rerunning is a no-op once the file is clean.
/// Move plaintext keys from config.toml into an explicit platform credential
/// store. Hidden in v0.8.8 because the normal setup path is config/env only.
fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
for provider in PROVIDER_LIST {
let slot = keyring_slot(provider);
let slot = provider_slot(provider);
let from_provider_block = store
.config
.providers
@@ -1030,9 +1043,11 @@ fn delegate_to_tui(
| ProviderKind::NvidiaNim
| ProviderKind::Openrouter
| ProviderKind::Novita
| ProviderKind::Fireworks
| ProviderKind::Sglang
) {
bail!(
"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, and Novita providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.",
"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, Novita, Fireworks, and SGLang providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.",
resolved_runtime.provider.as_str()
);
}
@@ -1042,6 +1057,7 @@ fn delegate_to_tui(
cmd.env("DEEPSEEK_PROVIDER", resolved_runtime.provider.as_str());
if let Some(api_key) = resolved_runtime.api_key.as_ref() {
cmd.env("DEEPSEEK_API_KEY", api_key);
cmd.env("DEEPSEEK_API_KEY_SOURCE", "dispatcher");
}
if let Some(model) = cli.model.as_ref() {
@@ -1064,6 +1080,7 @@ fn delegate_to_tui(
}
if let Some(api_key) = cli.api_key.as_ref() {
cmd.env("DEEPSEEK_API_KEY", api_key);
cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli");
}
if let Some(base_url) = cli.base_url.as_ref() {
cmd.env("DEEPSEEK_BASE_URL", base_url);
@@ -1432,15 +1449,16 @@ mod tests {
}
#[test]
fn deepseek_login_writes_tui_compatible_config() {
fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
let path = std::env::temp_dir().join(format!(
"deepseek-cli-login-test-{}-{nanos}.toml",
std::process::id()
));
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
let secrets = no_keyring_secrets();
run_login_command(
run_login_command_with_secrets(
&mut store,
LoginArgs {
provider: ProviderArg::Deepseek,
@@ -1449,10 +1467,15 @@ mod tests {
device_code: false,
token: None,
},
&secrets,
)
.expect("login should write config");
assert_eq!(store.config.api_key.as_deref(), Some("sk-test"));
assert_eq!(
store.config.providers.deepseek.api_key.as_deref(),
Some("sk-test")
);
assert_eq!(
store.config.default_text_model.as_deref(),
Some("deepseek-v4-pro")
@@ -1517,6 +1540,28 @@ mod tests {
}))
));
let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Set {
provider: ProviderArg::Fireworks,
api_key: None,
api_key_stdin: false,
}
}))
));
let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Get {
provider: ProviderArg::Sglang
}
}))
));
let cli = parse_ok(&["deepseek", "auth", "list"]);
assert!(matches!(
cli.command,
@@ -1543,18 +1588,14 @@ mod tests {
}
#[test]
fn auth_set_writes_to_keyring_and_not_to_config_file() {
use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
use std::sync::Arc;
fn auth_set_writes_to_shared_config_file() {
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
let path = std::env::temp_dir().join(format!(
"deepseek-cli-auth-set-test-{}-{nanos}.toml",
std::process::id()
));
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
let inner = Arc::new(InMemoryKeyringStore::new());
let secrets = Secrets::new(inner.clone());
let secrets = no_keyring_secrets();
run_auth_command_with_secrets(
&mut store,
@@ -1567,27 +1608,19 @@ mod tests {
)
.expect("set should succeed");
assert_eq!(store.config.api_key.as_deref(), Some("sk-keyring"));
assert_eq!(
inner.get("deepseek").unwrap(),
Some("sk-keyring".to_string())
store.config.providers.deepseek.api_key.as_deref(),
Some("sk-keyring")
);
// Plaintext config slot must not be written.
assert!(store.config.api_key.is_none());
assert!(store.config.providers.deepseek.api_key.is_none());
let saved = std::fs::read_to_string(&path).unwrap_or_default();
assert!(
!saved.contains("sk-keyring"),
"plaintext key leaked into config: {saved}"
);
assert!(saved.contains("api_key = \"sk-keyring\""));
let _ = std::fs::remove_file(path);
}
#[test]
fn auth_clear_removes_from_keyring_and_config() {
use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
use std::sync::Arc;
fn auth_clear_removes_from_config() {
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
let path = std::env::temp_dir().join(format!(
"deepseek-cli-auth-clear-test-{}-{nanos}.toml",
@@ -1598,9 +1631,7 @@ mod tests {
store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
store.save().unwrap();
let inner = Arc::new(InMemoryKeyringStore::new());
inner.set("deepseek", "sk-keyring").unwrap();
let secrets = Secrets::new(inner.clone());
let secrets = no_keyring_secrets();
run_auth_command_with_secrets(
&mut store,
@@ -1611,13 +1642,36 @@ mod tests {
)
.expect("clear should succeed");
assert_eq!(inner.get("deepseek").unwrap(), None);
assert!(store.config.api_key.is_none());
assert!(store.config.providers.deepseek.api_key.is_none());
let _ = std::fs::remove_file(path);
}
#[test]
fn logout_removes_plaintext_provider_keys() {
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
let path = std::env::temp_dir().join(format!(
"deepseek-cli-logout-test-{}-{nanos}.toml",
std::process::id()
));
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
store.config.api_key = Some("sk-stale".to_string());
store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
store.save().unwrap();
let secrets = no_keyring_secrets();
run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
assert!(store.config.api_key.is_none());
assert!(store.config.providers.deepseek.api_key.is_none());
assert!(store.config.providers.fireworks.api_key.is_none());
let _ = std::fs::remove_file(path);
}
#[test]
fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
+210 -40
View File
@@ -19,8 +19,13 @@ const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro";
const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro";
const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro";
const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/v1";
const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1";
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
@@ -31,6 +36,8 @@ pub enum ProviderKind {
Openai,
Openrouter,
Novita,
Fireworks,
Sglang,
}
impl ProviderKind {
@@ -42,6 +49,8 @@ impl ProviderKind {
Self::Openai => "openai",
Self::Openrouter => "openrouter",
Self::Novita => "novita",
Self::Fireworks => "fireworks",
Self::Sglang => "sglang",
}
}
@@ -53,6 +62,8 @@ impl ProviderKind {
"openai" | "open-ai" => Some(Self::Openai),
"openrouter" | "open_router" => Some(Self::Openrouter),
"novita" => Some(Self::Novita),
"fireworks" | "fireworks-ai" => Some(Self::Fireworks),
"sglang" | "sg-lang" => Some(Self::Sglang),
_ => None,
}
}
@@ -77,6 +88,10 @@ pub struct ProvidersToml {
pub openrouter: ProviderConfigToml,
#[serde(default)]
pub novita: ProviderConfigToml,
#[serde(default)]
pub fireworks: ProviderConfigToml,
#[serde(default)]
pub sglang: ProviderConfigToml,
}
impl ProvidersToml {
@@ -88,6 +103,8 @@ impl ProvidersToml {
ProviderKind::Openai => &self.openai,
ProviderKind::Openrouter => &self.openrouter,
ProviderKind::Novita => &self.novita,
ProviderKind::Fireworks => &self.fireworks,
ProviderKind::Sglang => &self.sglang,
}
}
@@ -98,6 +115,8 @@ impl ProvidersToml {
ProviderKind::Openai => &mut self.openai,
ProviderKind::Openrouter => &mut self.openrouter,
ProviderKind::Novita => &mut self.novita,
ProviderKind::Fireworks => &mut self.fireworks,
ProviderKind::Sglang => &mut self.sglang,
}
}
}
@@ -297,6 +316,8 @@ impl ConfigToml {
&project.providers.openrouter,
);
merge_provider_config(&mut self.providers.novita, &project.providers.novita);
merge_provider_config(&mut self.providers.fireworks, &project.providers.fireworks);
merge_provider_config(&mut self.providers.sglang, &project.providers.sglang);
if project.network.is_some() {
self.network = project.network;
@@ -346,6 +367,12 @@ impl ConfigToml {
"providers.novita.api_key" => self.providers.novita.api_key.clone(),
"providers.novita.base_url" => self.providers.novita.base_url.clone(),
"providers.novita.model" => self.providers.novita.model.clone(),
"providers.fireworks.api_key" => self.providers.fireworks.api_key.clone(),
"providers.fireworks.base_url" => self.providers.fireworks.base_url.clone(),
"providers.fireworks.model" => self.providers.fireworks.model.clone(),
"providers.sglang.api_key" => self.providers.sglang.api_key.clone(),
"providers.sglang.base_url" => self.providers.sglang.base_url.clone(),
"providers.sglang.model" => self.providers.sglang.model.clone(),
_ => self.extras.get(key).map(toml::Value::to_string),
}
}
@@ -415,6 +442,24 @@ impl ConfigToml {
"providers.novita.model" => {
self.providers.novita.model = Some(value.to_string());
}
"providers.fireworks.api_key" => {
self.providers.fireworks.api_key = Some(value.to_string());
}
"providers.fireworks.base_url" => {
self.providers.fireworks.base_url = Some(value.to_string());
}
"providers.fireworks.model" => {
self.providers.fireworks.model = Some(value.to_string());
}
"providers.sglang.api_key" => {
self.providers.sglang.api_key = Some(value.to_string());
}
"providers.sglang.base_url" => {
self.providers.sglang.base_url = Some(value.to_string());
}
"providers.sglang.model" => {
self.providers.sglang.model = Some(value.to_string());
}
_ => {
self.extras
.insert(key.to_string(), toml::Value::String(value.to_string()));
@@ -462,6 +507,12 @@ impl ConfigToml {
"providers.novita.api_key" => self.providers.novita.api_key = None,
"providers.novita.base_url" => self.providers.novita.base_url = None,
"providers.novita.model" => self.providers.novita.model = None,
"providers.fireworks.api_key" => self.providers.fireworks.api_key = None,
"providers.fireworks.base_url" => self.providers.fireworks.base_url = None,
"providers.fireworks.model" => self.providers.fireworks.model = None,
"providers.sglang.api_key" => self.providers.sglang.api_key = None,
"providers.sglang.base_url" => self.providers.sglang.base_url = None,
"providers.sglang.model" => self.providers.sglang.model = None,
_ => {
self.extras.remove(key);
}
@@ -555,6 +606,24 @@ impl ConfigToml {
if let Some(v) = self.providers.novita.model.as_ref() {
out.insert("providers.novita.model".to_string(), v.clone());
}
if let Some(v) = self.providers.fireworks.api_key.as_ref() {
out.insert("providers.fireworks.api_key".to_string(), redact_secret(v));
}
if let Some(v) = self.providers.fireworks.base_url.as_ref() {
out.insert("providers.fireworks.base_url".to_string(), v.clone());
}
if let Some(v) = self.providers.fireworks.model.as_ref() {
out.insert("providers.fireworks.model".to_string(), v.clone());
}
if let Some(v) = self.providers.sglang.api_key.as_ref() {
out.insert("providers.sglang.api_key".to_string(), redact_secret(v));
}
if let Some(v) = self.providers.sglang.base_url.as_ref() {
out.insert("providers.sglang.base_url".to_string(), v.clone());
}
if let Some(v) = self.providers.sglang.model.as_ref() {
out.insert("providers.sglang.model".to_string(), v.clone());
}
for (k, v) in &self.extras {
out.insert(k.clone(), v.to_string());
@@ -562,19 +631,25 @@ impl ConfigToml {
out
}
/// Resolve runtime options with the default secrets façade
/// ([`Secrets::auto_detect`]). For test injection or custom backends,
/// use [`Self::resolve_runtime_options_with_secrets`].
/// Resolve runtime options without touching platform credential stores.
///
/// v0.8.8 keeps the default auth path deliberately boring:
/// CLI flag → config file → environment. Explicit keyring migration
/// remains available through auth commands, but normal startup and
/// diagnostics must not trigger platform credential prompts.
#[must_use]
pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
self.resolve_runtime_options_with_secrets(cli, default_secrets())
let no_keyring = Secrets::new(std::sync::Arc::new(
deepseek_secrets::InMemoryKeyringStore::new(),
));
self.resolve_runtime_options_with_secrets(cli, &no_keyring)
}
/// Resolve runtime options using an explicit secrets façade.
///
/// API-key precedence is **CLI flag → keyring → env → config-file**.
/// (`Secrets::resolve` already collapses keyring → env, so we layer
/// CLI on top and TOML on the bottom.)
/// API-key precedence is **CLI flag → config-file → environment**.
/// If a caller explicitly injects a secrets façade with a populated
/// credential store, that store is used only when config/env are empty.
#[must_use]
pub fn resolve_runtime_options_with_secrets(
&self,
@@ -594,18 +669,17 @@ impl ConfigToml {
let root_deepseek_model = (provider == ProviderKind::Deepseek)
.then(|| self.default_text_model.clone())
.flatten();
// CLI flag wins outright. Otherwise: keyring → env (via Secrets) → config-file.
// CLI flag wins outright. Otherwise: config-file → injected secrets/env.
// This makes `deepseek auth set` a reliable fix even when the user's
// shell still exports an old key. The default caller injects an empty
// in-memory store, so this path does not touch platform credential
// stores during ordinary startup.
let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
let api_key = cli
.api_key
.clone()
.or_else(|| secrets.resolve(provider.as_str()))
.or_else(|| {
let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
if from_file.is_some() {
warn_legacy_api_key_in_toml_once();
}
from_file
});
.or_else(|| from_file.clone())
.or_else(|| secrets.resolve(provider.as_str()));
let base_url = cli
.base_url
@@ -619,6 +693,8 @@ impl ConfigToml {
ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
});
let model = cli
@@ -634,6 +710,8 @@ impl ConfigToml {
ProviderKind::Openai => DEFAULT_OPENAI_MODEL.to_string(),
ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL.to_string(),
ProviderKind::Novita => DEFAULT_NOVITA_MODEL.to_string(),
ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL.to_string(),
ProviderKind::Sglang => DEFAULT_SGLANG_MODEL.to_string(),
});
let model = normalize_model_for_provider(provider, &model);
@@ -733,6 +811,17 @@ fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
"deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
| "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
(ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
DEFAULT_FIREWORKS_MODEL.to_string()
}
(ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
DEFAULT_SGLANG_MODEL.to_string()
}
(
ProviderKind::Sglang,
"deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
| "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
_ => model.to_string(),
}
}
@@ -810,18 +899,6 @@ impl ConfigStore {
}
}
/// One-time deprecation warning emitted whenever a TOML `api_key`
/// value is read by the resolver. Callers should migrate to the
/// keyring via `deepseek auth set` / `deepseek auth migrate`.
fn warn_legacy_api_key_in_toml_once() {
static WARNED: OnceLock<()> = OnceLock::new();
let _ = WARNED.get_or_init(|| {
tracing::warn!(
"api_key in config.toml is deprecated; use 'deepseek auth set' or 'deepseek auth migrate' to move it to the OS keyring"
);
});
}
/// Process-wide default [`Secrets`] façade. The first caller wins; the
/// lock is exposed so test or CLI code can install an explicit
/// backend (e.g. an [`deepseek_secrets::InMemoryKeyringStore`]) before
@@ -829,13 +906,10 @@ fn warn_legacy_api_key_in_toml_once() {
pub fn default_secrets() -> &'static Secrets {
static SECRETS: OnceLock<Secrets> = OnceLock::new();
SECRETS.get_or_init(|| {
// Tests should never poke the real OS keyring — using
// auto_detect would surface stale macOS Keychain entries
// from the developer's session and break the precedence
// assertions. Cargo sets the `RUST_TEST_*` family of env
// vars (and `CARGO_PKG_NAME` is always populated), but the
// `cfg(test)` flag is the canonical signal here. See
// `install_test_secrets` for explicit installs.
// Tests should never poke real platform credential stores. Cargo sets the
// `RUST_TEST_*` family of env vars (and `CARGO_PKG_NAME` is
// always populated), but the `cfg(test)` flag is the canonical
// signal here. See `install_test_secrets` for explicit installs.
#[cfg(test)]
{
Secrets::new(std::sync::Arc::new(
@@ -897,6 +971,8 @@ struct EnvRuntimeOverrides {
openai_base_url: Option<String>,
openrouter_base_url: Option<String>,
novita_base_url: Option<String>,
fireworks_base_url: Option<String>,
sglang_base_url: Option<String>,
}
impl EnvRuntimeOverrides {
@@ -931,6 +1007,12 @@ impl EnvRuntimeOverrides {
novita_base_url: std::env::var("NOVITA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
sglang_base_url: std::env::var("SGLANG_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
}
}
@@ -943,6 +1025,8 @@ impl EnvRuntimeOverrides {
ProviderKind::Openai => self.openai_base_url.clone(),
ProviderKind::Openrouter => self.openrouter_base_url.clone(),
ProviderKind::Novita => self.novita_base_url.clone(),
ProviderKind::Fireworks => self.fireworks_base_url.clone(),
ProviderKind::Sglang => self.sglang_base_url.clone(),
}
}
}
@@ -973,6 +1057,10 @@ mod tests {
openrouter_base_url: Option<OsString>,
novita_api_key: Option<OsString>,
novita_base_url: Option<OsString>,
fireworks_api_key: Option<OsString>,
fireworks_base_url: Option<OsString>,
sglang_api_key: Option<OsString>,
sglang_base_url: Option<OsString>,
}
impl EnvGuard {
@@ -991,6 +1079,10 @@ mod tests {
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"),
fireworks_api_key: env::var_os("FIREWORKS_API_KEY"),
fireworks_base_url: env::var_os("FIREWORKS_BASE_URL"),
sglang_api_key: env::var_os("SGLANG_API_KEY"),
sglang_base_url: env::var_os("SGLANG_BASE_URL"),
};
// Safety: test-only environment mutation guarded by a module mutex.
unsafe {
@@ -1007,6 +1099,10 @@ mod tests {
env::remove_var("OPENROUTER_BASE_URL");
env::remove_var("NOVITA_API_KEY");
env::remove_var("NOVITA_BASE_URL");
env::remove_var("FIREWORKS_API_KEY");
env::remove_var("FIREWORKS_BASE_URL");
env::remove_var("SGLANG_API_KEY");
env::remove_var("SGLANG_BASE_URL");
}
guard
}
@@ -1037,6 +1133,10 @@ mod tests {
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());
Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take());
Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take());
Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take());
Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take());
}
}
}
@@ -1215,6 +1315,11 @@ mod tests {
);
assert_eq!(ProviderKind::parse("novita"), Some(ProviderKind::Novita));
assert_eq!(ProviderKind::parse("Novita"), Some(ProviderKind::Novita));
assert_eq!(
ProviderKind::parse("fireworks-ai"),
Some(ProviderKind::Fireworks)
);
assert_eq!(ProviderKind::parse("sg-lang"), Some(ProviderKind::Sglang));
}
#[test]
@@ -1249,6 +1354,38 @@ mod tests {
assert_eq!(resolved.model, DEFAULT_NOVITA_MODEL);
}
#[test]
fn fireworks_provider_defaults_to_canonical_endpoint_and_model() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let config = ConfigToml {
provider: ProviderKind::Fireworks,
..ConfigToml::default()
};
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::Fireworks);
assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL);
assert_eq!(resolved.model, DEFAULT_FIREWORKS_MODEL);
}
#[test]
fn sglang_provider_defaults_to_local_endpoint_and_model() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let config = ConfigToml {
provider: ProviderKind::Sglang,
..ConfigToml::default()
};
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::Sglang);
assert_eq!(resolved.base_url, DEFAULT_SGLANG_BASE_URL);
assert_eq!(resolved.model, DEFAULT_SGLANG_MODEL);
}
#[test]
fn openrouter_env_api_key_falls_back_when_config_missing() {
let _lock = env_lock();
@@ -1285,6 +1422,24 @@ mod tests {
assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL);
}
#[test]
fn fireworks_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", "fireworks");
env::set_var("FIREWORKS_API_KEY", "fw-env-key");
}
let resolved =
ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::Fireworks);
assert_eq!(resolved.api_key.as_deref(), Some("fw-env-key"));
assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL);
}
#[test]
fn openrouter_provider_normalizes_flash_aliases() {
let _lock = env_lock();
@@ -1317,6 +1472,22 @@ mod tests {
assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL);
}
#[test]
fn sglang_provider_normalizes_flash_aliases() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let cli = CliRuntimeOverrides {
provider: Some(ProviderKind::Sglang),
model: Some("deepseek-v4-flash".to_string()),
..CliRuntimeOverrides::default()
};
let resolved = ConfigToml::default().resolve_runtime_options(&cli);
assert_eq!(resolved.provider, ProviderKind::Sglang);
assert_eq!(resolved.model, DEFAULT_SGLANG_FLASH_MODEL);
}
#[test]
fn openrouter_provider_specific_config_overrides_env() {
let _lock = env_lock();
@@ -1335,7 +1506,7 @@ mod tests {
}
#[test]
fn keyring_resolves_above_env_and_toml() {
fn config_file_resolves_above_env_and_keyring() {
use deepseek_secrets::KeyringStore;
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
@@ -1351,14 +1522,14 @@ mod tests {
let resolved =
config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
// Safety: env mutation guarded by env_lock().
unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
}
#[test]
fn env_resolves_when_keyring_empty_above_toml() {
fn env_resolves_when_config_file_and_keyring_empty() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
// Safety: env mutation guarded by env_lock().
@@ -1367,8 +1538,7 @@ mod tests {
let secrets = Secrets::new(std::sync::Arc::new(
deepseek_secrets::InMemoryKeyringStore::new(),
));
let mut config = ConfigToml::default();
config.providers.deepseek.api_key = Some("file-key".to_string());
let config = ConfigToml::default();
let resolved =
config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
+30
View File
@@ -401,6 +401,8 @@ pub fn env_for(name: &str) -> Option<String> {
"nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
&["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
}
"fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"],
"sglang" | "sg-lang" => &["SGLANG_API_KEY"],
"openai" => &["OPENAI_API_KEY"],
_ => return None,
};
@@ -435,6 +437,8 @@ mod tests {
"NOVITA_API_KEY",
"NVIDIA_API_KEY",
"NVIDIA_NIM_API_KEY",
"FIREWORKS_API_KEY",
"SGLANG_API_KEY",
"OPENAI_API_KEY",
] {
// Safety: tests serialise on env_lock(); the broader
@@ -525,6 +529,32 @@ mod tests {
unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
}
#[test]
fn fireworks_env_aliases_resolve() {
let _lock = env_lock();
clear_known_envs();
// Safety: env mutation guarded by env_lock().
unsafe { std::env::set_var("FIREWORKS_API_KEY", "fw-key") };
assert_eq!(env_for("fireworks").as_deref(), Some("fw-key"));
assert_eq!(env_for("fireworks-ai").as_deref(), Some("fw-key"));
// Safety: env mutation guarded by env_lock().
unsafe { std::env::remove_var("FIREWORKS_API_KEY") };
}
#[test]
fn sglang_env_aliases_resolve() {
let _lock = env_lock();
clear_known_envs();
// Safety: env mutation guarded by env_lock().
unsafe { std::env::set_var("SGLANG_API_KEY", "sglang-key") };
assert_eq!(env_for("sglang").as_deref(), Some("sglang-key"));
assert_eq!(env_for("sg-lang").as_deref(), Some("sglang-key"));
// Safety: env mutation guarded by env_lock().
unsafe { std::env::remove_var("SGLANG_API_KEY") };
}
#[cfg(unix)]
#[test]
fn file_store_round_trips_with_secure_perms() {
+289 -116
View File
@@ -1091,7 +1091,7 @@ impl Config {
})
}
fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {
pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {
let providers = self.providers.as_ref()?;
Some(match provider {
ApiProvider::Deepseek => &providers.deepseek,
@@ -1104,7 +1104,7 @@ impl Config {
})
}
fn provider_config(&self) -> Option<&ProviderConfig> {
pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {
self.provider_config_for(self.api_provider())
}
@@ -1175,16 +1175,12 @@ impl Config {
/// Read the API key.
///
/// Precedence: **explicit in-memory override → OS keyring → environment
/// → provider-scoped config → legacy root config**. The keyring + env
/// layers are collapsed by [`deepseek_secrets::Secrets::resolve`].
/// Precedence: **explicit in-memory override → provider/root config
/// → environment**.
///
/// The in-memory `self.api_key` override takes priority over the
/// keyring so a fresh key entered via `/logout` + onboarding actually
/// takes effect even when an old key is still cached in the OS keyring
/// (#343). The override is only honored when the user *explicitly* set
/// the field (not the legacy `API_KEYRING_SENTINEL` placeholder, not
/// empty whitespace).
/// The in-memory `self.api_key` override is only honored when the user
/// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`
/// placeholder, not empty whitespace).
pub fn deepseek_api_key(&self) -> Result<String> {
let provider = self.api_provider();
let slot = match provider {
@@ -1197,9 +1193,7 @@ impl Config {
};
// 0. Explicit in-memory override (set by onboarding / provider
// picker). Wins over keyring + env so a freshly-entered key
// takes effect immediately even if a stale credential lingers
// in the OS keyring (#343).
// picker). Wins so a freshly-entered key takes effect immediately.
if let Some(configured) = self.api_key.as_ref()
&& !configured.trim().is_empty()
&& configured != API_KEYRING_SENTINEL
@@ -1207,33 +1201,37 @@ impl Config {
return Ok(configured.clone());
}
// 1. OS keyring + 2. environment variables (handled by Secrets).
let secrets = deepseek_secrets::Secrets::auto_detect();
if let Some(value) = secrets.resolve(slot)
&& !value.trim().is_empty()
{
return Ok(value);
}
// 3. config file (provider-scoped slot).
// 1. Config file (provider-scoped slot). This intentionally wins
// over ambient env so `deepseek auth set` fixes stale shell exports.
if let Some(configured) = self
.provider_config_for(provider)
.and_then(|provider| provider.api_key.clone())
&& !configured.trim().is_empty()
{
tracing::warn!(
"[providers.{slot}] api_key in config.toml is deprecated; \
run 'deepseek auth set --provider {slot}' to move it to the OS keyring"
);
return Ok(configured);
}
// 2. Environment variables. Do not query platform credential stores
// here; routine startup and doctor checks must stay prompt-free.
if let Some(value) = deepseek_secrets::env_for(slot)
&& !value.trim().is_empty()
{
return Ok(value);
}
match provider {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(
"DeepSeek API key not found. Set it using one of these methods:\n\
1. Run 'deepseek auth set --provider deepseek' to save it in the OS keyring (recommended)\n\
2. Set DEEPSEEK_API_KEY environment variable\n\
3. Add 'api_key = \"your-key\"' to ~/.deepseek/config.toml (deprecated)"
"DeepSeek API key not found.\n\
\n\
1. Get a key: https://platform.deepseek.com/api_keys\n\
2. Save it (works in every folder, no OS prompts):\n\
deepseek auth set --provider deepseek\n\
\n\
Alternatives:\n\
export DEEPSEEK_API_KEY=<your-key> (current shell only;\n\
also note: zsh users exports in ~/.zshrc only reach interactive\n\
shells, prefer ~/.zshenv for everything)\n\
api_key = \"<your-key>\" in ~/.deepseek/config.toml"
),
ApiProvider::NvidiaNim => anyhow::bail!(
"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \
@@ -1596,11 +1594,6 @@ fn apply_env_overrides(config: &mut Config) {
if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
config.provider = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_API_KEY")
&& !value.trim().is_empty()
{
config.api_key = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_BASE_URL") {
if matches!(config.api_provider(), ApiProvider::NvidiaNim) {
config
@@ -2143,8 +2136,42 @@ pub fn ensure_parent_dir(path: &Path) -> Result<()> {
Ok(())
}
/// Save an API key to the config file. Creates the file if it doesn't exist.
pub fn save_api_key(api_key: &str) -> Result<PathBuf> {
/// Where a saved credential ended up. Returned by [`save_api_key`] so
/// the caller can show a confirmation message without leaking the key.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SavedCredential {
/// Stored in the deepseek config file at the given path.
ConfigFile(PathBuf),
}
impl SavedCredential {
/// Human-readable description for status / log output. Never
/// includes the key value.
#[must_use]
pub fn describe(&self) -> String {
match self {
Self::ConfigFile(path) => path.display().to_string(),
}
}
}
/// Save the active provider's API key to `~/.deepseek/config.toml`.
///
/// v0.8.8 intentionally uses the shared config file as the default
/// setup path. It works in every folder, in npm installs, in IDE
/// terminals, and without platform credential prompts.
pub fn save_api_key(api_key: &str) -> Result<SavedCredential> {
let trimmed = api_key.trim();
if trimmed.is_empty() {
anyhow::bail!("Refusing to save an empty API key.");
}
let path = save_api_key_to_config_file(trimmed)?;
Ok(SavedCredential::ConfigFile(path))
}
/// Write the `api_key` slot directly to `config.toml`.
fn save_api_key_to_config_file(api_key: &str) -> Result<PathBuf> {
fn is_api_key_assignment(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed
@@ -2157,8 +2184,6 @@ pub fn save_api_key(api_key: &str) -> Result<PathBuf> {
ensure_parent_dir(&config_path)?;
// Don't use keychain - just write directly to config file
// Keychain causes permission prompts on macOS for unsigned binaries
let key_to_write = api_key.to_string();
let content = if config_path.exists() {
@@ -2217,23 +2242,40 @@ reasoning_effort = "max"
Ok(config_path)
}
/// Check if an API key is configured (either in config or environment)
/// Check if an API key is configured anywhere the runtime can resolve it.
///
/// Order of inspection:
/// 1. `DEEPSEEK_API_KEY` env var (fast, no I/O, no OS prompts).
/// 2. In-memory override on the config (set by onboarding / picker).
/// 3. Config-file `api_key` slot (cheap file read already done by
/// the loaded `Config`).
///
/// Platform credential stores are intentionally not queried here.
/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8
/// keeps the default auth path to environment variables and
/// `~/.deepseek/config.toml`.
///
/// Used by [`crate::tui::app::App::new`] to decide whether to gate
/// the user behind the in-TUI api-key onboarding screen — getting
/// this wrong made users get prompted for credentials in situations
/// where normal env/config auth was already available.
pub fn has_api_key(config: &Config) -> bool {
// Check environment variable first (highest priority)
if std::env::var("DEEPSEEK_API_KEY").is_ok_and(|k| !k.trim().is_empty()) {
return true;
}
// Then check config file
config
if config
.api_key
.as_ref()
.is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
{
return true;
}
false
}
/// 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.
/// Check whether the given provider has any usable API key — via env var,
/// provider/root config. 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 {
@@ -2258,39 +2300,35 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {
return true;
}
if let Some(providers) = config.providers.as_ref() {
let entry = match provider {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => &providers.deepseek,
ApiProvider::NvidiaNim => &providers.nvidia_nim,
ApiProvider::Openrouter => &providers.openrouter,
ApiProvider::Novita => &providers.novita,
ApiProvider::Fireworks => &providers.fireworks,
ApiProvider::Sglang => &providers.sglang,
};
if entry
.api_key
.as_ref()
.is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
{
return true;
}
if config
.provider_config_for(provider)
.and_then(|entry| 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 (both global and CN share it).
matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
&& config
.api_key
.as_ref()
.is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
{
return true;
}
false
}
/// 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.
/// Save an API key to the appropriate place for the given provider.
/// DeepSeek goes through [`save_api_key`]. Other providers write
/// `[providers.<name>] api_key = "..."` to `~/.deepseek/config.toml`.
/// 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);
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
return match save_api_key(api_key)? {
SavedCredential::ConfigFile(path) => Ok(path),
};
}
let config_path = default_config_path()
@@ -2357,17 +2395,12 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result<PathBuf>
Ok(config_path)
}
/// Clear the API key from every storage layer the resolver consults.
/// Clear the API key from config-file storage.
///
/// `/logout` calls this to wipe credentials so the next request can't
/// silently use a stale key (#343). The function clears:
///
/// 1. **OS keyring** — every known provider slot (`deepseek`, `nvidia-nim`,
/// `openrouter`, `novita`, `fireworks`, `sglang`). A leftover keyring
/// entry would otherwise be returned by [`Config::deepseek_api_key`]
/// even after the user enters a new key.
/// 2. **Config file** — strips the legacy root `api_key = ...` line *and*
/// every `api_key` line nested in a `[providers.<name>]` table.
/// silently use a stale config key (#343). The function strips the legacy
/// root `api_key = ...` line *and* every `api_key` line nested in a
/// `[providers.<name>]` table.
///
/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally
/// **not** unset — they are managed by the user's shell and outside the
@@ -2375,31 +2408,9 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result<PathBuf>
/// (Path 0) ensures a freshly-entered key still wins over a stale env
/// var that lingers from a previous session.
pub fn clear_api_key() -> Result<()> {
// 1. Clear every known provider slot from the OS keyring (or
// file-backed fallback). Errors are warned-and-continued because
// a missing entry is a no-op and a transient keyring failure
// shouldn't block the rest of the wipe.
let secrets = deepseek_secrets::Secrets::auto_detect();
let backend = secrets.backend_name();
for slot in &[
"deepseek",
"nvidia-nim",
"openrouter",
"novita",
"fireworks",
"sglang",
] {
if let Err(err) = secrets.delete(slot) {
tracing::warn!("Failed to clear keyring slot '{slot}': {err}");
}
}
log_sensitive_event(
"credential.clear",
json!({ "backend": backend, "scope": "keyring_all_slots" }),
);
// 2. Strip api_key lines from config.toml, including provider-scoped
// nested entries.
// Strip api_key lines from config.toml, including provider-scoped nested
// entries. Clearing a config file must not trigger platform credential
// prompts.
let config_path = default_config_path()
.context("Failed to resolve config path: home directory not found.")?;
@@ -2645,7 +2656,10 @@ mod tests {
}
#[test]
fn save_api_key_writes_config() -> Result<()> {
fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {
// `save_api_key` writes to the shared user config file. This
// pins the boring v0.8.8 setup path and avoids platform
// credential prompts during onboarding.
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -2659,15 +2673,64 @@ mod tests {
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
let path = save_api_key("test-key")?;
let saved = save_api_key("test-key")?;
let expected = temp_root.join(".deepseek").join("config.toml");
assert_eq!(path, expected);
assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));
assert_eq!(saved.describe(), expected.display().to_string());
let contents = fs::read_to_string(&path)?;
let contents = fs::read_to_string(&expected)?;
assert!(contents.contains("api_key = \""));
Ok(())
}
#[test]
fn save_api_key_rejects_empty_input() {
let _lock = lock_test_env();
let err = save_api_key(" ").expect_err("empty should bail");
assert!(
err.to_string().contains("empty"),
"expected error to mention empty, got: {err}"
);
}
#[test]
fn saved_credential_describe_returns_config_file_path() {
let cf = SavedCredential::ConfigFile(PathBuf::from("/tmp/x.toml"));
assert_eq!(cf.describe(), "/tmp/x.toml");
}
#[test]
fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {
// Pins the v0.8.8 contract: `has_api_key` covers the prompt-free
// sources used by `Config::deepseek_api_key` (in-memory override,
// env var, config-file slot).
let _lock = lock_test_env();
// Explicit in-memory key wins over every other source per
// `Config::deepseek_api_key`'s "Path 0" override.
let cfg = Config {
api_key: Some("sk-in-memory-override".to_string()),
..Default::default()
};
assert!(
has_api_key(&cfg),
"in-memory override must be detected as a usable key"
);
// Env var path.
let env_cfg = Config::default();
unsafe {
std::env::set_var("DEEPSEEK_API_KEY", "sk-test-from-env");
}
assert!(
has_api_key(&env_cfg),
"env-var key must be detected even with empty config"
);
unsafe {
std::env::remove_var("DEEPSEEK_API_KEY");
}
Ok(())
}
/// Regression for #343: clear_api_key strips both the root `api_key`
/// and any nested `[providers.<name>].api_key` lines from config.toml
/// so a stale credential can't shadow a fresh login.
@@ -2725,8 +2788,8 @@ api_key = "old-openrouter-key"
}
/// Regression for #343: explicit in-memory `api_key` (non-empty,
/// non-sentinel) wins over the keyring/env layer so a freshly-typed
/// onboarding key takes effect even if a stale credential lingers.
/// non-sentinel) wins over env/config so a freshly-typed onboarding
/// key takes effect immediately.
#[test]
fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {
let _lock = lock_test_env();
@@ -2753,6 +2816,35 @@ api_key = "old-openrouter-key"
Ok(())
}
#[test]
fn deepseek_api_key_prefers_saved_config_over_stale_env() -> 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-config-over-env-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
unsafe {
env::set_var("DEEPSEEK_API_KEY", "stale-env-key");
}
let config = Config {
api_key: Some("fresh-config-key".to_string()),
..Config::default()
};
assert_eq!(config.deepseek_api_key()?, "fresh-config-key");
unsafe {
env::remove_var("DEEPSEEK_API_KEY");
}
Ok(())
}
#[test]
fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {
let _lock = lock_test_env();
@@ -2773,8 +2865,8 @@ api_key = "old-openrouter-key"
..Config::default()
};
// Sentinel must not be treated as a real key — the resolver should
// fall through to keyring / env / config-provider and ultimately
// bail out with a "key not found" error.
// fall through to env / config-provider and ultimately bail out
// with a "key not found" error.
let _err = config
.deepseek_api_key()
.expect_err("sentinel placeholder must not satisfy the API key check");
@@ -2920,8 +3012,8 @@ api_key = "old-openrouter-key"
"api_key_backup = \"old\"\napi_key = \"current\"\n",
)?;
let path = save_api_key("new-key")?;
assert_eq!(path, config_path);
let saved = save_api_key("new-key")?;
assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));
let contents = fs::read_to_string(&config_path)?;
assert!(contents.contains("api_key_backup = \"old\""));
@@ -2978,6 +3070,35 @@ api_key = "old-openrouter-key"
Ok(())
}
#[test]
fn apply_env_overrides_does_not_copy_api_key_into_config() -> 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-env-key-not-config-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
unsafe {
env::set_var("DEEPSEEK_API_KEY", "env-key");
}
let mut config = Config::default();
apply_env_overrides(&mut config);
assert_eq!(config.api_key, None);
assert_eq!(config.deepseek_api_key()?, "env-key");
unsafe {
env::remove_var("DEEPSEEK_API_KEY");
}
Ok(())
}
#[test]
fn normalize_model_name_handles_aliases_and_future_ids() {
assert_eq!(
@@ -3521,6 +3642,32 @@ api_key = "novita-table-key"
Ok(())
}
#[test]
fn has_api_key_for_uses_deepseek_cn_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-has-key-cn-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
let mut providers = ProvidersConfig::default();
providers.deepseek_cn.api_key = Some("cn-file-key".to_string());
let config = Config {
providers: Some(providers),
..Config::default()
};
assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));
Ok(())
}
#[test]
fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {
let _lock = lock_test_env();
@@ -3590,6 +3737,32 @@ api_key = "novita-table-key"
Ok(())
}
#[test]
fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> 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-cn-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
let path = save_api_key_for(ApiProvider::DeepseekCN, "cn-saved-key")?;
let contents = fs::read_to_string(&path)?;
let parsed: toml::Value = toml::from_str(&contents)?;
assert_eq!(
parsed.get("api_key").and_then(toml::Value::as_str),
Some("cn-saved-key")
);
Ok(())
}
#[test]
fn nvidia_nim_reads_facade_provider_table() -> Result<()> {
let _lock = lock_test_env();
+83 -27
View File
@@ -177,7 +177,7 @@ enum Commands {
},
/// Create default AGENTS.md in current directory
Init,
/// Save a DeepSeek API key to the config file
/// Save a DeepSeek API key to the shared user config
Login {
/// API key to store (otherwise read from stdin)
#[arg(long)]
@@ -1179,14 +1179,22 @@ enum ApiKeySource {
}
fn resolve_api_key_source(config: &Config) -> ApiKeySource {
if std::env::var("DEEPSEEK_API_KEY")
if config
.api_key
.as_ref()
.is_some_and(|k| !k.trim().is_empty())
|| config
.provider_config()
.and_then(|entry| entry.api_key.as_ref())
.is_some_and(|k| !k.trim().is_empty())
{
ApiKeySource::Config
} else if std::env::var("DEEPSEEK_API_KEY")
.ok()
.filter(|k| !k.trim().is_empty())
.is_some()
{
ApiKeySource::Env
} else if config.deepseek_api_key().is_ok() {
ApiKeySource::Config
} else {
ApiKeySource::Missing
}
@@ -1252,7 +1260,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
"deepseek auth set --provider sglang --api-key \"...\"",
),
crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => {
("DEEPSEEK_API_KEY", "deepseek login --api-key \"...\"")
("DEEPSEEK_API_KEY", "deepseek auth set --provider deepseek")
}
};
println!(
@@ -1461,40 +1469,68 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
println!();
println!("{}", "API Keys:".bold());
// Report the active keyring backend (system / file-based / unavailable).
let secrets = deepseek_secrets::Secrets::auto_detect();
println!(" · keyring backend: {}", secrets.backend_name());
// Per-provider state: keyring, env, config file (no values printed).
for (slot, env_names) in [
("deepseek", &["DEEPSEEK_API_KEY"][..]),
("nvidia-nim", &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"][..]),
("openrouter", &["OPENROUTER_API_KEY"][..]),
("novita", &["NOVITA_API_KEY"][..]),
// Per-provider state: env + config file only (no values printed).
// Keep doctor/status prompt-free even for unsigned rebuilt binaries.
for (provider, slot, env_names) in [
(
crate::config::ApiProvider::Deepseek,
"deepseek",
&["DEEPSEEK_API_KEY"][..],
),
(
crate::config::ApiProvider::NvidiaNim,
"nvidia-nim",
&["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"][..],
),
(
crate::config::ApiProvider::Openrouter,
"openrouter",
&["OPENROUTER_API_KEY"][..],
),
(
crate::config::ApiProvider::Novita,
"novita",
&["NOVITA_API_KEY"][..],
),
(
crate::config::ApiProvider::Fireworks,
"fireworks",
&["FIREWORKS_API_KEY"][..],
),
(
crate::config::ApiProvider::Sglang,
"sglang",
&["SGLANG_API_KEY"][..],
),
] {
let in_keyring = secrets
.get(slot)
.ok()
.flatten()
.is_some_and(|v| !v.trim().is_empty());
let in_env = env_names.iter().any(|n| {
std::env::var(n)
.ok()
.filter(|v| !v.trim().is_empty())
.is_some()
});
let icon = if in_keyring || in_env {
let in_config = config
.provider_config_for(provider)
.and_then(|entry| entry.api_key.as_ref())
.is_some_and(|v| !v.trim().is_empty())
|| (matches!(provider, crate::config::ApiProvider::Deepseek)
&& config
.api_key
.as_ref()
.is_some_and(|v| !v.trim().is_empty()));
let icon = if in_env || in_config {
"".truecolor(aqua_r, aqua_g, aqua_b)
} else {
"·".dimmed()
};
println!(
" {} {slot}: keyring={}, env={}",
" {} {slot}: env={}, config={}",
icon,
if in_keyring { "yes" } else { "no" },
if in_env { "yes" } else { "no" }
if in_env { "yes" } else { "no" },
if in_config { "yes" } else { "no" }
);
}
println!(" · credential sources: env, ~/.deepseek/config.toml");
let has_api_key = if config.deepseek_api_key().is_ok() {
println!(
@@ -1507,7 +1543,9 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
" {} active provider key not configured",
"".truecolor(red_r, red_g, red_b)
);
println!(" Run 'deepseek auth set --provider <name>' to save a key to the OS keyring.");
println!(
" Run 'deepseek auth set --provider <name>' to save a key to ~/.deepseek/config.toml."
);
false
};
@@ -2337,8 +2375,8 @@ fn run_login(api_key: Option<String>) -> Result<()> {
Some(key) => key,
None => read_api_key_from_stdin()?,
};
let path = config::save_api_key(&api_key)?;
println!("Saved API key to {}", path.display());
let saved = config::save_api_key(&api_key)?;
println!("Saved API key to {}", saved.describe());
Ok(())
}
@@ -4525,6 +4563,24 @@ mod setup_helper_tests {
assert_eq!(source, ApiKeySource::Env);
}
#[test]
fn resolve_api_key_source_prefers_config_over_env() {
let prev = std::env::var("DEEPSEEK_API_KEY").ok();
unsafe {
std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key");
}
let cfg = Config {
api_key: Some("fresh-config-key".to_string()),
..Config::default()
};
let source = resolve_api_key_source(&cfg);
match prev {
Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
}
assert_eq!(source, ApiKeySource::Config);
}
#[test]
fn skills_count_for_returns_zero_for_missing_dir() {
let tmp = TempDir::new().unwrap();
+4 -4
View File
@@ -9,7 +9,7 @@ use serde_json::Value;
use thiserror::Error;
use crate::compaction::CompactionConfig;
use crate::config::{ApiProvider, Config, has_api_key, save_api_key};
use crate::config::{ApiProvider, Config, SavedCredential, has_api_key, save_api_key};
use crate::config_ui::ConfigUiMode;
use crate::core::coherence::CoherenceState;
use crate::cycle_manager::{CycleBriefing, CycleConfig};
@@ -1231,18 +1231,18 @@ impl App {
}
}
pub fn submit_api_key(&mut self) -> Result<PathBuf, ApiKeyError> {
pub fn submit_api_key(&mut self) -> Result<SavedCredential, ApiKeyError> {
let key = self.api_key_input.trim().to_string();
if key.is_empty() {
return Err(ApiKeyError::Empty);
}
match save_api_key(&key) {
Ok(path) => {
Ok(saved) => {
self.api_key_input.clear();
self.api_key_cursor = 0;
self.onboarding_needs_api_key = false;
Ok(path)
Ok(saved)
}
Err(source) => Err(ApiKeyError::SaveFailed { source }),
}
+7 -6
View File
@@ -9,26 +9,27 @@ use crate::tui::app::App;
pub fn lines(app: &App) -> Vec<Line<'static>> {
let mut lines = vec![
Line::from(Span::styled(
"API Key Setup",
"Connect your DeepSeek API key",
Style::default()
.fg(palette::DEEPSEEK_SKY)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
"Enter your DEEPSEEK_API_KEY to continue.",
"Step 1. Open https://platform.deepseek.com/api_keys and create a key.",
Style::default().fg(palette::TEXT_PRIMARY),
)),
Line::from(Span::styled(
"Get your key at https://platform.deepseek.com",
Style::default().fg(palette::DEEPSEEK_SKY),
"Step 2. Paste it below and press Enter.",
Style::default().fg(palette::TEXT_PRIMARY),
)),
Line::from(""),
Line::from(Span::styled(
"Paste the full key exactly as issued (no spaces/newlines).",
"Saved to ~/.deepseek/config.toml so it works from any folder.",
Style::default().fg(palette::TEXT_MUTED),
)),
Line::from(Span::styled(
"Unusual-looking formats warn, but setup only blocks clearly broken input.",
"Paste the full key exactly as issued (no spaces or newlines).",
Style::default().fg(palette::TEXT_MUTED),
)),
Line::from(""),
+5 -1
View File
@@ -17,7 +17,7 @@
//! 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 crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect},
@@ -292,6 +292,10 @@ impl ModalView for ProviderPickerView {
self.api_key_input.pop();
ViewAction::None
}
KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.api_key_input.pop();
ViewAction::None
}
KeyCode::Enter => {
let key = self.api_key_input.trim().to_string();
if key.is_empty() {
+49 -5
View File
@@ -1424,6 +1424,14 @@ async fn run_event_loop(
let remaining = deadline.saturating_duration_since(now);
poll_timeout = poll_timeout.min(remaining.max(Duration::from_millis(50)));
}
poll_timeout = clamp_event_poll_timeout(poll_timeout);
// #549: this async task also performs a blocking terminal poll. Give
// the engine task a scheduler turn before we block again so an
// interactive submit can reach the API instead of appearing stuck on
// `working.` with no network activity.
tokio::task::yield_now().await;
if event::poll(poll_timeout)? {
let evt = event::read()?;
app.needs_redraw = true;
@@ -1585,7 +1593,19 @@ async fn run_event_loop(
continue;
}
match app.submit_api_key() {
Ok(_) => {
Ok(saved) => {
// Surface where the key landed so the
// user can verify the shared config
// file path before the welcome
// screen advances. The toast queue
// outlives the onboarding state
// transition, so it stays visible on
// the next screen too.
app.push_status_toast(
format!("API key saved to {}", saved.describe()),
StatusToastLevel::Info,
Some(4_000),
);
app.status_message = None;
// Recreate the engine so it picks up the newly saved key
// without requiring a full process restart.
@@ -1595,8 +1615,7 @@ async fn run_event_loop(
// (e.g. a subsequent /provider switch)
// sees it; the explicit-override path
// in `deepseek_api_key` (#343) makes
// this win even if the OS keyring
// still holds a stale credential.
// this win immediately.
config.api_key = Some(key.clone());
let mut refreshed_config = config.clone();
refreshed_config.api_key = Some(key);
@@ -1652,6 +1671,13 @@ async fn run_event_loop(
app.delete_api_key_char();
sync_api_key_validation_status(app, false);
}
KeyCode::Char('h')
if is_ctrl_h_backspace(&key)
&& app.onboarding == OnboardingState::ApiKey =>
{
app.delete_api_key_char();
sync_api_key_validation_status(app, false);
}
KeyCode::Char(c) if app.onboarding == OnboardingState::ApiKey => {
app.insert_api_key_char(c);
sync_api_key_validation_status(app, false);
@@ -2402,6 +2428,12 @@ async fn run_event_loop(
app.delete_char();
}
KeyCode::Backspace => {}
KeyCode::Char('h')
if is_ctrl_h_backspace(&key) && !app.remove_selected_composer_attachment() =>
{
app.delete_char();
}
KeyCode::Char('h') if is_ctrl_h_backspace(&key) => {}
KeyCode::Delete if !app.remove_selected_composer_attachment() => {
app.delete_char_forward();
}
@@ -4099,8 +4131,8 @@ async fn execute_command_input(
let result = commands::execute(input, app);
// After /logout: clear the in-memory api_key fields so the next
// onboarding round entering a new key doesn't see the stale value
// (#343). The on-disk + keyring side is handled by clear_api_key()
// inside commands::config::logout.
// (#343). The on-disk side is handled by clear_api_key() inside
// commands::config::logout.
if input.trim().eq_ignore_ascii_case("/logout") {
config.api_key = None;
if let Some(providers) = config.providers.as_mut() {
@@ -6314,6 +6346,11 @@ fn idle_poll_ms(app: &App) -> u64 {
if app.low_motion { 120 } else { UI_IDLE_POLL_MS }
}
fn clamp_event_poll_timeout(timeout: Duration) -> Duration {
const MIN_EVENT_POLL_TIMEOUT: Duration = Duration::from_millis(1);
timeout.max(MIN_EVENT_POLL_TIMEOUT)
}
fn history_has_live_motion(history: &[HistoryCell]) -> bool {
use crate::tui::history::SubAgentCell;
use crate::tui::widgets::agent_card::AgentLifecycle;
@@ -7079,6 +7116,13 @@ fn is_paste_shortcut(key: &KeyEvent) -> bool {
key.modifiers.contains(KeyModifiers::CONTROL)
}
fn is_ctrl_h_backspace(key: &KeyEvent) -> bool {
matches!(key.code, KeyCode::Char('h'))
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT)
&& !key.modifiers.contains(KeyModifiers::SUPER)
}
fn should_scroll_with_arrows(_app: &App) -> bool {
false
}
+32
View File
@@ -916,6 +916,22 @@ fn footer_state_label_drops_thinking_and_prefers_compacting() {
assert!(footer_state_label(&app).0.starts_with("compacting"));
}
#[test]
fn event_poll_timeout_has_nonzero_floor() {
assert_eq!(
clamp_event_poll_timeout(Duration::ZERO),
Duration::from_millis(1)
);
assert_eq!(
clamp_event_poll_timeout(Duration::from_micros(250)),
Duration::from_millis(1)
);
assert_eq!(
clamp_event_poll_timeout(Duration::from_millis(24)),
Duration::from_millis(24)
);
}
#[test]
fn footer_status_line_spans_show_mode_and_model_idle_and_active() {
let mut app = create_test_app();
@@ -1741,6 +1757,22 @@ fn details_shortcut_modifiers_accept_plain_shift_and_alt_only() {
));
}
#[test]
fn ctrl_h_is_treated_as_terminal_backspace() {
assert!(is_ctrl_h_backspace(&KeyEvent::new(
KeyCode::Char('h'),
KeyModifiers::CONTROL
)));
assert!(!is_ctrl_h_backspace(&KeyEvent::new(
KeyCode::Char('h'),
KeyModifiers::NONE
)));
assert!(!is_ctrl_h_backspace(&KeyEvent::new(
KeyCode::Char('h'),
KeyModifiers::CONTROL | KeyModifiers::ALT
)));
}
#[test]
fn partial_file_mention_finds_token_under_cursor() {
// Cursor in middle of `@docs/de` should be detected as a partial mention.
+8
View File
@@ -222,6 +222,14 @@ impl UserInputView {
self.other_input.pop();
ViewAction::None
}
KeyCode::Char('h')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
self.other_input.pop();
ViewAction::None
}
KeyCode::Char(ch) => {
if !ch.is_control() {
self.other_input.push(ch);
+5 -4
View File
@@ -50,13 +50,14 @@ user-global. If your repo needs more, file an issue describing the
specific use case.
The `deepseek` facade and `deepseek-tui` binary share the same config file for
DeepSeek auth and model defaults. `deepseek login --api-key ...` writes the
root `api_key` field that `deepseek-tui` reads directly, and `deepseek --model
deepseek-v4-flash` is forwarded to the TUI as `DEEPSEEK_MODEL`.
DeepSeek auth and model defaults. `deepseek auth set --provider deepseek` (and
the legacy `deepseek login --api-key ...` alias) saves the key to
`~/.deepseek/config.toml`, and `deepseek --model deepseek-v4-flash` is forwarded
to the TUI as `DEEPSEEK_MODEL`.
For hosted or self-hosted DeepSeek V4 providers, set `provider = "nvidia-nim"`,
`"fireworks"`, or `"sglang"` or pass `deepseek --provider <name>`. The facade
stores provider credentials under `[providers.<name>]` and forwards the resolved
saves provider credentials to the shared user config and forwards the resolved
key, base URL, provider, and model to the TUI process. Use
`deepseek auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"` or
`deepseek auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"` to