fix(auth): use config-backed setup without credential prompts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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 ARM64(HarmonyOS 轻薄本、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)。
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
|
||||
@@ -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(""),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user