返回 DeepSeek-TUI-2026
lib.rs
根目录 / crates / config / src / lib.rs
1 use std::collections::BTreeMap;
2 use std::fs;
3 #[cfg(unix)]
4 use std::io::Write;
5 use std::path::{Path, PathBuf};
6 use std::sync::OnceLock;
7
8 use anyhow::{Context, Result, bail};
9 use deepseek_secrets::SecretSource;
10 pub use deepseek_secrets::Secrets;
11 use serde::{Deserialize, Serialize};
12
13 #[cfg(unix)]
14 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
15
16 pub const CONFIG_FILE_NAME: &str = "config.toml";
17 const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro";
18 const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
19 const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
20 const DEFAULT_OPENAI_MODEL: &str = "gpt-4.1";
21 const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com";
22 const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
23 const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
24 const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro";
25 const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
26 const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro";
27 const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
28 const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro";
29 const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
30 const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
31 const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
32 const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/v1";
33 const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
34 const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1";
35 const DEFAULT_VLLM_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
36 const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
37 const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1";
38
39 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
40 #[serde(rename_all = "kebab-case")]
41 pub enum ProviderKind {
42 #[default]
43 Deepseek,
44 NvidiaNim,
45 Openai,
46 Openrouter,
47 Novita,
48 Fireworks,
49 Sglang,
50 Vllm,
51 }
52
53 impl ProviderKind {
54 #[must_use]
55 pub fn as_str(self) -> &'static str {
56 match self {
57 Self::Deepseek => "deepseek",
58 Self::NvidiaNim => "nvidia-nim",
59 Self::Openai => "openai",
60 Self::Openrouter => "openrouter",
61 Self::Novita => "novita",
62 Self::Fireworks => "fireworks",
63 Self::Sglang => "sglang",
64 Self::Vllm => "vllm",
65 }
66 }
67
68 #[must_use]
69 pub fn parse(value: &str) -> Option<Self> {
70 match value.trim().to_ascii_lowercase().as_str() {
71 "deepseek" | "deep-seek" => Some(Self::Deepseek),
72 "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => Some(Self::NvidiaNim),
73 "openai" | "open-ai" => Some(Self::Openai),
74 "openrouter" | "open_router" => Some(Self::Openrouter),
75 "novita" => Some(Self::Novita),
76 "fireworks" | "fireworks-ai" => Some(Self::Fireworks),
77 "sglang" | "sg-lang" => Some(Self::Sglang),
78 "vllm" | "v-llm" => Some(Self::Vllm),
79 _ => None,
80 }
81 }
82 }
83
84 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
85 pub struct ProviderConfigToml {
86 pub api_key: Option<String>,
87 pub base_url: Option<String>,
88 pub model: Option<String>,
89 #[serde(default)]
90 pub http_headers: BTreeMap<String, String>,
91 }
92
93 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
94 pub struct ProvidersToml {
95 #[serde(default)]
96 pub deepseek: ProviderConfigToml,
97 #[serde(default)]
98 pub nvidia_nim: ProviderConfigToml,
99 #[serde(default)]
100 pub openai: ProviderConfigToml,
101 #[serde(default)]
102 pub openrouter: ProviderConfigToml,
103 #[serde(default)]
104 pub novita: ProviderConfigToml,
105 #[serde(default)]
106 pub fireworks: ProviderConfigToml,
107 #[serde(default)]
108 pub sglang: ProviderConfigToml,
109 #[serde(default)]
110 pub vllm: ProviderConfigToml,
111 }
112
113 impl ProvidersToml {
114 #[must_use]
115 pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
116 match provider {
117 ProviderKind::Deepseek => &self.deepseek,
118 ProviderKind::NvidiaNim => &self.nvidia_nim,
119 ProviderKind::Openai => &self.openai,
120 ProviderKind::Openrouter => &self.openrouter,
121 ProviderKind::Novita => &self.novita,
122 ProviderKind::Fireworks => &self.fireworks,
123 ProviderKind::Sglang => &self.sglang,
124 ProviderKind::Vllm => &self.vllm,
125 }
126 }
127
128 pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
129 match provider {
130 ProviderKind::Deepseek => &mut self.deepseek,
131 ProviderKind::NvidiaNim => &mut self.nvidia_nim,
132 ProviderKind::Openai => &mut self.openai,
133 ProviderKind::Openrouter => &mut self.openrouter,
134 ProviderKind::Novita => &mut self.novita,
135 ProviderKind::Fireworks => &mut self.fireworks,
136 ProviderKind::Sglang => &mut self.sglang,
137 ProviderKind::Vllm => &mut self.vllm,
138 }
139 }
140 }
141
142 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
143 pub struct ConfigToml {
144 /// TUI-compatible DeepSeek API key. Kept at the root so both `deepseek`
145 /// and `deepseek-tui` can share a single config file.
146 pub api_key: Option<String>,
147 /// TUI-compatible DeepSeek base URL.
148 pub base_url: Option<String>,
149 /// Optional extra HTTP headers forwarded to model API requests.
150 #[serde(default)]
151 pub http_headers: BTreeMap<String, String>,
152 /// TUI-compatible default DeepSeek model.
153 pub default_text_model: Option<String>,
154 #[serde(default)]
155 pub provider: ProviderKind,
156 pub model: Option<String>,
157 pub auth_mode: Option<String>,
158 pub chatgpt_access_token: Option<String>,
159 pub device_code_session: Option<String>,
160 pub output_mode: Option<String>,
161 pub log_level: Option<String>,
162 pub telemetry: Option<bool>,
163 pub approval_policy: Option<String>,
164 pub sandbox_mode: Option<String>,
165 #[serde(default)]
166 pub providers: ProvidersToml,
167 /// Per-domain network policy (#135). When absent, network tools fall back
168 /// to a permissive default that mirrors pre-v0.7.0 behavior.
169 #[serde(default)]
170 pub network: Option<NetworkPolicyToml>,
171 /// Community skill installer settings (#140). Mirrors
172 /// [`SkillsToml`] from the TUI side; the dispatcher consults
173 /// `registry_url` when running `deepseek skill install`.
174 #[serde(default)]
175 pub skills: Option<SkillsToml>,
176 /// Workspace side-git snapshots (#137). The live TUI defaults this to
177 /// enabled with 7-day retention when absent.
178 #[serde(default)]
179 pub snapshots: Option<SnapshotsToml>,
180 /// Post-edit LSP diagnostics injection (#136). When absent, the engine
181 /// applies the defaults documented in [`LspConfigToml`].
182 #[serde(default)]
183 pub lsp: Option<LspConfigToml>,
184 #[serde(flatten)]
185 pub extras: BTreeMap<String, toml::Value>,
186 }
187
188 /// On-disk schema for the `[skills]` table (#140). See `config.example.toml`
189 /// for documentation.
190 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
191 pub struct SkillsToml {
192 /// Curated registry index URL. When unset, the TUI falls back to the
193 /// bundled default (community-curated GitHub raw).
194 #[serde(default)]
195 pub registry_url: Option<String>,
196 /// Per-skill maximum *uncompressed* size in bytes. When unset, the TUI
197 /// uses 5 MiB.
198 #[serde(default)]
199 pub max_install_size_bytes: Option<u64>,
200 }
201
202 /// On-disk schema for the `[snapshots]` table (#137). See
203 /// `config.example.toml` for documentation.
204 #[derive(Debug, Clone, Serialize, Deserialize)]
205 pub struct SnapshotsToml {
206 #[serde(default = "default_snapshots_enabled")]
207 pub enabled: bool,
208 #[serde(default = "default_snapshot_max_age_days")]
209 pub max_age_days: u64,
210 }
211
212 fn default_snapshots_enabled() -> bool {
213 true
214 }
215
216 fn default_snapshot_max_age_days() -> u64 {
217 7
218 }
219
220 impl Default for SnapshotsToml {
221 fn default() -> Self {
222 Self {
223 enabled: default_snapshots_enabled(),
224 max_age_days: default_snapshot_max_age_days(),
225 }
226 }
227 }
228
229 /// On-disk schema for the `[network]` table (#135). See `config.example.toml`
230 /// for documentation.
231 #[derive(Debug, Clone, Serialize, Deserialize)]
232 pub struct NetworkPolicyToml {
233 /// Decision for hosts that are not in `allow` or `deny`. One of
234 /// `"allow" | "deny" | "prompt"`. Defaults to `"prompt"`.
235 #[serde(default = "default_network_decision")]
236 pub default: String,
237 /// Hosts that are always allowed. Subdomain rules: a leading dot
238 /// (`.example.com`) matches subdomains but not the apex.
239 #[serde(default)]
240 pub allow: Vec<String>,
241 /// Hosts that are always denied. Deny entries win over allow entries.
242 #[serde(default)]
243 pub deny: Vec<String>,
244 /// Whether to record one audit-log line per outbound network call.
245 #[serde(default = "default_network_audit")]
246 pub audit: bool,
247 }
248
249 fn default_network_decision() -> String {
250 "prompt".to_string()
251 }
252
253 fn default_network_audit() -> bool {
254 true
255 }
256
257 impl Default for NetworkPolicyToml {
258 fn default() -> Self {
259 Self {
260 default: default_network_decision(),
261 allow: Vec::new(),
262 deny: Vec::new(),
263 audit: default_network_audit(),
264 }
265 }
266 }
267
268 /// On-disk schema for the `[lsp]` table (#136). See `config.example.toml`
269 /// for documentation. All fields are optional so the TUI runtime can fall
270 /// back to its own defaults when keys are absent.
271 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
272 pub struct LspConfigToml {
273 /// Master switch.
274 pub enabled: Option<bool>,
275 /// Maximum time to wait for diagnostics after an edit, in milliseconds.
276 pub poll_after_edit_ms: Option<u64>,
277 /// Cap on diagnostics surfaced per file.
278 pub max_diagnostics_per_file: Option<usize>,
279 /// When `true`, warnings (severity 2) are surfaced in addition to errors.
280 pub include_warnings: Option<bool>,
281 /// Optional override for the `language -> [cmd, ...args]` table.
282 pub servers: Option<BTreeMap<String, Vec<String>>>,
283 }
284
285 impl ConfigToml {
286 /// Merge project-level overrides from `$WORKSPACE/.deepseek/config.toml`.
287 /// Only populated fields in `project` are applied; everything else
288 /// keeps its global value. Provider-specific sub-tables are merged
289 /// field-by-field so a project can set just `providers.deepseek.model`
290 /// without needing to repeat `api_key` or `base_url`.
291 pub fn merge_project_overrides(&mut self, project: ConfigToml) {
292 // Check provider override condition before moving fields.
293 let has_api_key = project.api_key.is_some();
294
295 // Top-level scalar fields: apply when the project has a value.
296 if has_api_key {
297 self.api_key = project.api_key;
298 }
299 if project.base_url.is_some() {
300 self.base_url = project.base_url;
301 }
302 if !project.http_headers.is_empty() {
303 self.http_headers = project.http_headers;
304 }
305 if project.default_text_model.is_some() {
306 self.default_text_model = project.default_text_model;
307 }
308 if project.model.is_some() {
309 self.model = project.model;
310 }
311 if project.auth_mode.is_some() {
312 self.auth_mode = project.auth_mode;
313 }
314 if project.output_mode.is_some() {
315 self.output_mode = project.output_mode;
316 }
317 if project.telemetry.is_some() {
318 self.telemetry = project.telemetry;
319 }
320 if project.approval_policy.is_some() {
321 self.approval_policy = project.approval_policy;
322 }
323 if project.sandbox_mode.is_some() {
324 self.sandbox_mode = project.sandbox_mode;
325 }
326 // Provider is only overridden if explicitly set (non-default).
327 if project.provider != ProviderKind::Deepseek || has_api_key {
328 self.provider = project.provider;
329 }
330
331 // Merge provider sub-tables field-by-field.
332 merge_provider_config(&mut self.providers.deepseek, &project.providers.deepseek);
333 merge_provider_config(
334 &mut self.providers.nvidia_nim,
335 &project.providers.nvidia_nim,
336 );
337 merge_provider_config(&mut self.providers.openai, &project.providers.openai);
338 merge_provider_config(
339 &mut self.providers.openrouter,
340 &project.providers.openrouter,
341 );
342 merge_provider_config(&mut self.providers.novita, &project.providers.novita);
343 merge_provider_config(&mut self.providers.fireworks, &project.providers.fireworks);
344 merge_provider_config(&mut self.providers.sglang, &project.providers.sglang);
345 merge_provider_config(&mut self.providers.vllm, &project.providers.vllm);
346
347 if project.network.is_some() {
348 self.network = project.network;
349 }
350 if project.skills.is_some() {
351 self.skills = project.skills;
352 }
353 if project.snapshots.is_some() {
354 self.snapshots = project.snapshots;
355 }
356 if project.lsp.is_some() {
357 self.lsp = project.lsp;
358 }
359 for (k, v) in project.extras {
360 self.extras.insert(k, v);
361 }
362 }
363
364 #[must_use]
365 pub fn get_value(&self, key: &str) -> Option<String> {
366 match key {
367 "provider" => Some(self.provider.as_str().to_string()),
368 "api_key" => self.api_key.clone(),
369 "base_url" => self.base_url.clone(),
370 "http_headers" => serialize_http_headers(&self.http_headers),
371 "default_text_model" => self.default_text_model.clone(),
372 "model" => self.model.clone(),
373 "auth.mode" => self.auth_mode.clone(),
374 "auth.chatgpt_access_token" => self.chatgpt_access_token.clone(),
375 "auth.device_code_session" => self.device_code_session.clone(),
376 "output_mode" => self.output_mode.clone(),
377 "log_level" => self.log_level.clone(),
378 "telemetry" => self.telemetry.map(|v| v.to_string()),
379 "approval_policy" => self.approval_policy.clone(),
380 "sandbox_mode" => self.sandbox_mode.clone(),
381 "providers.deepseek.api_key" => self.providers.deepseek.api_key.clone(),
382 "providers.deepseek.base_url" => self.providers.deepseek.base_url.clone(),
383 "providers.deepseek.model" => self.providers.deepseek.model.clone(),
384 "providers.deepseek.http_headers" => {
385 serialize_http_headers(&self.providers.deepseek.http_headers)
386 }
387 "providers.nvidia_nim.api_key" => self.providers.nvidia_nim.api_key.clone(),
388 "providers.nvidia_nim.base_url" => self.providers.nvidia_nim.base_url.clone(),
389 "providers.nvidia_nim.model" => self.providers.nvidia_nim.model.clone(),
390 "providers.nvidia_nim.http_headers" => {
391 serialize_http_headers(&self.providers.nvidia_nim.http_headers)
392 }
393 "providers.openai.api_key" => self.providers.openai.api_key.clone(),
394 "providers.openai.base_url" => self.providers.openai.base_url.clone(),
395 "providers.openai.model" => self.providers.openai.model.clone(),
396 "providers.openai.http_headers" => {
397 serialize_http_headers(&self.providers.openai.http_headers)
398 }
399 "providers.openrouter.api_key" => self.providers.openrouter.api_key.clone(),
400 "providers.openrouter.base_url" => self.providers.openrouter.base_url.clone(),
401 "providers.openrouter.model" => self.providers.openrouter.model.clone(),
402 "providers.openrouter.http_headers" => {
403 serialize_http_headers(&self.providers.openrouter.http_headers)
404 }
405 "providers.novita.api_key" => self.providers.novita.api_key.clone(),
406 "providers.novita.base_url" => self.providers.novita.base_url.clone(),
407 "providers.novita.model" => self.providers.novita.model.clone(),
408 "providers.novita.http_headers" => {
409 serialize_http_headers(&self.providers.novita.http_headers)
410 }
411 "providers.fireworks.api_key" => self.providers.fireworks.api_key.clone(),
412 "providers.fireworks.base_url" => self.providers.fireworks.base_url.clone(),
413 "providers.fireworks.model" => self.providers.fireworks.model.clone(),
414 "providers.fireworks.http_headers" => {
415 serialize_http_headers(&self.providers.fireworks.http_headers)
416 }
417 "providers.sglang.api_key" => self.providers.sglang.api_key.clone(),
418 "providers.sglang.base_url" => self.providers.sglang.base_url.clone(),
419 "providers.sglang.model" => self.providers.sglang.model.clone(),
420 "providers.sglang.http_headers" => {
421 serialize_http_headers(&self.providers.sglang.http_headers)
422 }
423 "providers.vllm.api_key" => self.providers.vllm.api_key.clone(),
424 "providers.vllm.base_url" => self.providers.vllm.base_url.clone(),
425 "providers.vllm.model" => self.providers.vllm.model.clone(),
426 "providers.vllm.http_headers" => {
427 serialize_http_headers(&self.providers.vllm.http_headers)
428 }
429 _ => self.extras.get(key).map(toml::Value::to_string),
430 }
431 }
432
433 pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
434 match key {
435 "provider" => {
436 self.provider = ProviderKind::parse(value)
437 .with_context(|| format!("unknown provider '{value}'"))?;
438 }
439 "api_key" => self.api_key = Some(value.to_string()),
440 "base_url" => self.base_url = Some(value.to_string()),
441 "http_headers" => self.http_headers = parse_http_headers(value)?,
442 "default_text_model" => self.default_text_model = Some(value.to_string()),
443 "model" => self.model = Some(value.to_string()),
444 "auth.mode" => self.auth_mode = Some(value.to_string()),
445 "auth.chatgpt_access_token" => self.chatgpt_access_token = Some(value.to_string()),
446 "auth.device_code_session" => self.device_code_session = Some(value.to_string()),
447 "output_mode" => self.output_mode = Some(value.to_string()),
448 "log_level" => self.log_level = Some(value.to_string()),
449 "telemetry" => {
450 self.telemetry = Some(parse_bool(value)?);
451 }
452 "approval_policy" => self.approval_policy = Some(value.to_string()),
453 "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
454 "providers.deepseek.api_key" => {
455 let value = value.to_string();
456 self.providers.deepseek.api_key = Some(value.clone());
457 self.api_key = Some(value);
458 }
459 "providers.deepseek.base_url" => {
460 let value = value.to_string();
461 self.providers.deepseek.base_url = Some(value.clone());
462 self.base_url = Some(value);
463 }
464 "providers.deepseek.model" => {
465 let value = value.to_string();
466 self.providers.deepseek.model = Some(value.clone());
467 self.default_text_model = Some(value);
468 }
469 "providers.deepseek.http_headers" => {
470 let headers = parse_http_headers(value)?;
471 self.providers.deepseek.http_headers = headers.clone();
472 self.http_headers = headers;
473 }
474 "providers.openai.api_key" => self.providers.openai.api_key = Some(value.to_string()),
475 "providers.openai.base_url" => self.providers.openai.base_url = Some(value.to_string()),
476 "providers.openai.model" => self.providers.openai.model = Some(value.to_string()),
477 "providers.openai.http_headers" => {
478 self.providers.openai.http_headers = parse_http_headers(value)?;
479 }
480 "providers.nvidia_nim.api_key" => {
481 self.providers.nvidia_nim.api_key = Some(value.to_string());
482 }
483 "providers.nvidia_nim.base_url" => {
484 self.providers.nvidia_nim.base_url = Some(value.to_string());
485 }
486 "providers.nvidia_nim.model" => {
487 self.providers.nvidia_nim.model = Some(value.to_string());
488 }
489 "providers.nvidia_nim.http_headers" => {
490 self.providers.nvidia_nim.http_headers = parse_http_headers(value)?;
491 }
492 "providers.openrouter.api_key" => {
493 self.providers.openrouter.api_key = Some(value.to_string());
494 }
495 "providers.openrouter.base_url" => {
496 self.providers.openrouter.base_url = Some(value.to_string());
497 }
498 "providers.openrouter.model" => {
499 self.providers.openrouter.model = Some(value.to_string());
500 }
501 "providers.openrouter.http_headers" => {
502 self.providers.openrouter.http_headers = parse_http_headers(value)?;
503 }
504 "providers.novita.api_key" => {
505 self.providers.novita.api_key = Some(value.to_string());
506 }
507 "providers.novita.base_url" => {
508 self.providers.novita.base_url = Some(value.to_string());
509 }
510 "providers.novita.model" => {
511 self.providers.novita.model = Some(value.to_string());
512 }
513 "providers.novita.http_headers" => {
514 self.providers.novita.http_headers = parse_http_headers(value)?;
515 }
516 "providers.fireworks.api_key" => {
517 self.providers.fireworks.api_key = Some(value.to_string());
518 }
519 "providers.fireworks.base_url" => {
520 self.providers.fireworks.base_url = Some(value.to_string());
521 }
522 "providers.fireworks.model" => {
523 self.providers.fireworks.model = Some(value.to_string());
524 }
525 "providers.fireworks.http_headers" => {
526 self.providers.fireworks.http_headers = parse_http_headers(value)?;
527 }
528 "providers.sglang.api_key" => {
529 self.providers.sglang.api_key = Some(value.to_string());
530 }
531 "providers.sglang.base_url" => {
532 self.providers.sglang.base_url = Some(value.to_string());
533 }
534 "providers.sglang.model" => {
535 self.providers.sglang.model = Some(value.to_string());
536 }
537 "providers.sglang.http_headers" => {
538 self.providers.sglang.http_headers = parse_http_headers(value)?;
539 }
540 "providers.vllm.api_key" => {
541 self.providers.vllm.api_key = Some(value.to_string());
542 }
543 "providers.vllm.base_url" => {
544 self.providers.vllm.base_url = Some(value.to_string());
545 }
546 "providers.vllm.model" => {
547 self.providers.vllm.model = Some(value.to_string());
548 }
549 "providers.vllm.http_headers" => {
550 self.providers.vllm.http_headers = parse_http_headers(value)?;
551 }
552 _ => {
553 self.extras
554 .insert(key.to_string(), toml::Value::String(value.to_string()));
555 }
556 }
557 Ok(())
558 }
559
560 pub fn unset_value(&mut self, key: &str) -> Result<()> {
561 match key {
562 "provider" => self.provider = ProviderKind::Deepseek,
563 "api_key" => self.api_key = None,
564 "base_url" => self.base_url = None,
565 "http_headers" => self.http_headers.clear(),
566 "default_text_model" => self.default_text_model = None,
567 "model" => self.model = None,
568 "auth.mode" => self.auth_mode = None,
569 "auth.chatgpt_access_token" => self.chatgpt_access_token = None,
570 "auth.device_code_session" => self.device_code_session = None,
571 "output_mode" => self.output_mode = None,
572 "log_level" => self.log_level = None,
573 "telemetry" => self.telemetry = None,
574 "approval_policy" => self.approval_policy = None,
575 "sandbox_mode" => self.sandbox_mode = None,
576 "providers.deepseek.api_key" => {
577 self.providers.deepseek.api_key = None;
578 self.api_key = None;
579 }
580 "providers.deepseek.base_url" => {
581 self.providers.deepseek.base_url = None;
582 self.base_url = None;
583 }
584 "providers.deepseek.model" => {
585 self.providers.deepseek.model = None;
586 self.default_text_model = None;
587 }
588 "providers.deepseek.http_headers" => {
589 self.providers.deepseek.http_headers.clear();
590 self.http_headers.clear();
591 }
592 "providers.openai.api_key" => self.providers.openai.api_key = None,
593 "providers.openai.base_url" => self.providers.openai.base_url = None,
594 "providers.openai.model" => self.providers.openai.model = None,
595 "providers.openai.http_headers" => self.providers.openai.http_headers.clear(),
596 "providers.nvidia_nim.api_key" => self.providers.nvidia_nim.api_key = None,
597 "providers.nvidia_nim.base_url" => self.providers.nvidia_nim.base_url = None,
598 "providers.nvidia_nim.model" => self.providers.nvidia_nim.model = None,
599 "providers.nvidia_nim.http_headers" => self.providers.nvidia_nim.http_headers.clear(),
600 "providers.openrouter.api_key" => self.providers.openrouter.api_key = None,
601 "providers.openrouter.base_url" => self.providers.openrouter.base_url = None,
602 "providers.openrouter.model" => self.providers.openrouter.model = None,
603 "providers.openrouter.http_headers" => self.providers.openrouter.http_headers.clear(),
604 "providers.novita.api_key" => self.providers.novita.api_key = None,
605 "providers.novita.base_url" => self.providers.novita.base_url = None,
606 "providers.novita.model" => self.providers.novita.model = None,
607 "providers.novita.http_headers" => self.providers.novita.http_headers.clear(),
608 "providers.fireworks.api_key" => self.providers.fireworks.api_key = None,
609 "providers.fireworks.base_url" => self.providers.fireworks.base_url = None,
610 "providers.fireworks.model" => self.providers.fireworks.model = None,
611 "providers.fireworks.http_headers" => self.providers.fireworks.http_headers.clear(),
612 "providers.sglang.api_key" => self.providers.sglang.api_key = None,
613 "providers.sglang.base_url" => self.providers.sglang.base_url = None,
614 "providers.sglang.model" => self.providers.sglang.model = None,
615 "providers.sglang.http_headers" => self.providers.sglang.http_headers.clear(),
616 "providers.vllm.api_key" => self.providers.vllm.api_key = None,
617 "providers.vllm.base_url" => self.providers.vllm.base_url = None,
618 "providers.vllm.model" => self.providers.vllm.model = None,
619 "providers.vllm.http_headers" => self.providers.vllm.http_headers.clear(),
620 _ => {
621 self.extras.remove(key);
622 }
623 }
624 Ok(())
625 }
626
627 #[must_use]
628 pub fn list_values(&self) -> BTreeMap<String, String> {
629 let mut out = BTreeMap::new();
630 out.insert("provider".to_string(), self.provider.as_str().to_string());
631
632 if let Some(v) = self.api_key.as_ref() {
633 out.insert("api_key".to_string(), redact_secret(v));
634 }
635 if let Some(v) = self.base_url.as_ref() {
636 out.insert("base_url".to_string(), v.clone());
637 }
638 if let Some(v) = serialize_http_headers(&self.http_headers) {
639 out.insert("http_headers".to_string(), v);
640 }
641 if let Some(v) = self.default_text_model.as_ref() {
642 out.insert("default_text_model".to_string(), v.clone());
643 }
644 if let Some(v) = self.model.as_ref() {
645 out.insert("model".to_string(), v.clone());
646 }
647 if let Some(v) = self.auth_mode.as_ref() {
648 out.insert("auth.mode".to_string(), v.clone());
649 }
650 if let Some(v) = self.chatgpt_access_token.as_ref() {
651 out.insert("auth.chatgpt_access_token".to_string(), redact_secret(v));
652 }
653 if let Some(v) = self.device_code_session.as_ref() {
654 out.insert("auth.device_code_session".to_string(), redact_secret(v));
655 }
656 if let Some(v) = self.output_mode.as_ref() {
657 out.insert("output_mode".to_string(), v.clone());
658 }
659 if let Some(v) = self.log_level.as_ref() {
660 out.insert("log_level".to_string(), v.clone());
661 }
662 if let Some(v) = self.telemetry {
663 out.insert("telemetry".to_string(), v.to_string());
664 }
665 if let Some(v) = self.approval_policy.as_ref() {
666 out.insert("approval_policy".to_string(), v.clone());
667 }
668 if let Some(v) = self.sandbox_mode.as_ref() {
669 out.insert("sandbox_mode".to_string(), v.clone());
670 }
671 if let Some(v) = self.providers.deepseek.api_key.as_ref() {
672 out.insert("providers.deepseek.api_key".to_string(), redact_secret(v));
673 }
674 if let Some(v) = self.providers.deepseek.base_url.as_ref() {
675 out.insert("providers.deepseek.base_url".to_string(), v.clone());
676 }
677 if let Some(v) = self.providers.deepseek.model.as_ref() {
678 out.insert("providers.deepseek.model".to_string(), v.clone());
679 }
680 if let Some(v) = serialize_http_headers(&self.providers.deepseek.http_headers) {
681 out.insert("providers.deepseek.http_headers".to_string(), v);
682 }
683 if let Some(v) = self.providers.openai.api_key.as_ref() {
684 out.insert("providers.openai.api_key".to_string(), redact_secret(v));
685 }
686 if let Some(v) = self.providers.openai.base_url.as_ref() {
687 out.insert("providers.openai.base_url".to_string(), v.clone());
688 }
689 if let Some(v) = self.providers.openai.model.as_ref() {
690 out.insert("providers.openai.model".to_string(), v.clone());
691 }
692 if let Some(v) = serialize_http_headers(&self.providers.openai.http_headers) {
693 out.insert("providers.openai.http_headers".to_string(), v);
694 }
695 if let Some(v) = self.providers.nvidia_nim.api_key.as_ref() {
696 out.insert("providers.nvidia_nim.api_key".to_string(), redact_secret(v));
697 }
698 if let Some(v) = self.providers.nvidia_nim.base_url.as_ref() {
699 out.insert("providers.nvidia_nim.base_url".to_string(), v.clone());
700 }
701 if let Some(v) = self.providers.nvidia_nim.model.as_ref() {
702 out.insert("providers.nvidia_nim.model".to_string(), v.clone());
703 }
704 if let Some(v) = serialize_http_headers(&self.providers.nvidia_nim.http_headers) {
705 out.insert("providers.nvidia_nim.http_headers".to_string(), v);
706 }
707 if let Some(v) = self.providers.openrouter.api_key.as_ref() {
708 out.insert("providers.openrouter.api_key".to_string(), redact_secret(v));
709 }
710 if let Some(v) = self.providers.openrouter.base_url.as_ref() {
711 out.insert("providers.openrouter.base_url".to_string(), v.clone());
712 }
713 if let Some(v) = self.providers.openrouter.model.as_ref() {
714 out.insert("providers.openrouter.model".to_string(), v.clone());
715 }
716 if let Some(v) = serialize_http_headers(&self.providers.openrouter.http_headers) {
717 out.insert("providers.openrouter.http_headers".to_string(), v);
718 }
719 if let Some(v) = self.providers.novita.api_key.as_ref() {
720 out.insert("providers.novita.api_key".to_string(), redact_secret(v));
721 }
722 if let Some(v) = self.providers.novita.base_url.as_ref() {
723 out.insert("providers.novita.base_url".to_string(), v.clone());
724 }
725 if let Some(v) = self.providers.novita.model.as_ref() {
726 out.insert("providers.novita.model".to_string(), v.clone());
727 }
728 if let Some(v) = serialize_http_headers(&self.providers.novita.http_headers) {
729 out.insert("providers.novita.http_headers".to_string(), v);
730 }
731 if let Some(v) = self.providers.fireworks.api_key.as_ref() {
732 out.insert("providers.fireworks.api_key".to_string(), redact_secret(v));
733 }
734 if let Some(v) = self.providers.fireworks.base_url.as_ref() {
735 out.insert("providers.fireworks.base_url".to_string(), v.clone());
736 }
737 if let Some(v) = self.providers.fireworks.model.as_ref() {
738 out.insert("providers.fireworks.model".to_string(), v.clone());
739 }
740 if let Some(v) = serialize_http_headers(&self.providers.fireworks.http_headers) {
741 out.insert("providers.fireworks.http_headers".to_string(), v);
742 }
743 if let Some(v) = self.providers.sglang.api_key.as_ref() {
744 out.insert("providers.sglang.api_key".to_string(), redact_secret(v));
745 }
746 if let Some(v) = self.providers.sglang.base_url.as_ref() {
747 out.insert("providers.sglang.base_url".to_string(), v.clone());
748 }
749 if let Some(v) = self.providers.sglang.model.as_ref() {
750 out.insert("providers.sglang.model".to_string(), v.clone());
751 }
752 if let Some(v) = serialize_http_headers(&self.providers.sglang.http_headers) {
753 out.insert("providers.sglang.http_headers".to_string(), v);
754 }
755 if let Some(v) = self.providers.vllm.api_key.as_ref() {
756 out.insert("providers.vllm.api_key".to_string(), redact_secret(v));
757 }
758 if let Some(v) = self.providers.vllm.base_url.as_ref() {
759 out.insert("providers.vllm.base_url".to_string(), v.clone());
760 }
761 if let Some(v) = self.providers.vllm.model.as_ref() {
762 out.insert("providers.vllm.model".to_string(), v.clone());
763 }
764 if let Some(v) = serialize_http_headers(&self.providers.vllm.http_headers) {
765 out.insert("providers.vllm.http_headers".to_string(), v);
766 }
767
768 for (k, v) in &self.extras {
769 out.insert(k.clone(), v.to_string());
770 }
771 out
772 }
773
774 /// Resolve runtime options without touching platform credential stores.
775 ///
776 /// This method keeps library callers prompt-free: CLI flag → config file
777 /// → environment. Call `resolve_runtime_options_with_secrets` when a
778 /// user-facing dispatcher should recover OS-keyring credentials.
779 #[must_use]
780 pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
781 let no_keyring = Secrets::new(std::sync::Arc::new(
782 deepseek_secrets::InMemoryKeyringStore::new(),
783 ));
784 self.resolve_runtime_options_with_secrets(cli, &no_keyring)
785 }
786
787 /// Resolve runtime options using an explicit secrets façade.
788 ///
789 /// API-key precedence is **CLI flag → config-file → keyring → environment**.
790 #[must_use]
791 pub fn resolve_runtime_options_with_secrets(
792 &self,
793 cli: &CliRuntimeOverrides,
794 secrets: &Secrets,
795 ) -> ResolvedRuntimeOptions {
796 let env = EnvRuntimeOverrides::load();
797 let provider = cli.provider.or(env.provider).unwrap_or(self.provider);
798
799 let provider_cfg = self.providers.for_provider(provider);
800 let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
801 .then(|| self.api_key.clone())
802 .flatten();
803 let root_deepseek_base_url = (provider == ProviderKind::Deepseek)
804 .then(|| self.base_url.clone())
805 .flatten();
806 let root_deepseek_model = (provider == ProviderKind::Deepseek)
807 .then(|| self.default_text_model.clone())
808 .flatten();
809 // CLI flag wins outright. Otherwise: config-file → injected secrets/env.
810 // This makes `deepseek auth set` a reliable fix even when the user's
811 // shell still exports an old key. When the file is empty, the injected
812 // secrets façade recovers older OS-keyring credentials before falling
813 // back to ambient env.
814 let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
815 let (api_key, api_key_source) = if let Some(value) = cli.api_key.clone() {
816 (Some(value), Some(RuntimeApiKeySource::Cli))
817 } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) {
818 (Some(value), Some(RuntimeApiKeySource::ConfigFile))
819 } else if let Some((value, source)) = secrets.resolve_with_source(provider.as_str()) {
820 let source = match source {
821 SecretSource::Keyring => RuntimeApiKeySource::Keyring,
822 SecretSource::Env => RuntimeApiKeySource::Env,
823 };
824 (Some(value), Some(source))
825 } else {
826 (None, None)
827 };
828
829 let base_url = cli
830 .base_url
831 .clone()
832 .or_else(|| env.base_url_for(provider))
833 .or_else(|| provider_cfg.base_url.clone())
834 .or(root_deepseek_base_url)
835 .unwrap_or_else(|| match provider {
836 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
837 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
838 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
839 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
840 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
841 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
842 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
843 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
844 });
845
846 let model = cli
847 .model
848 .clone()
849 .or_else(|| env.model.clone())
850 .or_else(|| provider_cfg.model.clone())
851 .or(root_deepseek_model)
852 .or_else(|| self.model.clone())
853 .unwrap_or_else(|| match provider {
854 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL.to_string(),
855 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL.to_string(),
856 ProviderKind::Openai => DEFAULT_OPENAI_MODEL.to_string(),
857 ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL.to_string(),
858 ProviderKind::Novita => DEFAULT_NOVITA_MODEL.to_string(),
859 ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL.to_string(),
860 ProviderKind::Sglang => DEFAULT_SGLANG_MODEL.to_string(),
861 ProviderKind::Vllm => DEFAULT_VLLM_MODEL.to_string(),
862 });
863 let model = normalize_model_for_provider(provider, &model);
864
865 let mut http_headers = self.http_headers.clone();
866 http_headers.extend(provider_cfg.http_headers.clone());
867 if let Some(env_headers) = env.http_headers {
868 http_headers.extend(env_headers);
869 }
870 http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
871
872 let output_mode = cli
873 .output_mode
874 .clone()
875 .or_else(|| env.output_mode.clone())
876 .or_else(|| self.output_mode.clone());
877 let auth_mode = cli
878 .auth_mode
879 .clone()
880 .or_else(|| env.auth_mode.clone())
881 .or_else(|| self.auth_mode.clone());
882 let log_level = cli
883 .log_level
884 .clone()
885 .or_else(|| env.log_level.clone())
886 .or_else(|| self.log_level.clone());
887 let telemetry = cli
888 .telemetry
889 .or(env.telemetry)
890 .or(self.telemetry)
891 .unwrap_or(false);
892 let approval_policy = cli
893 .approval_policy
894 .clone()
895 .or_else(|| env.approval_policy.clone())
896 .or_else(|| self.approval_policy.clone());
897 let sandbox_mode = cli
898 .sandbox_mode
899 .clone()
900 .or_else(|| env.sandbox_mode.clone())
901 .or_else(|| self.sandbox_mode.clone());
902
903 ResolvedRuntimeOptions {
904 provider,
905 model,
906 api_key,
907 api_key_source,
908 base_url,
909 auth_mode,
910 output_mode,
911 log_level,
912 telemetry,
913 approval_policy,
914 sandbox_mode,
915 http_headers,
916 }
917 }
918 }
919
920 fn merge_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
921 if source.api_key.is_some() {
922 target.api_key = source.api_key.clone();
923 }
924 if source.base_url.is_some() {
925 target.base_url = source.base_url.clone();
926 }
927 if source.model.is_some() {
928 target.model = source.model.clone();
929 }
930 if !source.http_headers.is_empty() {
931 target.http_headers = source.http_headers.clone();
932 }
933 }
934
935 /// Load a project-level config from `$WORKSPACE/.deepseek/config.toml`.
936 /// Returns `None` if the file doesn't exist or can't be parsed.
937 pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
938 let path = workspace.join(".deepseek").join(CONFIG_FILE_NAME);
939 if !path.exists() {
940 return None;
941 }
942 let raw = fs::read_to_string(&path).ok()?;
943 toml::from_str(&raw).ok()
944 }
945
946 fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
947 let normalized = model.trim().to_ascii_lowercase();
948 match (provider, normalized.as_str()) {
949 (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
950 DEFAULT_NVIDIA_NIM_MODEL.to_string()
951 }
952 (
953 ProviderKind::NvidiaNim,
954 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
955 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
956 ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
957 (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
958 DEFAULT_OPENROUTER_MODEL.to_string()
959 }
960 (
961 ProviderKind::Openrouter,
962 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
963 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
964 ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
965 (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
966 DEFAULT_NOVITA_MODEL.to_string()
967 }
968 (
969 ProviderKind::Novita,
970 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
971 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
972 ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
973 (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
974 DEFAULT_FIREWORKS_MODEL.to_string()
975 }
976 (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
977 DEFAULT_SGLANG_MODEL.to_string()
978 }
979 (
980 ProviderKind::Sglang,
981 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
982 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
983 ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
984 (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
985 DEFAULT_VLLM_MODEL.to_string()
986 }
987 (
988 ProviderKind::Vllm,
989 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
990 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
991 ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
992 _ => model.to_string(),
993 }
994 }
995
996 #[derive(Debug, Clone, Default)]
997 pub struct CliRuntimeOverrides {
998 pub provider: Option<ProviderKind>,
999 pub model: Option<String>,
1000 pub api_key: Option<String>,
1001 pub base_url: Option<String>,
1002 pub auth_mode: Option<String>,
1003 pub output_mode: Option<String>,
1004 pub log_level: Option<String>,
1005 pub telemetry: Option<bool>,
1006 pub approval_policy: Option<String>,
1007 pub sandbox_mode: Option<String>,
1008 }
1009
1010 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1011 pub enum RuntimeApiKeySource {
1012 Cli,
1013 ConfigFile,
1014 Keyring,
1015 Env,
1016 }
1017
1018 impl RuntimeApiKeySource {
1019 #[must_use]
1020 pub fn as_env_value(self) -> &'static str {
1021 match self {
1022 Self::Cli => "cli",
1023 Self::ConfigFile => "config",
1024 Self::Keyring => "keyring",
1025 Self::Env => "env",
1026 }
1027 }
1028 }
1029
1030 #[derive(Debug, Clone)]
1031 pub struct ResolvedRuntimeOptions {
1032 pub provider: ProviderKind,
1033 pub model: String,
1034 pub api_key: Option<String>,
1035 pub api_key_source: Option<RuntimeApiKeySource>,
1036 pub base_url: String,
1037 pub auth_mode: Option<String>,
1038 pub output_mode: Option<String>,
1039 pub log_level: Option<String>,
1040 pub telemetry: bool,
1041 pub approval_policy: Option<String>,
1042 pub sandbox_mode: Option<String>,
1043 pub http_headers: BTreeMap<String, String>,
1044 }
1045
1046 #[derive(Debug, Clone)]
1047 pub struct ConfigStore {
1048 path: PathBuf,
1049 pub config: ConfigToml,
1050 }
1051
1052 impl ConfigStore {
1053 pub fn load(path: Option<PathBuf>) -> Result<Self> {
1054 let path = resolve_config_path(path)?;
1055 if !path.exists() {
1056 return Ok(Self {
1057 path,
1058 config: ConfigToml::default(),
1059 });
1060 }
1061
1062 let raw = fs::read_to_string(&path)
1063 .with_context(|| format!("failed to read config at {}", path.display()))?;
1064 let parsed: ConfigToml = toml::from_str(&raw)
1065 .with_context(|| format!("failed to parse config at {}", path.display()))?;
1066
1067 Ok(Self {
1068 path,
1069 config: parsed,
1070 })
1071 }
1072
1073 pub fn save(&self) -> Result<()> {
1074 if let Some(parent) = self.path.parent() {
1075 fs::create_dir_all(parent).with_context(|| {
1076 format!("failed to create config directory {}", parent.display())
1077 })?;
1078 }
1079 let body = toml::to_string_pretty(&self.config).context("failed to serialize config")?;
1080 #[cfg(unix)]
1081 {
1082 let mut file = fs::OpenOptions::new()
1083 .write(true)
1084 .create(true)
1085 .truncate(true)
1086 .mode(0o600)
1087 .open(&self.path)
1088 .with_context(|| format!("failed to write config at {}", self.path.display()))?;
1089 file.write_all(body.as_bytes())
1090 .with_context(|| format!("failed to write config at {}", self.path.display()))?;
1091 file.set_permissions(fs::Permissions::from_mode(0o600))
1092 .with_context(|| {
1093 format!(
1094 "failed to set config permissions at {}",
1095 self.path.display()
1096 )
1097 })?;
1098 }
1099 #[cfg(not(unix))]
1100 {
1101 fs::write(&self.path, body)
1102 .with_context(|| format!("failed to write config at {}", self.path.display()))?;
1103 }
1104 Ok(())
1105 }
1106
1107 #[must_use]
1108 pub fn path(&self) -> &Path {
1109 &self.path
1110 }
1111 }
1112
1113 /// Process-wide default [`Secrets`] façade. The first caller wins; the
1114 /// lock is exposed so test or CLI code can install an explicit
1115 /// backend (e.g. an [`deepseek_secrets::InMemoryKeyringStore`]) before
1116 /// any resolver runs.
1117 pub fn default_secrets() -> &'static Secrets {
1118 static SECRETS: OnceLock<Secrets> = OnceLock::new();
1119 SECRETS.get_or_init(|| {
1120 // Tests should never poke real platform credential stores. Cargo sets the
1121 // `RUST_TEST_*` family of env vars (and `CARGO_PKG_NAME` is
1122 // always populated), but the `cfg(test)` flag is the canonical
1123 // signal here. See `install_test_secrets` for explicit installs.
1124 #[cfg(test)]
1125 {
1126 Secrets::new(std::sync::Arc::new(
1127 deepseek_secrets::InMemoryKeyringStore::new(),
1128 ))
1129 }
1130 #[cfg(not(test))]
1131 {
1132 Secrets::auto_detect()
1133 }
1134 })
1135 }
1136
1137 pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
1138 if let Some(path) = explicit {
1139 return Ok(path);
1140 }
1141 if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
1142 let trimmed = path.trim();
1143 if !trimmed.is_empty() {
1144 return Ok(PathBuf::from(trimmed));
1145 }
1146 }
1147 default_config_path()
1148 }
1149
1150 pub fn default_config_path() -> Result<PathBuf> {
1151 let home = dirs::home_dir().context("failed to resolve home directory for config path")?;
1152 Ok(home.join(".deepseek").join(CONFIG_FILE_NAME))
1153 }
1154
1155 fn parse_bool(raw: &str) -> Result<bool> {
1156 match raw.trim().to_ascii_lowercase().as_str() {
1157 "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
1158 "0" | "false" | "no" | "off" | "disabled" => Ok(false),
1159 _ => bail!("invalid boolean '{raw}'"),
1160 }
1161 }
1162
1163 fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
1164 let mut headers = BTreeMap::new();
1165 for pair in raw.trim().split(',') {
1166 let pair = pair.trim();
1167 if pair.is_empty() {
1168 continue;
1169 }
1170 let Some((name, value)) = pair.split_once('=') else {
1171 bail!("invalid header pair '{pair}', expected name=value");
1172 };
1173 let name = name.trim();
1174 let value = value.trim();
1175 if name.is_empty() {
1176 bail!("header name cannot be empty");
1177 }
1178 if value.is_empty() {
1179 continue;
1180 }
1181 headers.insert(name.to_string(), value.to_string());
1182 }
1183 Ok(headers)
1184 }
1185
1186 fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
1187 if headers.is_empty() {
1188 return None;
1189 }
1190 Some(
1191 headers
1192 .iter()
1193 .map(|(name, value)| format!("{name}={value}"))
1194 .collect::<Vec<_>>()
1195 .join(","),
1196 )
1197 }
1198
1199 fn redact_secret(secret: &str) -> String {
1200 if secret.len() <= 16 {
1201 return "********".to_string();
1202 }
1203 format!("{}***{}", &secret[..4], &secret[secret.len() - 4..])
1204 }
1205
1206 #[derive(Debug, Clone, Default)]
1207 struct EnvRuntimeOverrides {
1208 provider: Option<ProviderKind>,
1209 model: Option<String>,
1210 output_mode: Option<String>,
1211 auth_mode: Option<String>,
1212 log_level: Option<String>,
1213 telemetry: Option<bool>,
1214 approval_policy: Option<String>,
1215 sandbox_mode: Option<String>,
1216 http_headers: Option<BTreeMap<String, String>>,
1217 deepseek_base_url: Option<String>,
1218 nvidia_base_url: Option<String>,
1219 openai_base_url: Option<String>,
1220 openrouter_base_url: Option<String>,
1221 novita_base_url: Option<String>,
1222 fireworks_base_url: Option<String>,
1223 sglang_base_url: Option<String>,
1224 vllm_base_url: Option<String>,
1225 }
1226
1227 impl EnvRuntimeOverrides {
1228 fn load() -> Self {
1229 Self {
1230 provider: std::env::var("DEEPSEEK_PROVIDER")
1231 .ok()
1232 .and_then(|v| ProviderKind::parse(&v)),
1233 model: std::env::var("DEEPSEEK_MODEL").ok(),
1234 output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(),
1235 auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(),
1236 log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(),
1237 telemetry: std::env::var("DEEPSEEK_TELEMETRY")
1238 .ok()
1239 .and_then(|v| parse_bool(&v).ok()),
1240 approval_policy: std::env::var("DEEPSEEK_APPROVAL_POLICY").ok(),
1241 sandbox_mode: std::env::var("DEEPSEEK_SANDBOX_MODE").ok(),
1242 http_headers: std::env::var("DEEPSEEK_HTTP_HEADERS")
1243 .ok()
1244 .and_then(|value| parse_http_headers(&value).ok())
1245 .filter(|headers| !headers.is_empty()),
1246 deepseek_base_url: std::env::var("DEEPSEEK_BASE_URL")
1247 .ok()
1248 .filter(|v| !v.trim().is_empty()),
1249 nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
1250 .or_else(|_| std::env::var("NIM_BASE_URL"))
1251 .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
1252 .ok()
1253 .filter(|v| !v.trim().is_empty()),
1254 openai_base_url: std::env::var("OPENAI_BASE_URL")
1255 .ok()
1256 .filter(|v| !v.trim().is_empty()),
1257 openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
1258 .ok()
1259 .filter(|v| !v.trim().is_empty()),
1260 novita_base_url: std::env::var("NOVITA_BASE_URL")
1261 .ok()
1262 .filter(|v| !v.trim().is_empty()),
1263 fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
1264 .ok()
1265 .filter(|v| !v.trim().is_empty()),
1266 sglang_base_url: std::env::var("SGLANG_BASE_URL")
1267 .ok()
1268 .filter(|v| !v.trim().is_empty()),
1269 vllm_base_url: std::env::var("VLLM_BASE_URL")
1270 .ok()
1271 .filter(|v| !v.trim().is_empty()),
1272 }
1273 }
1274
1275 fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
1276 // Defaults belong in the resolver's final fallback so config-file
1277 // values (`providers.<name>.base_url`) still win when env is unset.
1278 match provider {
1279 ProviderKind::Deepseek => self.deepseek_base_url.clone(),
1280 ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
1281 ProviderKind::Openai => self.openai_base_url.clone(),
1282 ProviderKind::Openrouter => self.openrouter_base_url.clone(),
1283 ProviderKind::Novita => self.novita_base_url.clone(),
1284 ProviderKind::Fireworks => self.fireworks_base_url.clone(),
1285 ProviderKind::Sglang => self.sglang_base_url.clone(),
1286 ProviderKind::Vllm => self.vllm_base_url.clone(),
1287 }
1288 }
1289 }
1290
1291 #[cfg(test)]
1292 mod tests {
1293 use super::*;
1294 use std::env;
1295 use std::ffi::OsString;
1296 use std::sync::{Mutex, OnceLock};
1297
1298 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1299 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1300 LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
1301 }
1302
1303 struct EnvGuard {
1304 deepseek_api_key: Option<OsString>,
1305 deepseek_base_url: Option<OsString>,
1306 deepseek_http_headers: Option<OsString>,
1307 deepseek_model: Option<OsString>,
1308 deepseek_provider: Option<OsString>,
1309 nvidia_api_key: Option<OsString>,
1310 nvidia_nim_api_key: Option<OsString>,
1311 nim_base_url: Option<OsString>,
1312 nvidia_base_url: Option<OsString>,
1313 nvidia_nim_base_url: Option<OsString>,
1314 openrouter_api_key: Option<OsString>,
1315 openrouter_base_url: Option<OsString>,
1316 novita_api_key: Option<OsString>,
1317 novita_base_url: Option<OsString>,
1318 fireworks_api_key: Option<OsString>,
1319 fireworks_base_url: Option<OsString>,
1320 sglang_api_key: Option<OsString>,
1321 sglang_base_url: Option<OsString>,
1322 vllm_api_key: Option<OsString>,
1323 vllm_base_url: Option<OsString>,
1324 }
1325
1326 impl EnvGuard {
1327 fn without_deepseek_runtime_overrides() -> Self {
1328 let guard = Self {
1329 deepseek_api_key: env::var_os("DEEPSEEK_API_KEY"),
1330 deepseek_base_url: env::var_os("DEEPSEEK_BASE_URL"),
1331 deepseek_http_headers: env::var_os("DEEPSEEK_HTTP_HEADERS"),
1332 deepseek_model: env::var_os("DEEPSEEK_MODEL"),
1333 deepseek_provider: env::var_os("DEEPSEEK_PROVIDER"),
1334 nvidia_api_key: env::var_os("NVIDIA_API_KEY"),
1335 nvidia_nim_api_key: env::var_os("NVIDIA_NIM_API_KEY"),
1336 nim_base_url: env::var_os("NIM_BASE_URL"),
1337 nvidia_base_url: env::var_os("NVIDIA_BASE_URL"),
1338 nvidia_nim_base_url: env::var_os("NVIDIA_NIM_BASE_URL"),
1339 openrouter_api_key: env::var_os("OPENROUTER_API_KEY"),
1340 openrouter_base_url: env::var_os("OPENROUTER_BASE_URL"),
1341 novita_api_key: env::var_os("NOVITA_API_KEY"),
1342 novita_base_url: env::var_os("NOVITA_BASE_URL"),
1343 fireworks_api_key: env::var_os("FIREWORKS_API_KEY"),
1344 fireworks_base_url: env::var_os("FIREWORKS_BASE_URL"),
1345 sglang_api_key: env::var_os("SGLANG_API_KEY"),
1346 sglang_base_url: env::var_os("SGLANG_BASE_URL"),
1347 vllm_api_key: env::var_os("VLLM_API_KEY"),
1348 vllm_base_url: env::var_os("VLLM_BASE_URL"),
1349 };
1350 // Safety: test-only environment mutation guarded by a module mutex.
1351 unsafe {
1352 env::remove_var("DEEPSEEK_API_KEY");
1353 env::remove_var("DEEPSEEK_BASE_URL");
1354 env::remove_var("DEEPSEEK_HTTP_HEADERS");
1355 env::remove_var("DEEPSEEK_MODEL");
1356 env::remove_var("DEEPSEEK_PROVIDER");
1357 env::remove_var("NVIDIA_API_KEY");
1358 env::remove_var("NVIDIA_NIM_API_KEY");
1359 env::remove_var("NIM_BASE_URL");
1360 env::remove_var("NVIDIA_BASE_URL");
1361 env::remove_var("NVIDIA_NIM_BASE_URL");
1362 env::remove_var("OPENROUTER_API_KEY");
1363 env::remove_var("OPENROUTER_BASE_URL");
1364 env::remove_var("NOVITA_API_KEY");
1365 env::remove_var("NOVITA_BASE_URL");
1366 env::remove_var("FIREWORKS_API_KEY");
1367 env::remove_var("FIREWORKS_BASE_URL");
1368 env::remove_var("SGLANG_API_KEY");
1369 env::remove_var("SGLANG_BASE_URL");
1370 env::remove_var("VLLM_API_KEY");
1371 env::remove_var("VLLM_BASE_URL");
1372 }
1373 guard
1374 }
1375
1376 unsafe fn restore_var(key: &str, value: Option<OsString>) {
1377 if let Some(value) = value {
1378 unsafe { env::set_var(key, value) };
1379 } else {
1380 unsafe { env::remove_var(key) };
1381 }
1382 }
1383 }
1384
1385 impl Drop for EnvGuard {
1386 fn drop(&mut self) {
1387 // Safety: test-only environment mutation guarded by a module mutex.
1388 unsafe {
1389 Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take());
1390 Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take());
1391 Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take());
1392 Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take());
1393 Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take());
1394 Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take());
1395 Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take());
1396 Self::restore_var("NIM_BASE_URL", self.nim_base_url.take());
1397 Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take());
1398 Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take());
1399 Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take());
1400 Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take());
1401 Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take());
1402 Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take());
1403 Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take());
1404 Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take());
1405 Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take());
1406 Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take());
1407 Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take());
1408 Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take());
1409 }
1410 }
1411 }
1412
1413 #[test]
1414 fn root_deepseek_fields_are_runtime_fallbacks() {
1415 let _lock = env_lock();
1416 let _env = EnvGuard::without_deepseek_runtime_overrides();
1417 let config = ConfigToml {
1418 api_key: Some("root-key".to_string()),
1419 base_url: Some("https://api.deepseek.com".to_string()),
1420 default_text_model: Some("deepseek-v4-pro".to_string()),
1421 ..ConfigToml::default()
1422 };
1423
1424 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1425
1426 assert_eq!(resolved.provider, ProviderKind::Deepseek);
1427 assert_eq!(resolved.api_key.as_deref(), Some("root-key"));
1428 assert_eq!(resolved.base_url, "https://api.deepseek.com");
1429 assert_eq!(resolved.model, "deepseek-v4-pro");
1430 }
1431
1432 #[test]
1433 fn provider_specific_deepseek_fields_override_tui_compat_fields() {
1434 let _lock = env_lock();
1435 let _env = EnvGuard::without_deepseek_runtime_overrides();
1436 let mut config = ConfigToml {
1437 api_key: Some("root-key".to_string()),
1438 base_url: Some("https://api.deepseek.com".to_string()),
1439 default_text_model: Some("deepseek-v4-pro".to_string()),
1440 ..ConfigToml::default()
1441 };
1442 config.providers.deepseek.api_key = Some("provider-key".to_string());
1443 config.providers.deepseek.base_url = Some("https://api.deepseeki.com".to_string());
1444 config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
1445
1446 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1447
1448 assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
1449 assert_eq!(resolved.base_url, "https://api.deepseeki.com");
1450 assert_eq!(resolved.model, "deepseek-v4-flash");
1451 }
1452
1453 #[test]
1454 fn provider_http_headers_override_root_headers() {
1455 let _lock = env_lock();
1456 let _env = EnvGuard::without_deepseek_runtime_overrides();
1457 let mut config = ConfigToml {
1458 api_key: Some("root-key".to_string()),
1459 base_url: Some("https://api.deepseek.com".to_string()),
1460 default_text_model: Some("deepseek-v4-pro".to_string()),
1461 ..ConfigToml::default()
1462 };
1463 config.providers.deepseek.api_key = Some("provider-key".to_string());
1464 config.providers.deepseek.base_url = Some("https://api.deepseeki.com".to_string());
1465 config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
1466 config
1467 .http_headers
1468 .insert("X-Shared".to_string(), "root".to_string());
1469 config
1470 .providers
1471 .deepseek
1472 .http_headers
1473 .insert("X-Model-Provider-Id".to_string(), "tongyi".to_string());
1474 config
1475 .providers
1476 .deepseek
1477 .http_headers
1478 .insert("X-Shared".to_string(), "provider".to_string());
1479
1480 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1481
1482 assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
1483 assert_eq!(resolved.base_url, "https://api.deepseeki.com");
1484 assert_eq!(resolved.model, "deepseek-v4-flash");
1485 assert_eq!(
1486 resolved
1487 .http_headers
1488 .get("X-Model-Provider-Id")
1489 .map(String::as_str),
1490 Some("tongyi")
1491 );
1492 assert_eq!(
1493 resolved.http_headers.get("X-Shared").map(String::as_str),
1494 Some("provider")
1495 );
1496 }
1497
1498 #[test]
1499 fn http_headers_env_overrides_config() {
1500 let _lock = env_lock();
1501 let _env = EnvGuard::without_deepseek_runtime_overrides();
1502 let mut config = ConfigToml::default();
1503 config
1504 .http_headers
1505 .insert("X-Model-Provider-Id".to_string(), "from-file".to_string());
1506 // Safety: test-only environment mutation guarded by a module mutex.
1507 unsafe {
1508 env::set_var("DEEPSEEK_HTTP_HEADERS", "X-Model-Provider-Id=from-env");
1509 }
1510
1511 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1512
1513 assert_eq!(
1514 resolved
1515 .http_headers
1516 .get("X-Model-Provider-Id")
1517 .map(String::as_str),
1518 Some("from-env")
1519 );
1520 }
1521
1522 #[test]
1523 fn nvidia_nim_provider_defaults_to_catalog_endpoint_and_model() {
1524 let _lock = env_lock();
1525 let _env = EnvGuard::without_deepseek_runtime_overrides();
1526 let config = ConfigToml {
1527 provider: ProviderKind::NvidiaNim,
1528 ..ConfigToml::default()
1529 };
1530
1531 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1532
1533 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
1534 assert_eq!(resolved.base_url, DEFAULT_NVIDIA_NIM_BASE_URL);
1535 assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
1536 }
1537
1538 #[test]
1539 fn nvidia_nim_provider_uses_provider_specific_credentials() {
1540 let _lock = env_lock();
1541 let _env = EnvGuard::without_deepseek_runtime_overrides();
1542 let mut config = ConfigToml {
1543 provider: ProviderKind::NvidiaNim,
1544 ..ConfigToml::default()
1545 };
1546 config.providers.nvidia_nim.api_key = Some("nim-key".to_string());
1547 config.providers.nvidia_nim.base_url = Some("https://nim.example/v1".to_string());
1548 config.providers.nvidia_nim.model = Some("deepseek-ai/deepseek-v4-pro".to_string());
1549
1550 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1551
1552 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
1553 assert_eq!(resolved.api_key.as_deref(), Some("nim-key"));
1554 assert_eq!(resolved.base_url, "https://nim.example/v1");
1555 assert_eq!(resolved.model, "deepseek-ai/deepseek-v4-pro");
1556 }
1557
1558 #[test]
1559 fn nvidia_nim_provider_normalizes_flash_aliases() {
1560 let _lock = env_lock();
1561 let _env = EnvGuard::without_deepseek_runtime_overrides();
1562 let cli = CliRuntimeOverrides {
1563 provider: Some(ProviderKind::NvidiaNim),
1564 model: Some("deepseek-v4-flash".to_string()),
1565 ..CliRuntimeOverrides::default()
1566 };
1567
1568 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
1569
1570 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
1571 assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_FLASH_MODEL);
1572 }
1573
1574 #[test]
1575 fn nvidia_nim_provider_uses_nvidia_env_credentials() {
1576 let _lock = env_lock();
1577 let _env = EnvGuard::without_deepseek_runtime_overrides();
1578 // Safety: test-only environment mutation guarded by a module mutex.
1579 unsafe {
1580 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
1581 env::set_var("NVIDIA_API_KEY", "nim-env-key");
1582 env::set_var("NVIDIA_NIM_BASE_URL", "https://nim-env.example/v1");
1583 }
1584
1585 let config = ConfigToml::default();
1586 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1587
1588 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
1589 assert_eq!(resolved.api_key.as_deref(), Some("nim-env-key"));
1590 assert_eq!(resolved.base_url, "https://nim-env.example/v1");
1591 assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
1592 }
1593
1594 #[test]
1595 fn nvidia_nim_provider_accepts_short_nim_base_url_alias() {
1596 let _lock = env_lock();
1597 let _env = EnvGuard::without_deepseek_runtime_overrides();
1598 // Safety: test-only environment mutation guarded by a module mutex.
1599 unsafe {
1600 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
1601 env::set_var("NVIDIA_API_KEY", "nim-env-key");
1602 env::set_var("NIM_BASE_URL", "https://short-nim.example/v1");
1603 }
1604
1605 let config = ConfigToml::default();
1606 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1607
1608 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
1609 assert_eq!(resolved.base_url, "https://short-nim.example/v1");
1610 }
1611
1612 #[test]
1613 fn nvidia_nim_provider_can_fallback_to_deepseek_api_key_env() {
1614 let _lock = env_lock();
1615 let _env = EnvGuard::without_deepseek_runtime_overrides();
1616 // Safety: test-only environment mutation guarded by a module mutex.
1617 unsafe {
1618 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
1619 env::set_var("DEEPSEEK_API_KEY", "deepseek-compat-key");
1620 }
1621
1622 let config = ConfigToml::default();
1623 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1624
1625 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
1626 assert_eq!(resolved.api_key.as_deref(), Some("deepseek-compat-key"));
1627 }
1628
1629 #[test]
1630 fn list_values_redacts_root_api_key() {
1631 let config = ConfigToml {
1632 api_key: Some("sk-deepseek-secret".to_string()),
1633 ..ConfigToml::default()
1634 };
1635
1636 let values = config.list_values();
1637
1638 assert_eq!(
1639 values.get("api_key").map(String::as_str),
1640 Some("sk-d***cret")
1641 );
1642 }
1643
1644 #[test]
1645 fn list_values_fully_redacts_short_api_key() {
1646 let config = ConfigToml {
1647 api_key: Some("short-key".to_string()),
1648 ..ConfigToml::default()
1649 };
1650
1651 let values = config.list_values();
1652
1653 assert_eq!(values.get("api_key").map(String::as_str), Some("********"));
1654 }
1655
1656 #[cfg(unix)]
1657 #[test]
1658 fn save_clamps_existing_config_permissions() {
1659 use std::time::{SystemTime, UNIX_EPOCH};
1660
1661 let unique = SystemTime::now()
1662 .duration_since(UNIX_EPOCH)
1663 .expect("clock")
1664 .as_nanos();
1665 let dir = std::env::temp_dir().join(format!(
1666 "deepseek-config-perms-{}-{unique}",
1667 std::process::id()
1668 ));
1669 fs::create_dir_all(&dir).expect("mkdir");
1670 let path = dir.join(CONFIG_FILE_NAME);
1671 fs::write(&path, "api_key = \"old\"\n").expect("seed config");
1672 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod seed");
1673
1674 let store = ConfigStore {
1675 path: path.clone(),
1676 config: ConfigToml {
1677 api_key: Some("new-secret".to_string()),
1678 ..ConfigToml::default()
1679 },
1680 };
1681 store.save().expect("save");
1682
1683 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
1684 assert_eq!(mode, 0o600);
1685
1686 let _ = fs::remove_dir_all(dir);
1687 }
1688
1689 #[test]
1690 fn provider_kind_parses_openrouter_and_novita_aliases() {
1691 assert_eq!(
1692 ProviderKind::parse("openrouter"),
1693 Some(ProviderKind::Openrouter)
1694 );
1695 assert_eq!(
1696 ProviderKind::parse("OPEN_ROUTER"),
1697 Some(ProviderKind::Openrouter)
1698 );
1699 assert_eq!(ProviderKind::parse("novita"), Some(ProviderKind::Novita));
1700 assert_eq!(ProviderKind::parse("Novita"), Some(ProviderKind::Novita));
1701 assert_eq!(
1702 ProviderKind::parse("fireworks-ai"),
1703 Some(ProviderKind::Fireworks)
1704 );
1705 assert_eq!(ProviderKind::parse("sg-lang"), Some(ProviderKind::Sglang));
1706 assert_eq!(ProviderKind::parse("v-llm"), Some(ProviderKind::Vllm));
1707 assert_eq!(ProviderKind::parse("vllm"), Some(ProviderKind::Vllm));
1708 }
1709
1710 #[test]
1711 fn openrouter_provider_defaults_to_canonical_endpoint_and_model() {
1712 let _lock = env_lock();
1713 let _env = EnvGuard::without_deepseek_runtime_overrides();
1714 let config = ConfigToml {
1715 provider: ProviderKind::Openrouter,
1716 ..ConfigToml::default()
1717 };
1718
1719 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1720
1721 assert_eq!(resolved.provider, ProviderKind::Openrouter);
1722 assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL);
1723 assert_eq!(resolved.model, DEFAULT_OPENROUTER_MODEL);
1724 }
1725
1726 #[test]
1727 fn novita_provider_defaults_to_canonical_endpoint_and_model() {
1728 let _lock = env_lock();
1729 let _env = EnvGuard::without_deepseek_runtime_overrides();
1730 let config = ConfigToml {
1731 provider: ProviderKind::Novita,
1732 ..ConfigToml::default()
1733 };
1734
1735 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1736
1737 assert_eq!(resolved.provider, ProviderKind::Novita);
1738 assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL);
1739 assert_eq!(resolved.model, DEFAULT_NOVITA_MODEL);
1740 }
1741
1742 #[test]
1743 fn fireworks_provider_defaults_to_canonical_endpoint_and_model() {
1744 let _lock = env_lock();
1745 let _env = EnvGuard::without_deepseek_runtime_overrides();
1746 let config = ConfigToml {
1747 provider: ProviderKind::Fireworks,
1748 ..ConfigToml::default()
1749 };
1750
1751 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1752
1753 assert_eq!(resolved.provider, ProviderKind::Fireworks);
1754 assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL);
1755 assert_eq!(resolved.model, DEFAULT_FIREWORKS_MODEL);
1756 }
1757
1758 #[test]
1759 fn sglang_provider_defaults_to_local_endpoint_and_model() {
1760 let _lock = env_lock();
1761 let _env = EnvGuard::without_deepseek_runtime_overrides();
1762 let config = ConfigToml {
1763 provider: ProviderKind::Sglang,
1764 ..ConfigToml::default()
1765 };
1766
1767 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1768
1769 assert_eq!(resolved.provider, ProviderKind::Sglang);
1770 assert_eq!(resolved.base_url, DEFAULT_SGLANG_BASE_URL);
1771 assert_eq!(resolved.model, DEFAULT_SGLANG_MODEL);
1772 }
1773
1774 #[test]
1775 fn vllm_provider_defaults_to_local_endpoint_and_model() {
1776 let _lock = env_lock();
1777 let _env = EnvGuard::without_deepseek_runtime_overrides();
1778 let config = ConfigToml {
1779 provider: ProviderKind::Vllm,
1780 ..ConfigToml::default()
1781 };
1782
1783 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1784
1785 assert_eq!(resolved.provider, ProviderKind::Vllm);
1786 assert_eq!(resolved.base_url, DEFAULT_VLLM_BASE_URL);
1787 assert_eq!(resolved.model, DEFAULT_VLLM_MODEL);
1788 }
1789
1790 #[test]
1791 fn openrouter_env_api_key_falls_back_when_config_missing() {
1792 let _lock = env_lock();
1793 let _env = EnvGuard::without_deepseek_runtime_overrides();
1794 // Safety: test-only environment mutation guarded by a module mutex.
1795 unsafe {
1796 env::set_var("DEEPSEEK_PROVIDER", "openrouter");
1797 env::set_var("OPENROUTER_API_KEY", "or-env-key");
1798 }
1799
1800 let resolved =
1801 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
1802
1803 assert_eq!(resolved.provider, ProviderKind::Openrouter);
1804 assert_eq!(resolved.api_key.as_deref(), Some("or-env-key"));
1805 assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL);
1806 }
1807
1808 #[test]
1809 fn novita_env_api_key_falls_back_when_config_missing() {
1810 let _lock = env_lock();
1811 let _env = EnvGuard::without_deepseek_runtime_overrides();
1812 // Safety: test-only environment mutation guarded by a module mutex.
1813 unsafe {
1814 env::set_var("DEEPSEEK_PROVIDER", "novita");
1815 env::set_var("NOVITA_API_KEY", "novita-env-key");
1816 }
1817
1818 let resolved =
1819 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
1820
1821 assert_eq!(resolved.provider, ProviderKind::Novita);
1822 assert_eq!(resolved.api_key.as_deref(), Some("novita-env-key"));
1823 assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL);
1824 }
1825
1826 #[test]
1827 fn fireworks_env_api_key_falls_back_when_config_missing() {
1828 let _lock = env_lock();
1829 let _env = EnvGuard::without_deepseek_runtime_overrides();
1830 // Safety: test-only environment mutation guarded by a module mutex.
1831 unsafe {
1832 env::set_var("DEEPSEEK_PROVIDER", "fireworks");
1833 env::set_var("FIREWORKS_API_KEY", "fw-env-key");
1834 }
1835
1836 let resolved =
1837 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
1838
1839 assert_eq!(resolved.provider, ProviderKind::Fireworks);
1840 assert_eq!(resolved.api_key.as_deref(), Some("fw-env-key"));
1841 assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL);
1842 }
1843
1844 #[test]
1845 fn openrouter_provider_normalizes_flash_aliases() {
1846 let _lock = env_lock();
1847 let _env = EnvGuard::without_deepseek_runtime_overrides();
1848 let cli = CliRuntimeOverrides {
1849 provider: Some(ProviderKind::Openrouter),
1850 model: Some("deepseek-v4-flash".to_string()),
1851 ..CliRuntimeOverrides::default()
1852 };
1853
1854 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
1855
1856 assert_eq!(resolved.provider, ProviderKind::Openrouter);
1857 assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL);
1858 }
1859
1860 #[test]
1861 fn novita_provider_normalizes_flash_aliases() {
1862 let _lock = env_lock();
1863 let _env = EnvGuard::without_deepseek_runtime_overrides();
1864 let cli = CliRuntimeOverrides {
1865 provider: Some(ProviderKind::Novita),
1866 model: Some("deepseek-v4-flash".to_string()),
1867 ..CliRuntimeOverrides::default()
1868 };
1869
1870 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
1871
1872 assert_eq!(resolved.provider, ProviderKind::Novita);
1873 assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL);
1874 }
1875
1876 #[test]
1877 fn sglang_provider_normalizes_flash_aliases() {
1878 let _lock = env_lock();
1879 let _env = EnvGuard::without_deepseek_runtime_overrides();
1880 let cli = CliRuntimeOverrides {
1881 provider: Some(ProviderKind::Sglang),
1882 model: Some("deepseek-v4-flash".to_string()),
1883 ..CliRuntimeOverrides::default()
1884 };
1885
1886 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
1887
1888 assert_eq!(resolved.provider, ProviderKind::Sglang);
1889 assert_eq!(resolved.model, DEFAULT_SGLANG_FLASH_MODEL);
1890 }
1891
1892 #[test]
1893 fn vllm_provider_normalizes_flash_aliases() {
1894 let _lock = env_lock();
1895 let _env = EnvGuard::without_deepseek_runtime_overrides();
1896 let cli = CliRuntimeOverrides {
1897 provider: Some(ProviderKind::Vllm),
1898 model: Some("deepseek-v4-flash".to_string()),
1899 ..CliRuntimeOverrides::default()
1900 };
1901
1902 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
1903
1904 assert_eq!(resolved.provider, ProviderKind::Vllm);
1905 assert_eq!(resolved.model, DEFAULT_VLLM_FLASH_MODEL);
1906 }
1907
1908 #[test]
1909 fn openrouter_provider_specific_config_overrides_env() {
1910 let _lock = env_lock();
1911 let _env = EnvGuard::without_deepseek_runtime_overrides();
1912 let mut config = ConfigToml {
1913 provider: ProviderKind::Openrouter,
1914 ..ConfigToml::default()
1915 };
1916 config.providers.openrouter.api_key = Some("file-key".to_string());
1917 config.providers.openrouter.base_url = Some("https://or-mirror.example/v1".to_string());
1918
1919 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
1920
1921 assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
1922 assert_eq!(resolved.base_url, "https://or-mirror.example/v1");
1923 }
1924
1925 #[test]
1926 fn config_file_resolves_above_env_and_keyring() {
1927 use deepseek_secrets::KeyringStore;
1928 let _lock = env_lock();
1929 let _env = EnvGuard::without_deepseek_runtime_overrides();
1930 // Safety: env mutation guarded by env_lock().
1931 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
1932
1933 let store = std::sync::Arc::new(deepseek_secrets::InMemoryKeyringStore::new());
1934 store.set("deepseek", "ring-key").unwrap();
1935 let secrets = Secrets::new(store);
1936
1937 let mut config = ConfigToml::default();
1938 config.providers.deepseek.api_key = Some("file-key".to_string());
1939
1940 let resolved =
1941 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
1942 assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
1943 assert_eq!(
1944 resolved.api_key_source,
1945 Some(RuntimeApiKeySource::ConfigFile)
1946 );
1947
1948 // Safety: env mutation guarded by env_lock().
1949 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1950 }
1951
1952 #[test]
1953 fn env_resolves_when_config_file_and_keyring_empty() {
1954 let _lock = env_lock();
1955 let _env = EnvGuard::without_deepseek_runtime_overrides();
1956 // Safety: env mutation guarded by env_lock().
1957 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
1958
1959 let secrets = Secrets::new(std::sync::Arc::new(
1960 deepseek_secrets::InMemoryKeyringStore::new(),
1961 ));
1962 let config = ConfigToml::default();
1963
1964 let resolved =
1965 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
1966 assert_eq!(resolved.api_key.as_deref(), Some("env-key"));
1967 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
1968
1969 // Safety: env mutation guarded by env_lock().
1970 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
1971 }
1972
1973 #[test]
1974 fn config_file_resolves_when_keyring_and_env_empty() {
1975 let _lock = env_lock();
1976 let _env = EnvGuard::without_deepseek_runtime_overrides();
1977
1978 let secrets = Secrets::new(std::sync::Arc::new(
1979 deepseek_secrets::InMemoryKeyringStore::new(),
1980 ));
1981 let mut config = ConfigToml::default();
1982 config.providers.deepseek.api_key = Some("file-key".to_string());
1983
1984 let resolved =
1985 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
1986 assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
1987 assert_eq!(
1988 resolved.api_key_source,
1989 Some(RuntimeApiKeySource::ConfigFile)
1990 );
1991 }
1992
1993 #[test]
1994 fn keyring_resolves_when_config_file_empty_even_if_env_is_set() {
1995 use deepseek_secrets::KeyringStore;
1996 let _lock = env_lock();
1997 let _env = EnvGuard::without_deepseek_runtime_overrides();
1998 // Safety: env mutation guarded by env_lock().
1999 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key") };
2000
2001 let store = std::sync::Arc::new(deepseek_secrets::InMemoryKeyringStore::new());
2002 store.set("deepseek", "ring-key").unwrap();
2003 let secrets = Secrets::new(store);
2004
2005 let resolved = ConfigToml::default()
2006 .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
2007 assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
2008 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
2009
2010 // Safety: env mutation guarded by env_lock().
2011 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
2012 }
2013
2014 #[test]
2015 fn cli_flag_still_overrides_keyring() {
2016 use deepseek_secrets::KeyringStore;
2017 let _lock = env_lock();
2018 let _env = EnvGuard::without_deepseek_runtime_overrides();
2019
2020 let store = std::sync::Arc::new(deepseek_secrets::InMemoryKeyringStore::new());
2021 store.set("deepseek", "ring-key").unwrap();
2022 let secrets = Secrets::new(store);
2023
2024 let cli = CliRuntimeOverrides {
2025 api_key: Some("cli-key".to_string()),
2026 ..CliRuntimeOverrides::default()
2027 };
2028 let resolved = ConfigToml::default().resolve_runtime_options_with_secrets(&cli, &secrets);
2029 assert_eq!(resolved.api_key.as_deref(), Some("cli-key"));
2030 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Cli));
2031 }
2032 }
2033
2033 lines RUST