feat(cli): #134 add deepseek auth keyring subcommands and surface backend in doctor

Adds first-class keyring management on the dispatcher CLI and wires
the TUI to read its DeepSeek key through the same Secrets façade.

Subcommands:
* `auth set --provider <name>`   writes to the OS keyring; prompts
  on stdin without echo, never prints the key, never touches
  `config.toml`. Supports `--api-key` and `--api-key-stdin`.
* `auth get --provider <name>`   reports `set` / `not set` plus the
  resolving layer (keyring / env / config-file). Never prints the
  value.
* `auth clear --provider <name>` deletes from keyring and from any
  legacy plaintext slot in `config.toml` for parity.
* `auth list`                    table of all known providers and
  whether each layer holds a key. Non-revealing.
* `auth migrate [--dry-run]`     reads `api_key` (root + per-provider
  blocks) from `config.toml`, writes them to the keyring, then
  strips the entries from disk. Idempotent.
* `auth status`                  expanded to also report the active
  keyring backend and per-provider keyring state.

`doctor` now prints `keyring backend: ...` plus per-provider
`keyring=yes/no, env=yes/no` lines and points users at
`deepseek auth set` when no key resolves.

`Config::deepseek_api_key()` in the TUI is rewritten to consult
`Secrets::auto_detect()` first (keyring -> env), then fall back to
the existing TOML slots with a deprecation warning. Error messages
now lead with `deepseek auth set --provider <name>`.

5 new unit tests cover argument parsing for the new subcommands and
end-to-end auth set/clear/migrate behaviour against an
`InMemoryKeyringStore`, verifying that no plaintext key ever lands
in `config.toml`.

Verified manually on macOS:
  $ deepseek auth set --provider deepseek --api-key-stdin
  $ security find-generic-password -s deepseek -a deepseek
  # entry present
  $ deepseek auth migrate
  # api_key lines stripped from ~/.deepseek/config.toml

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-04-28 00:01:43 -05:00
parent a5cc9d5852
commit 30d7650bae
5 changed files with 558 additions and 111 deletions
+1
View File
@@ -19,6 +19,7 @@ deepseek-app-server = { path = "../app-server", version = "0.6.0" }
deepseek-config = { path = "../config", version = "0.6.0" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.6.0" }
deepseek-mcp = { path = "../mcp", version = "0.6.0" }
deepseek-secrets = { path = "../secrets", version = "0.6.0" }
deepseek-state = { path = "../state", version = "0.6.0" }
chrono.workspace = true
dirs.workspace = true
+480 -56
View File
@@ -15,6 +15,7 @@ use deepseek_app_server::{
use deepseek_config::{CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions};
use deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
use deepseek_mcp::{McpServerDefinition, run_stdio_server};
use deepseek_secrets::Secrets;
use deepseek_state::{StateStore, ThreadListFilters};
#[derive(Debug, Clone, Copy, ValueEnum)]
@@ -189,17 +190,43 @@ struct AuthArgs {
#[derive(Debug, Subcommand)]
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.
Set {
#[arg(long, value_enum)]
provider: ProviderArg,
/// Inline value (discouraged — appears in shell history).
#[arg(long)]
api_key: Option<String>,
/// Read the key from stdin instead of prompting.
#[arg(long = "api-key-stdin", default_value_t = false)]
api_key_stdin: bool,
},
/// Report whether a provider has a key configured. Never prints
/// the value; just `set` / `not set` plus the source layer.
Get {
#[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).
Clear {
#[arg(long, value_enum)]
provider: ProviderArg,
},
/// 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.
Migrate {
/// Don't actually write anything; print what would change.
#[arg(long, default_value_t = false)]
dry_run: bool,
},
}
#[derive(Debug, Args)]
@@ -506,85 +533,254 @@ fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
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",
}
}
/// 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()
}
fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
let slot = store
.config
.providers
.for_provider(provider)
.api_key
.as_ref();
let root = (provider == ProviderKind::Deepseek)
.then_some(store.config.api_key.as_ref())
.flatten();
slot.or(root).is_some_and(|v| !v.trim().is_empty())
}
fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {
run_auth_command_with_secrets(store, command, &Secrets::auto_detect())
}
fn run_auth_command_with_secrets(
store: &mut ConfigStore,
command: AuthCommand,
secrets: &Secrets,
) -> Result<()> {
match command {
AuthCommand::Status => {
let deepseek_env = std::env::var("DEEPSEEK_API_KEY")
.ok()
.filter(|v| !v.trim().is_empty())
.is_some();
let openai_env = std::env::var("OPENAI_API_KEY")
.ok()
.filter(|v| !v.trim().is_empty())
.is_some();
let nvidia_env = std::env::var("NVIDIA_API_KEY")
.or_else(|_| std::env::var("NVIDIA_NIM_API_KEY"))
.ok()
.filter(|v| !v.trim().is_empty())
.is_some();
let deepseek_file = store
.config
.providers
.deepseek
.api_key
.as_ref()
.or(store.config.api_key.as_ref())
.is_some_and(|v| !v.trim().is_empty());
let openai_file = store
.config
.providers
.openai
.api_key
.as_ref()
.is_some_and(|v| !v.trim().is_empty());
let nvidia_file = store
.config
.providers
.nvidia_nim
.api_key
.as_ref()
.is_some_and(|v| !v.trim().is_empty());
println!("provider: {}", store.config.provider.as_str());
println!(
"deepseek auth: env={}, config={}",
deepseek_env, deepseek_file
);
println!(
"nvidia-nim auth: env={}, config={}",
nvidia_env, nvidia_file
);
println!("openai auth: env={}, config={}", openai_env, openai_file);
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 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
);
}
Ok(())
}
AuthCommand::Set { provider, api_key } => {
AuthCommand::Set {
provider,
api_key,
api_key_stdin,
} => {
let provider: ProviderKind = provider.into();
let api_key = match api_key {
Some(v) => v,
None => read_api_key_from_stdin()?,
let slot = keyring_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)?,
};
store.config.provider = provider;
store.config.providers.for_provider_mut(provider).api_key = Some(api_key);
if provider == ProviderKind::Deepseek {
store.config.api_key = store.config.providers.deepseek.api_key.clone();
secrets
.set(slot, &api_key)
.with_context(|| format!("failed to write {slot} key to keyring"))?;
// Don't print the key. Don't echo length.
println!("saved API key for {slot} to {}", secrets.backend_name());
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 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;
if resolved {
let source = if in_keyring {
"keyring"
} else if in_env {
"env"
} else {
"config-file"
};
println!("{slot}: set (source: {source})");
} else {
println!("{slot}: not set");
}
store.save()?;
println!("saved API key for {}", provider.as_str());
Ok(())
}
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;
}
store.save()?;
println!("cleared API key for {}", provider.as_str());
println!("cleared API key for {slot}");
Ok(())
}
AuthCommand::List => {
println!("keyring backend: {}", secrets.backend_name());
println!("provider keyring 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 env = provider_env_set(provider);
let file = provider_config_set(store, provider);
println!(
"{slot:<12} {} {} {}",
yes_no(kr),
yes_no(env),
yes_no(file)
);
}
Ok(())
}
AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
}
}
fn yes_no(b: bool) -> &'static str {
if b { "yes" } else { "no " }
}
fn prompt_api_key(slot: &str) -> Result<String> {
use std::io::{IsTerminal, Write};
eprint!("Enter API key for {slot}: ");
io::stderr().flush().ok();
if !io::stdin().is_terminal() {
// Non-interactive: read directly without prompting twice.
return read_api_key_from_stdin();
}
let mut buf = String::new();
io::stdin()
.read_line(&mut buf)
.context("failed to read API key from stdin")?;
let key = buf.trim().to_string();
if key.is_empty() {
bail!("empty API key provided");
}
Ok(key)
}
/// Move plaintext keys from config.toml into the keyring. Stays
/// idempotent: rerunning is a no-op once the file is clean.
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 from_provider_block = store
.config
.providers
.for_provider(provider)
.api_key
.clone()
.filter(|v| !v.trim().is_empty());
let from_root = (provider == ProviderKind::Deepseek)
.then(|| store.config.api_key.clone())
.flatten()
.filter(|v| !v.trim().is_empty());
let value = from_provider_block.or(from_root);
let Some(value) = value else { continue };
if let Ok(Some(existing)) = secrets.get(slot)
&& existing == value
{
// Already migrated; safe to strip the file slot.
} else if dry_run {
migrated.push((provider, slot));
continue;
} else if let Err(err) = secrets.set(slot, &value) {
warnings.push(format!("skipped {slot}: failed to write to keyring: {err}"));
continue;
}
if !dry_run {
store.config.providers.for_provider_mut(provider).api_key = None;
if provider == ProviderKind::Deepseek {
store.config.api_key = None;
}
}
migrated.push((provider, slot));
}
if !dry_run && !migrated.is_empty() {
store
.save()
.context("failed to write updated config.toml")?;
}
println!("keyring backend: {}", secrets.backend_name());
if migrated.is_empty() {
println!("nothing to migrate (config.toml has no plaintext api_key entries)");
} else {
println!(
"{} {} provider key(s):",
if dry_run { "would migrate" } else { "migrated" },
migrated.len()
);
for (_, slot) in &migrated {
println!(" - {slot}");
}
if !dry_run {
println!(
"config.toml at {} no longer contains api_key entries for migrated providers.",
store.path().display()
);
}
}
for w in warnings {
eprintln!("warning: {w}");
}
Ok(())
}
fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
match command {
ConfigCommand::Get { key } => {
@@ -1199,6 +1395,234 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[test]
fn parses_auth_subcommand_matrix() {
let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Set {
provider: ProviderArg::Deepseek,
api_key: None,
api_key_stdin: false,
}
}))
));
let cli = parse_ok(&[
"deepseek",
"auth",
"set",
"--provider",
"openrouter",
"--api-key-stdin",
]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Set {
provider: ProviderArg::Openrouter,
api_key: None,
api_key_stdin: true,
}
}))
));
let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Get {
provider: ProviderArg::Novita
}
}))
));
let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Clear {
provider: ProviderArg::NvidiaNim
}
}))
));
let cli = parse_ok(&["deepseek", "auth", "list"]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::List
}))
));
let cli = parse_ok(&["deepseek", "auth", "migrate"]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Migrate { dry_run: false }
}))
));
let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
assert!(matches!(
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Migrate { dry_run: true }
}))
));
}
#[test]
fn auth_set_writes_to_keyring_and_not_to_config_file() {
use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
use std::sync::Arc;
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());
run_auth_command_with_secrets(
&mut store,
AuthCommand::Set {
provider: ProviderArg::Deepseek,
api_key: Some("sk-keyring".to_string()),
api_key_stdin: false,
},
&secrets,
)
.expect("set should succeed");
assert_eq!(
inner.get("deepseek").unwrap(),
Some("sk-keyring".to_string())
);
// 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}"
);
let _ = std::fs::remove_file(path);
}
#[test]
fn auth_clear_removes_from_keyring_and_config() {
use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
use std::sync::Arc;
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",
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.save().unwrap();
let inner = Arc::new(InMemoryKeyringStore::new());
inner.set("deepseek", "sk-keyring").unwrap();
let secrets = Secrets::new(inner.clone());
run_auth_command_with_secrets(
&mut store,
AuthCommand::Clear {
provider: ProviderArg::Deepseek,
},
&secrets,
)
.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 auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
use std::sync::Arc;
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
let path = std::env::temp_dir().join(format!(
"deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
std::process::id()
));
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
store.config.api_key = Some("sk-deep".to_string());
store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
store.config.providers.openrouter.api_key = Some("or-key".to_string());
store.config.providers.novita.api_key = Some("nv-key".to_string());
store.save().unwrap();
let inner = Arc::new(InMemoryKeyringStore::new());
let secrets = Secrets::new(inner.clone());
run_auth_command_with_secrets(
&mut store,
AuthCommand::Migrate { dry_run: false },
&secrets,
)
.expect("migrate should succeed");
assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
// Config file must no longer contain the api keys.
assert!(store.config.api_key.is_none());
assert!(store.config.providers.deepseek.api_key.is_none());
assert!(store.config.providers.openrouter.api_key.is_none());
assert!(store.config.providers.novita.api_key.is_none());
let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
let _ = std::fs::remove_file(path);
}
#[test]
fn auth_migrate_dry_run_does_not_modify_anything() {
use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
use std::sync::Arc;
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
let path = std::env::temp_dir().join(format!(
"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
std::process::id()
));
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
store.config.providers.openrouter.api_key = Some("or-stay".to_string());
store.save().unwrap();
let inner = Arc::new(InMemoryKeyringStore::new());
let secrets = Secrets::new(inner.clone());
run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
.expect("dry-run should succeed");
assert_eq!(inner.get("openrouter").unwrap(), None);
assert_eq!(
store.config.providers.openrouter.api_key.as_deref(),
Some("or-stay")
);
let _ = std::fs::remove_file(path);
}
#[test]
fn parses_global_override_flags() {
let cli = parse_ok(&[
+1
View File
@@ -13,6 +13,7 @@ path = "src/main.rs"
[dependencies]
anyhow = "1.0.100"
arboard = "3.4"
deepseek-secrets = { path = "../secrets", version = "0.6.0" }
async-stream = "0.3.6"
async-trait = "0.1"
bytes = "1.11.0"
+37 -42
View File
@@ -636,77 +636,72 @@ impl Config {
normalize_base_url(&base)
}
/// Read the API key from config/environment.
/// Read the API key.
///
/// Precedence: **OS keyring → environment → config file**. The
/// keyring + env layers are collapsed by [`deepseek_secrets::Secrets::resolve`];
/// the config-file fallback is preserved here for users who haven't
/// run `deepseek auth migrate` yet.
pub fn deepseek_api_key(&self) -> Result<String> {
let provider = self.api_provider();
let slot = match provider {
ApiProvider::Deepseek => "deepseek",
ApiProvider::NvidiaNim => "nvidia-nim",
ApiProvider::Openrouter => "openrouter",
ApiProvider::Novita => "novita",
};
match provider {
ApiProvider::Deepseek => {
if let Ok(key) = std::env::var("DEEPSEEK_API_KEY")
&& !key.trim().is_empty()
{
return Ok(key);
}
}
ApiProvider::NvidiaNim => {
for name in ["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"] {
if let Ok(key) = std::env::var(name)
&& !key.trim().is_empty()
{
return Ok(key);
}
}
}
ApiProvider::Openrouter => {
if let Ok(key) = std::env::var("OPENROUTER_API_KEY")
&& !key.trim().is_empty()
{
return Ok(key);
}
}
ApiProvider::Novita => {
if let Ok(key) = std::env::var("NOVITA_API_KEY")
&& !key.trim().is_empty()
{
return Ok(key);
}
}
// 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);
}
// Then check config file
// 3. config file (provider-scoped slot).
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);
}
// 4. legacy root `api_key` (deepseek only).
if let Some(configured) = self.api_key.clone()
&& !configured.trim().is_empty()
&& configured != API_KEYRING_SENTINEL
{
tracing::warn!(
"api_key in config.toml is deprecated; run 'deepseek auth migrate' to move it to the OS keyring"
);
return Ok(configured);
}
match provider {
ApiProvider::Deepseek => anyhow::bail!(
"DeepSeek API key not found. Set it using one of these methods:\n\
1. Set DEEPSEEK_API_KEY environment variable (recommended)\n\
2. Run 'deepseek login' to save to ~/.deepseek/config.toml\n\
3. Add 'api_key = \"your-key\"' to ~/.deepseek/config.toml"
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)"
),
ApiProvider::NvidiaNim => anyhow::bail!(
"NVIDIA NIM API key not found. Set NVIDIA_API_KEY, NVIDIA_NIM_API_KEY, \
or save api_key in ~/.deepseek/config.toml with provider = \"nvidia-nim\"."
"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \
set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \
with provider = \"nvidia-nim\"."
),
ApiProvider::Openrouter => anyhow::bail!(
"OpenRouter API key not found. Set OPENROUTER_API_KEY \
or add [providers.openrouter] api_key in ~/.deepseek/config.toml."
"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \
set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml."
),
ApiProvider::Novita => anyhow::bail!(
"Novita API key not found. Set NOVITA_API_KEY \
or add [providers.novita] api_key in ~/.deepseek/config.toml."
"Novita API key not found. Run 'deepseek auth set --provider novita', \
set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml."
),
}
}
+39 -13
View File
@@ -1342,28 +1342,54 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
// Check API keys
println!();
println!("{}", "API Keys:".bold());
let has_api_key = if std::env::var("DEEPSEEK_API_KEY")
.ok()
.filter(|k| !k.trim().is_empty())
.is_some()
{
println!(
" {} DEEPSEEK_API_KEY is set",
// 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"][..]),
] {
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 {
"".truecolor(aqua_r, aqua_g, aqua_b)
);
true
} else if config.deepseek_api_key().is_ok() {
} else {
"·".dimmed()
};
println!(
" {} DeepSeek API key found in effective config",
" {} {slot}: keyring={}, env={}",
icon,
if in_keyring { "yes" } else { "no" },
if in_env { "yes" } else { "no" }
);
}
let has_api_key = if config.deepseek_api_key().is_ok() {
println!(
" {} active provider key resolved",
"".truecolor(aqua_r, aqua_g, aqua_b)
);
true
} else {
println!(
" {} DeepSeek API key not configured",
" {} active provider key not configured",
"".truecolor(red_r, red_g, red_b)
);
println!(" Run 'deepseek' to configure interactively, or set DEEPSEEK_API_KEY");
println!(" Run 'deepseek auth set --provider <name>' to save a key to the OS keyring.");
false
};