返回 DeepSeek-TUI-2026
config.rs
根目录 / crates / tui / src / config.rs
1 //! Configuration loading and defaults for DeepSeek TUI.
2
3 use std::collections::HashMap;
4 use std::fmt::Write;
5 use std::fs;
6 #[cfg(unix)]
7 use std::io::Write as _;
8 use std::path::{Path, PathBuf};
9
10 use anyhow::{Context, Result};
11 use serde::{Deserialize, Serialize};
12 use serde_json::json;
13 #[cfg(unix)]
14 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
15
16 use crate::audit::log_sensitive_event;
17 use crate::features::{Features, FeaturesToml, is_known_feature_key};
18 use crate::hooks::HooksConfig;
19
20 pub const DEFAULT_MAX_SUBAGENTS: usize = 10;
21 pub const MAX_SUBAGENTS: usize = 20;
22 pub const DEFAULT_TEXT_MODEL: &str = "deepseek-v4-pro";
23 pub const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
24 pub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
25 pub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
26 pub const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro";
27 pub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
28 pub const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
29 pub const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro";
30 pub const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
31 pub const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/v1";
32 pub const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro";
33 pub const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
34 pub const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
35 pub const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
36 pub const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1";
37 pub const DEFAULT_VLLM_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
38 pub const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
39 pub const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1";
40 pub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = "https://api.deepseeki.com";
41 const API_KEYRING_SENTINEL: &str = "__KEYRING__";
42 pub const COMMON_DEEPSEEK_MODELS: &[&str] = &[
43 "deepseek-v4-pro",
44 "deepseek-v4-flash",
45 "deepseek-ai/deepseek-v4-pro",
46 "deepseek-ai/deepseek-v4-flash",
47 "deepseek/deepseek-v4-pro",
48 "deepseek/deepseek-v4-flash",
49 ];
50
51 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
52 #[serde(rename_all = "snake_case")]
53 pub enum ApiProvider {
54 Deepseek,
55 DeepseekCN,
56 NvidiaNim,
57 Openrouter,
58 Novita,
59 Fireworks,
60 Sglang,
61 Vllm,
62 }
63
64 impl ApiProvider {
65 #[must_use]
66 pub fn parse(value: &str) -> Option<Self> {
67 match value.trim().to_ascii_lowercase().as_str() {
68 "deepseek" | "deep-seek" => Some(Self::Deepseek),
69 "deepseek-cn" | "deepseek_china" | "deepseekcn" | "deepseek-china" => {
70 Some(Self::DeepseekCN)
71 }
72 "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => Some(Self::NvidiaNim),
73 "openrouter" | "open_router" => Some(Self::Openrouter),
74 "novita" => Some(Self::Novita),
75 "fireworks" | "fireworks-ai" => Some(Self::Fireworks),
76 "sglang" | "sg-lang" => Some(Self::Sglang),
77 "vllm" | "v-llm" => Some(Self::Vllm),
78 _ => None,
79 }
80 }
81
82 #[must_use]
83 pub fn as_str(self) -> &'static str {
84 match self {
85 Self::Deepseek => "deepseek",
86 Self::DeepseekCN => "deepseek-cn",
87 Self::NvidiaNim => "nvidia-nim",
88 Self::Openrouter => "openrouter",
89 Self::Novita => "novita",
90 Self::Fireworks => "fireworks",
91 Self::Sglang => "sglang",
92 Self::Vllm => "vllm",
93 }
94 }
95
96 /// Human-friendly label for picker UIs / status chips.
97 #[must_use]
98 pub fn display_name(self) -> &'static str {
99 match self {
100 Self::Deepseek => "DeepSeek",
101 Self::DeepseekCN => "DeepSeek (中国)",
102 Self::NvidiaNim => "NVIDIA NIM",
103 Self::Openrouter => "OpenRouter",
104 Self::Novita => "Novita AI",
105 Self::Fireworks => "Fireworks AI",
106 Self::Sglang => "SGLang",
107 Self::Vllm => "vLLM",
108 }
109 }
110
111 /// All providers, in the order shown in the picker.
112 #[must_use]
113 pub fn all() -> &'static [Self] {
114 &[
115 Self::Deepseek,
116 Self::DeepseekCN,
117 Self::NvidiaNim,
118 Self::Openrouter,
119 Self::Novita,
120 Self::Fireworks,
121 Self::Sglang,
122 Self::Vllm,
123 ]
124 }
125 }
126
127 // ============================================================================
128 // Provider Capability Matrix
129 // ============================================================================
130
131 /// Known capabilities for a provider + resolved-model combination.
132 ///
133 /// Returned by [`provider_capability`] to describe what a given provider
134 /// supports for the resolved model string. All fields are derived from
135 /// static knowledge (release docs, API guides) rather than live API probes.
136 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
137 pub struct ProviderCapability {
138 /// Canonical provider identifier.
139 pub provider: ApiProvider,
140 /// Resolved model identifier that will be sent in the API payload.
141 pub resolved_model: String,
142 /// Context window in tokens (the maximum input the model can accept).
143 pub context_window: u32,
144 /// Recommended maximum output tokens (`max_tokens`) for this combo.
145 pub max_output: u32,
146 /// Whether the provider+model supports thinking/reasoning mode.
147 pub thinking_supported: bool,
148 /// Whether the provider returns prompt-cache telemetry fields.
149 pub cache_telemetry_supported: bool,
150 /// Which request-payload dialect the provider uses.
151 pub request_payload_mode: RequestPayloadMode,
152 }
153
154 /// Which request-payload dialect the provider speaks.
155 #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
156 pub enum RequestPayloadMode {
157 /// Standard OpenAI-compatible `/v1/chat/completions` payload.
158 ChatCompletions,
159 }
160
161 /// Resolve the provider capability for a given [`ApiProvider`] and resolved
162 /// model string.
163 ///
164 /// The `resolved_model` should be the final model identifier that will appear
165 /// in the API payload (after normalization / provider-specific mapping).
166 #[must_use]
167 pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {
168 let model_lower = resolved_model.to_ascii_lowercase();
169 let is_v4_pro = model_lower.contains("v4-pro") || model_lower == "deepseek-v4pro";
170 let is_v4_flash = model_lower.contains("v4-flash")
171 || model_lower == "deepseek-v4flash"
172 || model_lower == "deepseek-v4";
173
174 // Context window: V4-class models get 1M, everything else falls through
175 // to the model's own lookup or a default.
176 let context_window = if is_v4_pro || is_v4_flash {
177 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
178 } else {
179 crate::models::context_window_for_model(resolved_model)
180 .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)
181 };
182
183 // Max output tokens: DeepSeek V4 models allow 262K; others get 4096.
184 let max_output = if is_v4_pro || is_v4_flash {
185 262_144
186 } else {
187 4096
188 };
189
190 // Thinking support: V4 models support thinking on all providers, but
191 // only when the model name matches the V4 family.
192 let thinking_supported = is_v4_pro || is_v4_flash;
193
194 // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.
195 let cache_telemetry_supported = matches!(
196 provider,
197 ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim
198 );
199
200 // Request payload mode: all current providers use chat completions.
201 let request_payload_mode = RequestPayloadMode::ChatCompletions;
202
203 ProviderCapability {
204 provider,
205 resolved_model: resolved_model.to_string(),
206 context_window,
207 max_output,
208 thinking_supported,
209 cache_telemetry_supported,
210 request_payload_mode,
211 }
212 }
213
214 /// Canonicalize compact DeepSeek model aliases to stable IDs.
215 ///
216 /// Already-valid model IDs pass through unchanged. Only the compact
217 /// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.
218 #[must_use]
219 pub fn canonical_model_name(model: &str) -> Option<&'static str> {
220 match model.trim().to_ascii_lowercase().as_str() {
221 "deepseek-v4pro" => Some("deepseek-v4-pro"),
222 "deepseek-v4flash" => Some("deepseek-v4-flash"),
223 _ => None,
224 }
225 }
226
227 /// Normalize a configured/runtime model name.
228 ///
229 /// Trims whitespace, preserves caller-provided case for already-valid model
230 /// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.
231 /// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`
232 /// endpoint is the authority on valid model IDs.
233 #[must_use]
234 pub fn normalize_model_name(model: &str) -> Option<String> {
235 let trimmed = model.trim();
236 if trimmed.is_empty() {
237 return None;
238 }
239 if let Some(canonical) = canonical_model_name(trimmed) {
240 return Some(canonical.to_string());
241 }
242
243 let normalized = trimmed.to_ascii_lowercase();
244 if !normalized.starts_with("deepseek") && !normalized.contains("/deepseek") {
245 return None;
246 }
247
248 if trimmed
249 .chars()
250 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))
251 {
252 return Some(trimmed.to_string());
253 }
254
255 None
256 }
257
258 // === Types ===
259
260 /// Raw retry configuration loaded from config files.
261 #[derive(Debug, Clone, Deserialize)]
262 pub struct RetryConfig {
263 pub enabled: Option<bool>,
264 pub max_retries: Option<u32>,
265 pub initial_delay: Option<f64>,
266 pub max_delay: Option<f64>,
267 pub exponential_base: Option<f64>,
268 }
269
270 /// UI configuration loaded from config files.
271 #[derive(Debug, Clone, Deserialize, Default)]
272 pub struct TuiConfig {
273 pub alternate_screen: Option<String>,
274 pub mouse_capture: Option<bool>,
275 /// Timeout for startup terminal mode/probe calls in milliseconds.
276 /// Defaults to 500ms when omitted.
277 pub terminal_probe_timeout_ms: Option<u64>,
278 /// Ordered list of footer items the user wants visible. `None` (the field
279 /// missing from `config.toml`) means "use the built-in default order"; an
280 /// empty `Some(vec![])` means "show nothing in the footer".
281 ///
282 /// Edited interactively via `/statusline`; persisted to `tui.status_items`
283 /// in `~/.deepseek/config.toml`.
284 pub status_items: Option<Vec<StatusItem>>,
285 /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so
286 /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,
287 /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them
288 /// Cmd+click-openable. Terminals without OSC 8 support render the plain
289 /// label and ignore the escape. Defaults to `true`; set `false` for
290 /// terminals that misrender the sequence.
291 pub osc8_links: Option<bool>,
292 /// High-level notification trigger condition. When set, overrides the
293 /// `[notifications].threshold_secs` gate from the lower-level
294 /// `[notifications]` block:
295 ///
296 /// - `Always` — fire a turn-completion notification on every successful
297 /// turn regardless of duration. The configured `[notifications].method`
298 /// and `include_summary` flag are still respected.
299 /// - `Never` — suppress all turn-completion notifications.
300 /// - Unset (default) — fall back to the `[notifications]` defaults.
301 pub notification_condition: Option<NotificationCondition>,
302 }
303
304 /// High-level notification trigger override. See
305 /// [`TuiConfig::notification_condition`].
306 #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
307 #[serde(rename_all = "snake_case")]
308 pub enum NotificationCondition {
309 /// Notify on every successful turn (no duration threshold).
310 Always,
311 /// Suppress notifications entirely.
312 Never,
313 }
314
315 /// Notification delivery method (mirrors `tui::notifications::Method`).
316 #[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
317 #[serde(rename_all = "kebab-case")]
318 pub enum NotificationMethod {
319 /// Auto-detect: OSC 9 for iTerm.app / Ghostty / WezTerm; BEL on
320 /// macOS / Linux otherwise; on Windows the fallback is `Off`
321 /// because BEL maps to the system error chime there (#583).
322 #[default]
323 Auto,
324 /// OSC 9 escape.
325 Osc9,
326 /// Plain BEL character.
327 Bel,
328 /// Disable notifications.
329 Off,
330 }
331
332 fn default_threshold_secs() -> u64 {
333 30
334 }
335
336 /// Desktop-notification configuration (OSC 9 / BEL on turn completion).
337 #[derive(Debug, Clone, Deserialize, Default)]
338 pub struct NotificationsConfig {
339 /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.
340 /// `auto` resolves to OSC 9 in iTerm.app / Ghostty / WezTerm; on
341 /// macOS / Linux it falls back to BEL, and on Windows it falls
342 /// back to `Off` so the post-turn notification doesn't ring the
343 /// system error chime (#583).
344 #[serde(default)]
345 pub method: NotificationMethod,
346 /// Only notify when the turn took at least this many seconds. Default: 30.
347 #[serde(default = "default_threshold_secs")]
348 pub threshold_secs: u64,
349 /// Include a short summary (elapsed time + cost) in the notification body.
350 /// Default: `false`.
351 #[serde(default)]
352 pub include_summary: bool,
353 }
354
355 fn default_snapshots_enabled() -> bool {
356 true
357 }
358
359 fn default_snapshot_max_age_days() -> u64 {
360 crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)
361 }
362
363 /// Workspace side-git snapshot configuration (#137).
364 #[derive(Debug, Clone, Deserialize)]
365 pub struct SnapshotsConfig {
366 /// Snapshot the workspace before and after each interactive agent turn.
367 #[serde(default = "default_snapshots_enabled")]
368 pub enabled: bool,
369 /// Prune side-git snapshots older than this many days at session boot.
370 #[serde(default = "default_snapshot_max_age_days")]
371 pub max_age_days: u64,
372 }
373
374 impl Default for SnapshotsConfig {
375 fn default() -> Self {
376 Self {
377 enabled: default_snapshots_enabled(),
378 max_age_days: default_snapshot_max_age_days(),
379 }
380 }
381 }
382
383 /// User-level memory configuration (#489).
384 ///
385 /// Default is opt-in: when this table is absent or `enabled = false`, the
386 /// memory file is neither read nor written, and `# foo` quick-adds in the
387 /// composer fall through to the normal turn-submission path.
388 #[derive(Debug, Clone, Default, Deserialize)]
389 pub struct MemoryConfig {
390 /// When `true`, load the user memory file at `Config::memory_path()`
391 /// into the system prompt as a `<user_memory>` block, and intercept
392 /// `# foo` typed in the composer to append to that file. Default `false`.
393 #[serde(default)]
394 pub enabled: Option<bool>,
395 }
396
397 impl SnapshotsConfig {
398 #[must_use]
399 pub fn max_age(&self) -> std::time::Duration {
400 std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))
401 }
402 }
403
404 /// One configurable footer item.
405 ///
406 /// Order in the user's `Vec<StatusItem>` is preserved: items in the left
407 /// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;
408 /// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`, `Cache`,
409 /// `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) likewise
410 /// honour ordering inside their cluster. The split between left and right is
411 /// deliberate — left holds steady identity (mode/model/cost), right holds
412 /// transient signals — so we route each variant to the correct side rather
413 /// than letting users reorder across the spacer.
414 ///
415 /// Variants without a current data source (`RateLimit`, `LastToolElapsed`)
416 /// are intentionally exposed today so the picker is forward-compatible; they
417 /// render empty until the supporting fields land. Empty spans don't take
418 /// up footer width, so the user sees no visual artifact.
419 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
420 #[serde(rename_all = "snake_case")]
421 pub enum StatusItem {
422 /// "agent" / "yolo" / "plan" chip.
423 Mode,
424 /// Model identifier (e.g. `deepseek-v4-pro`).
425 Model,
426 /// Session cost in the configured display currency.
427 Cost,
428 /// Activity label: "ready" / "draft" / "working".
429 Status,
430 /// Coherence intervention label: "refreshing context" / "verifying" / "resetting plan".
431 Coherence,
432 /// Sub-agent count chip ("3 agents").
433 Agents,
434 /// Reasoning-replay token count ("rsn 12.3k").
435 ReasoningReplay,
436 /// Cache hit rate ("cache 73%").
437 Cache,
438 /// Context-window utilisation percent ("48%").
439 ContextPercent,
440 /// Current git branch name (placeholder until wired).
441 GitBranch,
442 /// Elapsed time of the most recent tool call (placeholder until wired).
443 LastToolElapsed,
444 /// Remaining rate-limit budget (placeholder until wired).
445 RateLimit,
446 }
447
448 impl StatusItem {
449 /// Default footer composition matching v0.6.6 behaviour exactly. Used when
450 /// `tui.status_items` is missing from `config.toml` so upgraders see the
451 /// same footer they had before.
452 #[must_use]
453 pub fn default_footer() -> Vec<StatusItem> {
454 vec![
455 StatusItem::Mode,
456 StatusItem::Model,
457 StatusItem::Cost,
458 StatusItem::Status,
459 StatusItem::Coherence,
460 StatusItem::Agents,
461 StatusItem::ReasoningReplay,
462 StatusItem::Cache,
463 ]
464 }
465
466 /// Stable canonical name used in TOML and the picker label.
467 #[must_use]
468 pub fn key(self) -> &'static str {
469 match self {
470 StatusItem::Mode => "mode",
471 StatusItem::Model => "model",
472 StatusItem::Cost => "cost",
473 StatusItem::Status => "status",
474 StatusItem::Coherence => "coherence",
475 StatusItem::Agents => "agents",
476 StatusItem::ReasoningReplay => "reasoning_replay",
477 StatusItem::Cache => "cache",
478 StatusItem::ContextPercent => "context_percent",
479 StatusItem::GitBranch => "git_branch",
480 StatusItem::LastToolElapsed => "last_tool_elapsed",
481 StatusItem::RateLimit => "rate_limit",
482 }
483 }
484
485 /// Human-readable label for the picker.
486 #[must_use]
487 pub fn label(self) -> &'static str {
488 match self {
489 StatusItem::Mode => "Mode",
490 StatusItem::Model => "Model",
491 StatusItem::Cost => "Session cost",
492 StatusItem::Status => "Activity (ready/draft/working)",
493 StatusItem::Coherence => "Coherence interventions",
494 StatusItem::Agents => "Sub-agents in flight",
495 StatusItem::ReasoningReplay => "Reasoning replay tokens",
496 StatusItem::Cache => "Prompt cache hit rate",
497 StatusItem::ContextPercent => "Context window %",
498 StatusItem::GitBranch => "Git branch",
499 StatusItem::LastToolElapsed => "Last tool elapsed",
500 StatusItem::RateLimit => "Rate-limit remaining",
501 }
502 }
503
504 /// One-line hint shown beside the label so the user knows what each item
505 /// surfaces without having to toggle it on first.
506 #[must_use]
507 pub fn hint(self) -> &'static str {
508 match self {
509 StatusItem::Mode => "agent · yolo · plan",
510 StatusItem::Model => "the model id you'll send to",
511 StatusItem::Cost => "running total for this session",
512 StatusItem::Status => "what the agent is doing right now",
513 StatusItem::Coherence => "shown only when the engine intervenes",
514 StatusItem::Agents => "agents or RLM work in progress",
515 StatusItem::ReasoningReplay => "thinking tokens replayed each turn",
516 StatusItem::Cache => "% of prompt served from cache",
517 StatusItem::ContextPercent => "tokens used / model context window",
518 StatusItem::GitBranch => "current branch (placeholder)",
519 StatusItem::LastToolElapsed => "ms of the most recent tool call (placeholder)",
520 StatusItem::RateLimit => "remaining requests in the budget (placeholder)",
521 }
522 }
523
524 /// Every variant in display order — used by the picker to enumerate rows.
525 #[must_use]
526 pub fn all() -> &'static [StatusItem] {
527 &[
528 StatusItem::Mode,
529 StatusItem::Model,
530 StatusItem::Cost,
531 StatusItem::Status,
532 StatusItem::Coherence,
533 StatusItem::Agents,
534 StatusItem::ReasoningReplay,
535 StatusItem::Cache,
536 StatusItem::ContextPercent,
537 StatusItem::GitBranch,
538 StatusItem::LastToolElapsed,
539 StatusItem::RateLimit,
540 ]
541 }
542
543 /// Items that belong in the footer's left cluster (steady identity).
544 #[must_use]
545 pub fn is_left_cluster(self) -> bool {
546 matches!(
547 self,
548 StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status
549 )
550 }
551 }
552
553 /// Resolved retry policy with defaults applied.
554 #[derive(Debug, Clone)]
555 pub struct RetryPolicy {
556 pub enabled: bool,
557 pub max_retries: u32,
558 pub initial_delay: f64,
559 pub max_delay: f64,
560 pub exponential_base: f64,
561 }
562
563 /// Capacity-controller config loaded from config files/environment.
564 #[derive(Debug, Clone, Deserialize)]
565 pub struct CapacityConfig {
566 pub enabled: Option<bool>,
567 pub low_risk_max: Option<f64>,
568 pub medium_risk_max: Option<f64>,
569 pub severe_min_slack: Option<f64>,
570 pub severe_violation_ratio: Option<f64>,
571 pub refresh_cooldown_turns: Option<u64>,
572 pub replan_cooldown_turns: Option<u64>,
573 pub max_replay_per_turn: Option<usize>,
574 pub min_turns_before_guardrail: Option<u64>,
575 pub profile_window: Option<usize>,
576 pub deepseek_v3_2_chat_prior: Option<f64>,
577 pub deepseek_v3_2_reasoner_prior: Option<f64>,
578 pub deepseek_v4_pro_prior: Option<f64>,
579 pub deepseek_v4_flash_prior: Option<f64>,
580 pub fallback_default_prior: Option<f64>,
581 }
582
583 impl RetryPolicy {
584 /// Compute the backoff delay for a retry attempt.
585 #[must_use]
586 #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop
587 pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {
588 let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);
589 let delay = self.initial_delay * self.exponential_base.powi(exponent);
590 let delay = delay.min(self.max_delay);
591 // Clamp to a sane range to guard against NaN/negative from misconfigured values
592 let delay = delay.clamp(0.0, 300.0);
593 std::time::Duration::from_secs_f64(delay)
594 }
595 }
596
597 /// Context management configuration (append-only layered context with Flash seams).
598 #[derive(Debug, Clone, Deserialize, Default)]
599 pub struct ContextConfig {
600 /// Master enable for layered context management. Default: false while
601 /// v0.7.5 audits V4 prefix-cache behavior.
602 #[serde(default)]
603 pub enabled: Option<bool>,
604 /// Verbatim window: last N turns never summarized. Default: 16.
605 #[serde(default)]
606 pub verbatim_window_turns: Option<usize>,
607 /// Soft seam thresholds based on the active request input estimate.
608 #[serde(default)]
609 pub l1_threshold: Option<usize>,
610 #[serde(default)]
611 pub l2_threshold: Option<usize>,
612 #[serde(default)]
613 pub l3_threshold: Option<usize>,
614 /// Hard cycle boundary. Default: 768000.
615 #[serde(default)]
616 pub cycle_threshold: Option<usize>,
617 /// Model used for seam/briefing work. Default: "deepseek-v4-flash".
618 #[serde(default)]
619 pub seam_model: Option<String>,
620 /// Per-model threshold overrides.
621 #[serde(default)]
622 pub per_model: Option<HashMap<String, PerModelContextConfig>>,
623 }
624
625 /// Sub-agent model overrides. Keys in `models` can be role names (`worker`,
626 /// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,
627 /// `review`, `custom`). Per-call explicit model choices still win.
628 #[derive(Debug, Clone, Deserialize, Default)]
629 pub struct SubagentsConfig {
630 #[serde(default)]
631 pub default_model: Option<String>,
632 #[serde(default)]
633 pub worker_model: Option<String>,
634 #[serde(default)]
635 pub explorer_model: Option<String>,
636 #[serde(default)]
637 pub awaiter_model: Option<String>,
638 #[serde(default)]
639 pub review_model: Option<String>,
640 #[serde(default)]
641 pub custom_model: Option<String>,
642 #[serde(default)]
643 pub models: Option<HashMap<String, String>>,
644 /// Maximum concurrent sub-agents. Overrides the top-level max_subagents
645 /// setting. Clamped to [1, MAX_SUBAGENTS].
646 #[serde(default)]
647 pub max_concurrent: Option<usize>,
648 }
649
650 /// Per-model context tuning.
651 #[derive(Debug, Clone, Deserialize)]
652 pub struct PerModelContextConfig {
653 #[serde(default)]
654 pub l1_threshold: Option<usize>,
655 #[serde(default)]
656 pub l2_threshold: Option<usize>,
657 #[serde(default)]
658 pub l3_threshold: Option<usize>,
659 #[serde(default)]
660 pub cycle_threshold: Option<usize>,
661 }
662
663 /// Resolved CLI configuration, including defaults and environment overrides.
664 #[derive(Debug, Clone, Default, Deserialize)]
665 pub struct Config {
666 pub provider: Option<String>,
667 pub api_key: Option<String>,
668 pub base_url: Option<String>,
669 /// Optional extra HTTP headers sent to model API requests.
670 pub http_headers: Option<HashMap<String, String>>,
671 pub default_text_model: Option<String>,
672 /// DeepSeek reasoning-effort tier: `"off" | "low" | "medium" | "high" | "max"`.
673 /// Defaults to `"max"` at runtime if unset.
674 pub reasoning_effort: Option<String>,
675 pub tools_file: Option<String>,
676 pub skills_dir: Option<String>,
677 pub mcp_config_path: Option<String>,
678 pub notes_path: Option<String>,
679 pub memory_path: Option<String>,
680 /// When true, set `tool_choice: "required"` in all API requests so the
681 /// model MUST call a tool on every step (V4 strict tool-following mode).
682 pub strict_tool_mode: Option<bool>,
683 /// Additional system-prompt sources concatenated in declared order
684 /// (#454). Paths are expanded via `expand_path` so `~` and env
685 /// vars work. Project config overrides user config (replace, not
686 /// merge) — that's the typical "this repo needs X plus everything
687 /// I already have" pattern, where users put `~/global.md` in the
688 /// project's array if they want both. Each file is loaded, capped
689 /// at 100 KiB, and skipped (with a warning) on read errors so a
690 /// missing optional file doesn't fail the launch.
691 pub instructions: Option<Vec<String>>,
692 pub allow_shell: Option<bool>,
693 pub approval_policy: Option<String>,
694 pub sandbox_mode: Option<String>,
695 /// External sandbox backend: `"none"` or `"opensandbox"`.
696 /// When set, exec_shell routes commands through the backend's HTTP API
697 /// instead of spawning a local process.
698 pub sandbox_backend: Option<String>,
699 /// Base URL for the external sandbox backend (default: `"http://localhost:8080"`).
700 pub sandbox_url: Option<String>,
701 /// Optional API key for the external sandbox backend (sent as Bearer token).
702 pub sandbox_api_key: Option<String>,
703 pub managed_config_path: Option<String>,
704 pub requirements_path: Option<String>,
705 pub max_subagents: Option<usize>,
706 pub retry: Option<RetryConfig>,
707 pub capacity: Option<CapacityConfig>,
708 pub features: Option<FeaturesToml>,
709
710 /// TUI configuration (alternate screen, etc.)
711 pub tui: Option<TuiConfig>,
712
713 /// Lifecycle hooks configuration
714 #[serde(default)]
715 pub hooks: Option<HooksConfig>,
716
717 /// Provider-specific credentials and defaults shared with the `deepseek` facade.
718 #[serde(default)]
719 pub providers: Option<ProvidersConfig>,
720
721 /// Desktop notification settings (OSC 9 / BEL on long turn completion).
722 #[serde(default)]
723 pub notifications: Option<NotificationsConfig>,
724
725 /// Per-domain network policy (#135). When absent, network tools fall back
726 /// to a permissive default that mirrors pre-v0.7.0 behavior.
727 #[serde(default)]
728 pub network: Option<NetworkPolicyToml>,
729
730 /// Community skill installer settings (#140). When absent, installer
731 /// commands fall back to the bundled defaults
732 /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +
733 /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).
734 #[serde(default)]
735 pub skills: Option<SkillsConfig>,
736
737 /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day
738 /// retention when the table is absent.
739 #[serde(default)]
740 pub snapshots: Option<SnapshotsConfig>,
741
742 /// User-level memory file (#489). Default behaviour is **opt-in**:
743 /// loading + injection happens only when `[memory] enabled = true` or
744 /// `DEEPSEEK_MEMORY=on` is set.
745 #[serde(default)]
746 pub memory: Option<MemoryConfig>,
747
748 /// Post-edit LSP diagnostics injection (#136). When absent, the engine
749 /// applies the defaults documented in [`LspConfigToml`].
750 #[serde(default)]
751 pub lsp: Option<LspConfigToml>,
752
753 /// Append-only layered context management with Flash seam manager (#159).
754 #[serde(default)]
755 pub context: ContextConfig,
756
757 /// Sub-agent model overrides.
758 #[serde(default)]
759 pub subagents: Option<SubagentsConfig>,
760
761 /// Runtime API server tuning (`deepseek serve --http`). Currently only
762 /// hosts the CORS allow-list extension (whalescale#255 / #561). When the
763 /// table is absent, the daemon ships with localhost:3000 / localhost:1420
764 /// / tauri://localhost as the only allowed dev origins.
765 #[serde(default)]
766 pub runtime_api: Option<RuntimeApiConfig>,
767
768 /// Workshop / large-tool-output routing (#548). When absent, the global
769 /// default threshold of 4 096 tokens applies and routing is active.
770 #[serde(default)]
771 pub workshop: Option<crate::tools::large_output_router::WorkshopConfig>,
772 }
773
774 /// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.
775 #[derive(Debug, Clone, Deserialize, Default)]
776 pub struct RuntimeApiConfig {
777 /// Additional CORS origins to allow on top of the built-in defaults
778 /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,
779 /// `tauri://localhost`). Useful when developing a UI against a non-default
780 /// dev server port (e.g. Vite's default `:5173`).
781 ///
782 /// Resolution order (highest priority first): `--cors-origin` CLI flag,
783 /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.
784 #[serde(default)]
785 pub cors_origins: Option<Vec<String>>,
786 }
787
788 /// `[skills]` table — knobs for the community-skill installer.
789 #[derive(Debug, Clone, Deserialize, Default)]
790 pub struct SkillsConfig {
791 /// Curated registry index. `/skill install <name>` looks up the spec here.
792 /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].
793 #[serde(default)]
794 pub registry_url: Option<String>,
795 /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed
796 /// this limit are rejected during validation. Defaults to 5 MiB.
797 #[serde(default)]
798 pub max_install_size_bytes: Option<u64>,
799 }
800
801 impl SkillsConfig {
802 /// Resolve the registry URL with the bundled default.
803 #[must_use]
804 pub fn registry_url(&self) -> String {
805 self.registry_url
806 .clone()
807 .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())
808 }
809
810 /// Resolve the max install size with the bundled default.
811 #[must_use]
812 pub fn max_install_size_bytes(&self) -> u64 {
813 self.max_install_size_bytes
814 .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)
815 }
816 }
817
818 /// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live
819 /// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]
820 /// without reaching into the workspace config crate. See `config.example.toml`
821 /// for documentation.
822 #[derive(Debug, Clone, Deserialize)]
823 pub struct NetworkPolicyToml {
824 /// Decision for hosts that are not in `allow` or `deny`. One of
825 /// `"allow" | "deny" | "prompt"`. Defaults to `"prompt"`.
826 #[serde(default = "default_network_decision")]
827 pub default: String,
828 /// Hosts that are always allowed. Subdomain rules: a leading dot
829 /// (`.example.com`) matches subdomains but not the apex.
830 #[serde(default)]
831 pub allow: Vec<String>,
832 /// Hosts that are always denied. Deny entries win over allow entries.
833 #[serde(default)]
834 pub deny: Vec<String>,
835 /// Whether to record one audit-log line per outbound network call.
836 #[serde(default = "default_network_audit")]
837 pub audit: bool,
838 }
839
840 fn default_network_decision() -> String {
841 "prompt".to_string()
842 }
843
844 fn default_network_audit() -> bool {
845 true
846 }
847
848 impl Default for NetworkPolicyToml {
849 fn default() -> Self {
850 Self {
851 default: default_network_decision(),
852 allow: Vec::new(),
853 deny: Vec::new(),
854 audit: default_network_audit(),
855 }
856 }
857 }
858
859 impl NetworkPolicyToml {
860 /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the
861 /// on-disk schema.
862 #[must_use]
863 pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {
864 crate::network_policy::NetworkPolicy {
865 default: crate::network_policy::Decision::parse(&self.default).into(),
866 allow: self.allow,
867 deny: self.deny,
868 audit: self.audit,
869 }
870 }
871 }
872
873 /// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in
874 /// `config.example.toml`. When omitted, defaults from `LspConfig::default()`
875 /// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).
876 #[derive(Debug, Clone, Deserialize, Default)]
877 pub struct LspConfigToml {
878 /// Master switch. Defaults to `true`.
879 #[serde(default)]
880 pub enabled: Option<bool>,
881 /// How long to wait for the LSP server to publish diagnostics after a
882 /// `didOpen`/`didChange`. Defaults to 5000 ms.
883 #[serde(default)]
884 pub poll_after_edit_ms: Option<u64>,
885 /// Cap on diagnostics surfaced per file. Defaults to 20.
886 #[serde(default)]
887 pub max_diagnostics_per_file: Option<usize>,
888 /// Whether to surface warnings in addition to errors. Defaults to `false`.
889 #[serde(default)]
890 pub include_warnings: Option<bool>,
891 /// Optional override for the `Language -> [cmd, ...args]` table. Keys
892 /// are language slugs (`"rust"`, `"go"`, etc.).
893 #[serde(default)]
894 pub servers: Option<HashMap<String, Vec<String>>>,
895 }
896
897 impl LspConfigToml {
898 /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,
899 /// falling back to defaults for any unset fields.
900 #[must_use]
901 pub fn into_runtime(self) -> crate::lsp::LspConfig {
902 let defaults = crate::lsp::LspConfig::default();
903 crate::lsp::LspConfig {
904 enabled: self.enabled.unwrap_or(defaults.enabled),
905 poll_after_edit_ms: self
906 .poll_after_edit_ms
907 .unwrap_or(defaults.poll_after_edit_ms),
908 max_diagnostics_per_file: self
909 .max_diagnostics_per_file
910 .unwrap_or(defaults.max_diagnostics_per_file),
911 include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),
912 servers: self.servers.unwrap_or_default(),
913 }
914 }
915 }
916
917 #[derive(Debug, Clone, Default, Deserialize)]
918 pub struct ProviderConfig {
919 pub api_key: Option<String>,
920 pub base_url: Option<String>,
921 pub model: Option<String>,
922 pub http_headers: Option<HashMap<String, String>>,
923 }
924
925 #[derive(Debug, Clone, Default, Deserialize)]
926 pub struct ProvidersConfig {
927 #[serde(default)]
928 pub deepseek: ProviderConfig,
929 #[serde(default)]
930 pub deepseek_cn: ProviderConfig,
931 #[serde(default)]
932 pub nvidia_nim: ProviderConfig,
933 #[serde(default)]
934 pub openrouter: ProviderConfig,
935 #[serde(default)]
936 pub novita: ProviderConfig,
937 #[serde(default)]
938 pub fireworks: ProviderConfig,
939 #[serde(default)]
940 pub sglang: ProviderConfig,
941 #[serde(default)]
942 pub vllm: ProviderConfig,
943 }
944
945 #[derive(Debug, Clone, Deserialize, Default)]
946 struct ConfigFile {
947 #[serde(flatten)]
948 base: Config,
949 profiles: Option<HashMap<String, Config>>,
950 }
951
952 #[derive(Debug, Clone, Deserialize, Default)]
953 struct RequirementsFile {
954 #[serde(default)]
955 allowed_approval_policies: Vec<String>,
956 #[serde(default)]
957 allowed_sandbox_modes: Vec<String>,
958 }
959
960 // === Config Loading ===
961
962 impl Config {
963 /// Load configuration from disk and merge with environment overrides.
964 ///
965 /// # Examples
966 ///
967 /// ```ignore
968 /// # use crate::config::Config;
969 /// let config = Config::load(None, None)?;
970 /// # Ok::<(), anyhow::Error>(())
971 /// ```
972 pub fn load(path: Option<PathBuf>, profile: Option<&str>) -> Result<Self> {
973 let path = resolve_load_config_path(path);
974 let mut config = if let Some(path) = path.as_ref() {
975 if path.exists() {
976 let contents = fs::read_to_string(path)
977 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
978 let parsed: ConfigFile = toml::from_str(&contents)
979 .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
980 apply_profile(parsed, profile)?
981 } else {
982 Config::default()
983 }
984 } else {
985 Config::default()
986 };
987
988 apply_env_overrides(&mut config);
989 apply_managed_overrides(&mut config)?;
990 apply_requirements(&mut config)?;
991 normalize_model_config(&mut config);
992 config.validate()?;
993 Ok(config)
994 }
995
996 /// Validate that critical config fields are present.
997 pub fn validate(&self) -> Result<()> {
998 if let Some(provider) = self.provider.as_deref()
999 && ApiProvider::parse(provider).is_none()
1000 {
1001 anyhow::bail!(
1002 "Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openrouter, novita, fireworks, sglang, or vllm."
1003 );
1004 }
1005 if let Some(ref key) = self.api_key
1006 && key.trim().is_empty()
1007 {
1008 anyhow::bail!("api_key cannot be empty string");
1009 }
1010 if let Some(features) = &self.features {
1011 for key in features.entries.keys() {
1012 if !is_known_feature_key(key) {
1013 anyhow::bail!("Unknown feature flag: {key}");
1014 }
1015 }
1016 }
1017 if let Some(model) = self.default_text_model.as_deref()
1018 && !model.trim().eq_ignore_ascii_case("auto")
1019 && normalize_model_name(model).is_none()
1020 {
1021 anyhow::bail!(
1022 "Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro)."
1023 );
1024 }
1025 if let Some(policy) = self.approval_policy.as_deref() {
1026 let normalized = policy.trim().to_ascii_lowercase();
1027 if !matches!(
1028 normalized.as_str(),
1029 "on-request" | "untrusted" | "never" | "auto" | "suggest"
1030 ) {
1031 anyhow::bail!(
1032 "Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest."
1033 );
1034 }
1035 }
1036 if let Some(mode) = self.sandbox_mode.as_deref() {
1037 let normalized = mode.trim().to_ascii_lowercase();
1038 if !matches!(
1039 normalized.as_str(),
1040 "read-only" | "workspace-write" | "danger-full-access" | "external-sandbox"
1041 ) {
1042 anyhow::bail!(
1043 "Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox."
1044 );
1045 }
1046 }
1047 if let Some(tui) = &self.tui
1048 && let Some(mode) = tui.alternate_screen.as_deref()
1049 {
1050 let mode = mode.to_ascii_lowercase();
1051 if !matches!(mode.as_str(), "auto" | "always" | "never") {
1052 anyhow::bail!(
1053 "Invalid tui.alternate_screen '{mode}': expected auto, always, or never."
1054 );
1055 }
1056 }
1057 if let Some(capacity) = &self.capacity {
1058 if let Some(v) = capacity.low_risk_max
1059 && !(0.0..=1.0).contains(&v)
1060 {
1061 anyhow::bail!(
1062 "Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0]."
1063 );
1064 }
1065 if let Some(v) = capacity.medium_risk_max
1066 && !(0.0..=1.0).contains(&v)
1067 {
1068 anyhow::bail!(
1069 "Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0]."
1070 );
1071 }
1072 if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)
1073 && low > medium
1074 {
1075 anyhow::bail!(
1076 "Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium})."
1077 );
1078 }
1079 if let Some(v) = capacity.severe_violation_ratio
1080 && !(0.0..=1.0).contains(&v)
1081 {
1082 anyhow::bail!(
1083 "Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0]."
1084 );
1085 }
1086 }
1087 Ok(())
1088 }
1089
1090 #[must_use]
1091 pub fn api_provider(&self) -> ApiProvider {
1092 self.provider
1093 .as_deref()
1094 .and_then(ApiProvider::parse)
1095 .unwrap_or_else(|| {
1096 self.base_url
1097 .as_deref()
1098 .filter(|base| base.contains("integrate.api.nvidia.com"))
1099 .map(|_| ApiProvider::NvidiaNim)
1100 .or_else(|| {
1101 self.base_url
1102 .as_deref()
1103 .filter(|base| base.contains("api.deepseeki.com"))
1104 .map(|_| ApiProvider::DeepseekCN)
1105 })
1106 .unwrap_or(ApiProvider::Deepseek)
1107 })
1108 }
1109
1110 pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {
1111 let providers = self.providers.as_ref()?;
1112 Some(match provider {
1113 ApiProvider::Deepseek => &providers.deepseek,
1114 ApiProvider::DeepseekCN => &providers.deepseek_cn,
1115 ApiProvider::NvidiaNim => &providers.nvidia_nim,
1116 ApiProvider::Openrouter => &providers.openrouter,
1117 ApiProvider::Novita => &providers.novita,
1118 ApiProvider::Fireworks => &providers.fireworks,
1119 ApiProvider::Sglang => &providers.sglang,
1120 ApiProvider::Vllm => &providers.vllm,
1121 })
1122 }
1123
1124 pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {
1125 self.provider_config_for(self.api_provider())
1126 }
1127
1128 #[must_use]
1129 pub fn http_headers(&self) -> HashMap<String, String> {
1130 let mut headers = self.http_headers.clone().unwrap_or_default();
1131 if let Some(provider_headers) = self
1132 .provider_config()
1133 .and_then(|provider| provider.http_headers.as_ref())
1134 {
1135 headers.extend(provider_headers.clone());
1136 }
1137 headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
1138 headers
1139 }
1140
1141 #[must_use]
1142 pub fn default_model(&self) -> String {
1143 let provider = self.api_provider();
1144 if let Some(model) = self
1145 .provider_config()
1146 .and_then(|provider| provider.model.as_deref())
1147 && let Some(normalized) = normalize_model_for_provider(provider, model)
1148 {
1149 return normalized;
1150 }
1151 if let Some(model) = self.default_text_model.as_deref()
1152 && model.trim().eq_ignore_ascii_case("auto")
1153 {
1154 return "auto".to_string();
1155 }
1156 if let Some(model) = self.default_text_model.as_deref()
1157 && let Some(normalized) = normalize_model_name(model)
1158 {
1159 return model_for_provider(provider, normalized);
1160 }
1161
1162 match provider {
1163 ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,
1164 ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
1165 ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,
1166 ApiProvider::Novita => DEFAULT_NOVITA_MODEL,
1167 ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,
1168 ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,
1169 ApiProvider::Vllm => DEFAULT_VLLM_MODEL,
1170 }
1171 .to_string()
1172 }
1173
1174 /// Return the configured API base URL (normalized).
1175 #[must_use]
1176 pub fn deepseek_base_url(&self) -> String {
1177 let provider = self.api_provider();
1178 let provider_base = self
1179 .provider_config_for(provider)
1180 .and_then(|provider| provider.base_url.clone());
1181 // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a
1182 // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita
1183 // were added in v0.6.7 and require explicit `[providers.<name>]`
1184 // entries or the corresponding `*_BASE_URL` env var.
1185 let root_base = match provider {
1186 ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),
1187 ApiProvider::NvidiaNim => self
1188 .base_url
1189 .as_ref()
1190 .filter(|base| base.contains("integrate.api.nvidia.com"))
1191 .cloned(),
1192 ApiProvider::Openrouter
1193 | ApiProvider::Novita
1194 | ApiProvider::Fireworks
1195 | ApiProvider::Sglang
1196 | ApiProvider::Vllm => None,
1197 };
1198 let base = provider_base.or(root_base).unwrap_or_else(|| {
1199 match provider {
1200 ApiProvider::Deepseek => "https://api.deepseek.com",
1201 ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,
1202 ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
1203 ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
1204 ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,
1205 ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
1206 ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,
1207 ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,
1208 }
1209 .to_string()
1210 });
1211 normalize_base_url(&base)
1212 }
1213
1214 /// Read the API key.
1215 ///
1216 /// Precedence: **explicit in-memory override → provider/root config
1217 /// → environment**.
1218 ///
1219 /// The in-memory `self.api_key` override is only honored when the user
1220 /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`
1221 /// placeholder, not empty whitespace).
1222 pub fn deepseek_api_key(&self) -> Result<String> {
1223 let provider = self.api_provider();
1224 let slot = match provider {
1225 ApiProvider::Deepseek | ApiProvider::DeepseekCN => "deepseek",
1226 ApiProvider::NvidiaNim => "nvidia-nim",
1227 ApiProvider::Openrouter => "openrouter",
1228 ApiProvider::Novita => "novita",
1229 ApiProvider::Fireworks => "fireworks",
1230 ApiProvider::Sglang => "sglang",
1231 ApiProvider::Vllm => "vllm",
1232 };
1233
1234 // 0. Explicit in-memory override (set by onboarding / provider
1235 // picker). Wins so a freshly-entered key takes effect immediately.
1236 if let Some(configured) = self.api_key.as_ref()
1237 && !configured.trim().is_empty()
1238 && configured != API_KEYRING_SENTINEL
1239 {
1240 return Ok(configured.clone());
1241 }
1242
1243 // 1. Config file (provider-scoped slot). This intentionally wins
1244 // over ambient env so `deepseek auth set` fixes stale shell exports.
1245 if let Some(configured) = self
1246 .provider_config_for(provider)
1247 .and_then(|provider| provider.api_key.clone())
1248 && !configured.trim().is_empty()
1249 {
1250 return Ok(configured);
1251 }
1252
1253 // 2. Environment variables. Do not query platform credential stores
1254 // here; routine startup and doctor checks must stay prompt-free.
1255 if let Some(value) = deepseek_secrets::env_for(slot)
1256 && !value.trim().is_empty()
1257 {
1258 return Ok(value);
1259 }
1260
1261 match provider {
1262 ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(
1263 "DeepSeek API key not found.\n\
1264 \n\
1265 1. Get a key: https://platform.deepseek.com/api_keys\n\
1266 2. Save it (works in every folder, no OS prompts):\n\
1267 deepseek auth set --provider deepseek\n\
1268 \n\
1269 Alternatives:\n\
1270 • export DEEPSEEK_API_KEY=<your-key> (current shell only;\n\
1271 also note: zsh users — exports in ~/.zshrc only reach interactive\n\
1272 shells, prefer ~/.zshenv for everything)\n\
1273 • api_key = \"<your-key>\" in ~/.deepseek/config.toml"
1274 ),
1275 ApiProvider::NvidiaNim => anyhow::bail!(
1276 "NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \
1277 set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \
1278 with provider = \"nvidia-nim\"."
1279 ),
1280 ApiProvider::Openrouter => anyhow::bail!(
1281 "OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \
1282 set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml."
1283 ),
1284 ApiProvider::Novita => anyhow::bail!(
1285 "Novita API key not found. Run 'deepseek auth set --provider novita', \
1286 set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml."
1287 ),
1288 ApiProvider::Fireworks => anyhow::bail!(
1289 "Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \
1290 set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml."
1291 ),
1292 // Self-hosted SGLang deployments commonly run without auth on
1293 // localhost. Return an empty key and let the client omit the
1294 // Authorization header.
1295 ApiProvider::Sglang | ApiProvider::Vllm => Ok(String::new()),
1296 }
1297 }
1298
1299 /// Resolve the skills directory path.
1300 #[must_use]
1301 pub fn skills_dir(&self) -> PathBuf {
1302 self.skills_dir
1303 .as_deref()
1304 .map(expand_path)
1305 .or_else(default_skills_dir)
1306 .unwrap_or_else(|| PathBuf::from("./skills"))
1307 }
1308
1309 /// Resolve the MCP config path.
1310 #[must_use]
1311 pub fn mcp_config_path(&self) -> PathBuf {
1312 self.mcp_config_path
1313 .as_deref()
1314 .map(expand_path)
1315 .or_else(default_mcp_config_path)
1316 .unwrap_or_else(|| PathBuf::from("./mcp.json"))
1317 }
1318
1319 /// Resolve the notes file path.
1320 #[must_use]
1321 pub fn notes_path(&self) -> PathBuf {
1322 self.notes_path
1323 .as_deref()
1324 .map(expand_path)
1325 .or_else(default_notes_path)
1326 .unwrap_or_else(|| PathBuf::from("./notes.txt"))
1327 }
1328
1329 /// Resolve the memory file path.
1330 #[must_use]
1331 pub fn memory_path(&self) -> PathBuf {
1332 self.memory_path
1333 .as_deref()
1334 .map(expand_path)
1335 .or_else(default_memory_path)
1336 .unwrap_or_else(|| PathBuf::from("./memory.md"))
1337 }
1338
1339 /// Resolve the configured `instructions = [...]` array (#454)
1340 /// to absolute paths, in declared order. Empty when unset or
1341 /// when every entry is empty after trimming. Each entry runs
1342 /// through `expand_path` so `~` and env vars are honoured.
1343 #[must_use]
1344 pub fn instructions_paths(&self) -> Vec<PathBuf> {
1345 self.instructions
1346 .as_deref()
1347 .unwrap_or(&[])
1348 .iter()
1349 .map(String::as_str)
1350 .map(str::trim)
1351 .filter(|s| !s.is_empty())
1352 .map(expand_path)
1353 .collect()
1354 }
1355
1356 /// Whether the user-memory feature is enabled. The default is **off**
1357 /// to preserve zero-overhead behavior for users who haven't opted in.
1358 /// Flips to `true` when `[memory] enabled = true` in `config.toml` or
1359 /// `DEEPSEEK_MEMORY=on` is set in the environment.
1360 #[must_use]
1361 pub fn memory_enabled(&self) -> bool {
1362 self.memory
1363 .as_ref()
1364 .and_then(|m| m.enabled)
1365 .unwrap_or(false)
1366 }
1367
1368 /// Return whether shell execution is allowed.
1369 #[must_use]
1370 pub fn allow_shell(&self) -> bool {
1371 self.allow_shell.unwrap_or(true)
1372 }
1373
1374 /// Return the maximum number of concurrent sub-agents.
1375 /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,
1376 /// then falls back to `DEFAULT_MAX_SUBAGENTS`.
1377 #[must_use]
1378 pub fn max_subagents(&self) -> usize {
1379 // Check [subagents] max_concurrent first
1380 if let Some(subagents_cfg) = self.subagents.as_ref()
1381 && let Some(max) = subagents_cfg.max_concurrent
1382 {
1383 return max.clamp(1, MAX_SUBAGENTS);
1384 }
1385 // Fall back to top-level max_subagents
1386 self.max_subagents
1387 .unwrap_or(DEFAULT_MAX_SUBAGENTS)
1388 .clamp(1, MAX_SUBAGENTS)
1389 }
1390
1391 /// Raw sub-agent model override map. Values are validated at spawn time
1392 /// so an invalid role/type model fails before any partial agent spawn.
1393 #[must_use]
1394 pub fn subagent_model_overrides(&self) -> HashMap<String, String> {
1395 let mut overrides = HashMap::new();
1396 let Some(cfg) = self.subagents.as_ref() else {
1397 return overrides;
1398 };
1399
1400 let mut insert = |key: &str, value: &Option<String>| {
1401 if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
1402 overrides.insert(key.to_string(), model.to_string());
1403 }
1404 };
1405 insert("default", &cfg.default_model);
1406 insert("worker", &cfg.worker_model);
1407 insert("general", &cfg.worker_model);
1408 insert("explorer", &cfg.explorer_model);
1409 insert("explore", &cfg.explorer_model);
1410 insert("awaiter", &cfg.awaiter_model);
1411 insert("plan", &cfg.awaiter_model);
1412 insert("review", &cfg.review_model);
1413 insert("custom", &cfg.custom_model);
1414
1415 if let Some(models) = cfg.models.as_ref() {
1416 for (key, model) in models {
1417 let key = key.trim();
1418 let model = model.trim();
1419 if !key.is_empty() && !model.is_empty() {
1420 overrides.insert(key.to_ascii_lowercase(), model.to_string());
1421 }
1422 }
1423 }
1424
1425 overrides
1426 }
1427
1428 /// Return the configured DeepSeek reasoning-effort tier, if any.
1429 #[must_use]
1430 pub fn reasoning_effort(&self) -> Option<&str> {
1431 self.reasoning_effort.as_deref()
1432 }
1433
1434 /// Get hooks configuration, returning default if not configured.
1435 pub fn hooks_config(&self) -> HooksConfig {
1436 self.hooks.clone().unwrap_or_default()
1437 }
1438
1439 /// Resolve the notifications configuration with defaults applied.
1440 #[must_use]
1441 pub fn notifications_config(&self) -> NotificationsConfig {
1442 self.notifications.clone().unwrap_or_default()
1443 }
1444
1445 /// Resolve workspace side-git snapshot settings with defaults applied.
1446 #[must_use]
1447 pub fn snapshots_config(&self) -> SnapshotsConfig {
1448 self.snapshots.clone().unwrap_or_default()
1449 }
1450
1451 /// Resolve enabled features from defaults and config entries.
1452 #[must_use]
1453 pub fn features(&self) -> Features {
1454 let mut features = Features::with_defaults();
1455 if let Some(table) = &self.features {
1456 features.apply_map(&table.entries);
1457 }
1458 features
1459 }
1460
1461 /// Override a feature flag in memory (used by CLI overrides).
1462 pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {
1463 if !is_known_feature_key(key) {
1464 anyhow::bail!("Unknown feature flag: {key}");
1465 }
1466 let table = self.features.get_or_insert_with(FeaturesToml::default);
1467 table.entries.insert(key.to_string(), enabled);
1468 Ok(())
1469 }
1470
1471 /// Resolve the effective retry policy with defaults applied.
1472 #[must_use]
1473 pub fn retry_policy(&self) -> RetryPolicy {
1474 let defaults = RetryPolicy {
1475 enabled: true,
1476 max_retries: 3,
1477 initial_delay: 1.0,
1478 max_delay: 60.0,
1479 exponential_base: 2.0,
1480 };
1481
1482 let Some(cfg) = &self.retry else {
1483 return defaults;
1484 };
1485
1486 RetryPolicy {
1487 enabled: cfg.enabled.unwrap_or(defaults.enabled),
1488 max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),
1489 initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),
1490 max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),
1491 exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),
1492 }
1493 }
1494 }
1495
1496 // === Defaults ===
1497
1498 fn default_config_path() -> Option<PathBuf> {
1499 env_config_path().or_else(home_config_path)
1500 }
1501
1502 fn effective_home_dir() -> Option<PathBuf> {
1503 if let Some(path) = std::env::var_os("HOME") {
1504 let path = PathBuf::from(path);
1505 if !path.as_os_str().is_empty() {
1506 return Some(path);
1507 }
1508 }
1509
1510 if let Some(path) = std::env::var_os("USERPROFILE") {
1511 let path = PathBuf::from(path);
1512 if !path.as_os_str().is_empty() {
1513 return Some(path);
1514 }
1515 }
1516
1517 #[cfg(windows)]
1518 {
1519 if let (Some(drive), Some(homepath)) =
1520 (std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
1521 {
1522 let mut path = PathBuf::from(drive);
1523 path.push(homepath);
1524 if !path.as_os_str().is_empty() {
1525 return Some(path);
1526 }
1527 }
1528 }
1529
1530 dirs::home_dir()
1531 }
1532
1533 fn home_config_path() -> Option<PathBuf> {
1534 effective_home_dir().map(|home| home.join(".deepseek").join("config.toml"))
1535 }
1536
1537 #[must_use]
1538 pub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {
1539 let Some(config_path) = default_config_path() else {
1540 return false;
1541 };
1542 let Ok(raw) = fs::read_to_string(config_path) else {
1543 return false;
1544 };
1545 let Ok(doc) = toml::from_str::<toml::Value>(&raw) else {
1546 return false;
1547 };
1548 workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)
1549 }
1550
1551 pub(crate) fn save_workspace_trust(workspace: &Path) -> Result<PathBuf> {
1552 let config_path = default_config_path()
1553 .context("Failed to resolve config path: home directory not found.")?;
1554 ensure_parent_dir(&config_path)?;
1555
1556 let mut doc = if config_path.exists() {
1557 let raw = fs::read_to_string(&config_path)?;
1558 toml::from_str::<toml::Value>(&raw)
1559 .with_context(|| format!("Failed to parse config at {}", config_path.display()))?
1560 } else {
1561 toml::Value::Table(toml::value::Table::new())
1562 };
1563
1564 let root = doc
1565 .as_table_mut()
1566 .context("Config root must be a TOML table.")?;
1567 let projects = root
1568 .entry("projects".to_string())
1569 .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))
1570 .as_table_mut()
1571 .context("`projects` must be a table.")?;
1572 let project = projects
1573 .entry(workspace_config_key(workspace))
1574 .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))
1575 .as_table_mut()
1576 .context("Project entry must be a table.")?;
1577 project.insert(
1578 "trust_level".to_string(),
1579 toml::Value::String("trusted".to_string()),
1580 );
1581
1582 let serialized = toml::to_string_pretty(&doc).context("failed to serialize updated config")?;
1583 write_config_file_secure(&config_path, &serialized)
1584 .with_context(|| format!("Failed to write config to {}", config_path.display()))?;
1585 Ok(config_path)
1586 }
1587
1588 fn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {
1589 let workspace = canonicalize_or_keep(workspace);
1590 let projects = doc.get("projects")?.as_table()?;
1591 for (raw_path, project) in projects {
1592 let project_path = canonicalize_or_keep(&expand_path(raw_path));
1593 if project_path == workspace {
1594 return project.get("trust_level").and_then(toml::Value::as_str);
1595 }
1596 }
1597 None
1598 }
1599
1600 fn is_trusted_level(level: &str) -> bool {
1601 level.trim().eq_ignore_ascii_case("trusted")
1602 }
1603
1604 fn workspace_config_key(workspace: &Path) -> String {
1605 canonicalize_or_keep(workspace)
1606 .to_string_lossy()
1607 .into_owned()
1608 }
1609
1610 fn canonicalize_or_keep(path: &Path) -> PathBuf {
1611 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
1612 }
1613
1614 fn env_config_path() -> Option<PathBuf> {
1615 if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
1616 let trimmed = path.trim();
1617 if !trimmed.is_empty() {
1618 return Some(expand_path(trimmed));
1619 }
1620 }
1621 None
1622 }
1623
1624 fn expand_pathbuf(path: PathBuf) -> PathBuf {
1625 if let Some(raw) = path.to_str() {
1626 return expand_path(raw);
1627 }
1628 path
1629 }
1630
1631 fn resolve_load_config_path(path: Option<PathBuf>) -> Option<PathBuf> {
1632 if let Some(path) = path {
1633 return Some(expand_pathbuf(path));
1634 }
1635
1636 if let Some(path) = env_config_path() {
1637 if path.exists() {
1638 return Some(path);
1639 }
1640
1641 if let Some(home_path) = home_config_path()
1642 && home_path.exists()
1643 {
1644 return Some(home_path);
1645 }
1646
1647 return Some(path);
1648 }
1649
1650 home_config_path()
1651 }
1652
1653 /// Create an inspectable config file on first interactive launch.
1654 ///
1655 /// The file intentionally omits `api_key`; onboarding or `deepseek auth set`
1656 /// writes that field after the user supplies a key.
1657 pub fn ensure_config_file_exists(path: Option<PathBuf>) -> Result<Option<PathBuf>> {
1658 let config_path = path
1659 .map(expand_pathbuf)
1660 .or_else(default_config_path)
1661 .context("Failed to resolve config path: home directory not found.")?;
1662 if config_path.exists() {
1663 return Ok(None);
1664 }
1665
1666 ensure_parent_dir(&config_path)?;
1667 let content = format!(
1668 r#"# DeepSeek TUI Configuration
1669 # Get your API key from https://platform.deepseek.com
1670 # Save it with: deepseek auth set --provider deepseek
1671
1672 # Base URL (default: https://api.deepseek.com)
1673 # base_url = "https://api.deepseek.com"
1674
1675 # Default model
1676 default_text_model = "{default_model}"
1677
1678 # Thinking mode (DeepSeek V4 reasoning effort):
1679 # "auto" | "off" | "low" | "medium" | "high" | "max"
1680 # Shift+Tab in the TUI cycles between off / high / max.
1681 reasoning_effort = "auto"
1682 "#,
1683 default_model = DEFAULT_TEXT_MODEL
1684 );
1685 write_config_file_secure(&config_path, &content)
1686 .with_context(|| format!("Failed to write config to {}", config_path.display()))?;
1687 Ok(Some(config_path))
1688 }
1689
1690 fn default_managed_config_path() -> Option<PathBuf> {
1691 #[cfg(unix)]
1692 {
1693 Some(PathBuf::from("/etc/deepseek/managed_config.toml"))
1694 }
1695 #[cfg(not(unix))]
1696 {
1697 effective_home_dir().map(|home| home.join(".deepseek").join("managed_config.toml"))
1698 }
1699 }
1700
1701 fn default_requirements_path() -> Option<PathBuf> {
1702 #[cfg(unix)]
1703 {
1704 Some(PathBuf::from("/etc/deepseek/requirements.toml"))
1705 }
1706 #[cfg(not(unix))]
1707 {
1708 effective_home_dir().map(|home| home.join(".deepseek").join("requirements.toml"))
1709 }
1710 }
1711
1712 pub(crate) fn expand_path(path: &str) -> PathBuf {
1713 if let Some(stripped) = path.strip_prefix('~')
1714 && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\'))
1715 && let Some(mut home) = effective_home_dir()
1716 {
1717 let suffix = stripped.trim_start_matches(['/', '\\']);
1718 if !suffix.is_empty() {
1719 home.push(suffix);
1720 }
1721 return home;
1722 }
1723
1724 let expanded = shellexpand::tilde(path);
1725 PathBuf::from(expanded.as_ref())
1726 }
1727
1728 fn default_skills_dir() -> Option<PathBuf> {
1729 effective_home_dir().map(|home| home.join(".deepseek").join("skills"))
1730 }
1731
1732 fn default_mcp_config_path() -> Option<PathBuf> {
1733 effective_home_dir().map(|home| home.join(".deepseek").join("mcp.json"))
1734 }
1735
1736 fn default_notes_path() -> Option<PathBuf> {
1737 effective_home_dir().map(|home| home.join(".deepseek").join("notes.txt"))
1738 }
1739
1740 fn default_memory_path() -> Option<PathBuf> {
1741 effective_home_dir().map(|home| home.join(".deepseek").join("memory.md"))
1742 }
1743
1744 // === Environment Overrides ===
1745
1746 fn apply_env_overrides(config: &mut Config) {
1747 if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
1748 config.provider = Some(value);
1749 }
1750 if let Ok(value) = std::env::var("DEEPSEEK_BASE_URL") {
1751 if matches!(config.api_provider(), ApiProvider::NvidiaNim) {
1752 config
1753 .providers
1754 .get_or_insert_with(ProvidersConfig::default)
1755 .nvidia_nim
1756 .base_url = Some(value);
1757 } else {
1758 config.base_url = Some(value);
1759 }
1760 }
1761 if matches!(config.api_provider(), ApiProvider::NvidiaNim)
1762 && let Ok(value) = std::env::var("NVIDIA_NIM_BASE_URL")
1763 .or_else(|_| std::env::var("NIM_BASE_URL"))
1764 .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
1765 {
1766 config
1767 .providers
1768 .get_or_insert_with(ProvidersConfig::default)
1769 .nvidia_nim
1770 .base_url = Some(value);
1771 }
1772 // OpenRouter / Novita are scoped only on their own provider entry — the
1773 // legacy root `base_url` keeps DeepSeek-only semantics.
1774 if matches!(config.api_provider(), ApiProvider::Openrouter)
1775 && let Ok(value) = std::env::var("OPENROUTER_BASE_URL")
1776 && !value.trim().is_empty()
1777 {
1778 config
1779 .providers
1780 .get_or_insert_with(ProvidersConfig::default)
1781 .openrouter
1782 .base_url = Some(value);
1783 }
1784 if matches!(config.api_provider(), ApiProvider::Novita)
1785 && let Ok(value) = std::env::var("NOVITA_BASE_URL")
1786 && !value.trim().is_empty()
1787 {
1788 config
1789 .providers
1790 .get_or_insert_with(ProvidersConfig::default)
1791 .novita
1792 .base_url = Some(value);
1793 }
1794 if matches!(config.api_provider(), ApiProvider::Fireworks)
1795 && let Ok(value) = std::env::var("FIREWORKS_BASE_URL")
1796 && !value.trim().is_empty()
1797 {
1798 config
1799 .providers
1800 .get_or_insert_with(ProvidersConfig::default)
1801 .fireworks
1802 .base_url = Some(value);
1803 }
1804 if matches!(config.api_provider(), ApiProvider::Sglang)
1805 && let Ok(value) = std::env::var("SGLANG_BASE_URL")
1806 && !value.trim().is_empty()
1807 {
1808 config
1809 .providers
1810 .get_or_insert_with(ProvidersConfig::default)
1811 .sglang
1812 .base_url = Some(value);
1813 }
1814 if matches!(config.api_provider(), ApiProvider::Vllm)
1815 && let Ok(value) = std::env::var("VLLM_BASE_URL")
1816 && !value.trim().is_empty()
1817 {
1818 config
1819 .providers
1820 .get_or_insert_with(ProvidersConfig::default)
1821 .vllm
1822 .base_url = Some(value);
1823 }
1824 if let Ok(value) = std::env::var("DEEPSEEK_HTTP_HEADERS")
1825 && let Ok(headers) = parse_http_headers(&value)
1826 && !headers.is_empty()
1827 {
1828 let mut root_headers = config.http_headers.clone().unwrap_or_default();
1829 root_headers.extend(headers.clone());
1830 config.http_headers = Some(root_headers);
1831
1832 let provider = config.api_provider();
1833 let providers = config
1834 .providers
1835 .get_or_insert_with(ProvidersConfig::default);
1836 let entry = match provider {
1837 ApiProvider::Deepseek => &mut providers.deepseek,
1838 ApiProvider::DeepseekCN => &mut providers.deepseek_cn,
1839 ApiProvider::NvidiaNim => &mut providers.nvidia_nim,
1840 ApiProvider::Openrouter => &mut providers.openrouter,
1841 ApiProvider::Novita => &mut providers.novita,
1842 ApiProvider::Fireworks => &mut providers.fireworks,
1843 ApiProvider::Sglang => &mut providers.sglang,
1844 ApiProvider::Vllm => &mut providers.vllm,
1845 };
1846 let mut provider_headers = entry.http_headers.clone().unwrap_or_default();
1847 provider_headers.extend(headers);
1848 entry.http_headers = Some(provider_headers);
1849 }
1850 if matches!(config.api_provider(), ApiProvider::Sglang)
1851 && let Ok(value) = std::env::var("SGLANG_MODEL")
1852 {
1853 config.default_text_model = Some(value);
1854 }
1855 if matches!(config.api_provider(), ApiProvider::Vllm)
1856 && let Ok(value) = std::env::var("VLLM_MODEL")
1857 {
1858 config.default_text_model = Some(value);
1859 }
1860 if let Ok(value) =
1861 std::env::var("DEEPSEEK_MODEL").or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
1862 {
1863 config.default_text_model = Some(value);
1864 }
1865 if matches!(config.api_provider(), ApiProvider::NvidiaNim)
1866 && let Ok(value) = std::env::var("NVIDIA_NIM_MODEL")
1867 {
1868 config.default_text_model = Some(value);
1869 }
1870 if let Ok(value) = std::env::var("DEEPSEEK_SKILLS_DIR") {
1871 config.skills_dir = Some(value);
1872 }
1873 if let Ok(value) = std::env::var("DEEPSEEK_MCP_CONFIG") {
1874 config.mcp_config_path = Some(value);
1875 }
1876 if let Ok(value) = std::env::var("DEEPSEEK_NOTES_PATH") {
1877 config.notes_path = Some(value);
1878 }
1879 if let Ok(value) = std::env::var("DEEPSEEK_MEMORY_PATH") {
1880 config.memory_path = Some(value);
1881 }
1882 if let Ok(value) = std::env::var("DEEPSEEK_MEMORY") {
1883 let on = matches!(
1884 value.trim().to_ascii_lowercase().as_str(),
1885 "1" | "on" | "true" | "yes" | "y" | "enabled"
1886 );
1887 config
1888 .memory
1889 .get_or_insert_with(MemoryConfig::default)
1890 .enabled = Some(on);
1891 }
1892 if let Ok(value) = std::env::var("DEEPSEEK_ALLOW_SHELL") {
1893 config.allow_shell = Some(value == "1" || value.eq_ignore_ascii_case("true"));
1894 }
1895 if let Ok(value) = std::env::var("DEEPSEEK_APPROVAL_POLICY") {
1896 config.approval_policy = Some(value);
1897 }
1898 if let Ok(value) = std::env::var("DEEPSEEK_SANDBOX_MODE") {
1899 config.sandbox_mode = Some(value);
1900 }
1901 if let Ok(value) = std::env::var("DEEPSEEK_SANDBOX_BACKEND") {
1902 config.sandbox_backend = Some(value);
1903 }
1904 if let Ok(value) = std::env::var("DEEPSEEK_SANDBOX_URL") {
1905 config.sandbox_url = Some(value);
1906 }
1907 if let Ok(value) = std::env::var("DEEPSEEK_SANDBOX_API_KEY") {
1908 config.sandbox_api_key = Some(value);
1909 }
1910 if let Ok(value) = std::env::var("DEEPSEEK_MANAGED_CONFIG_PATH") {
1911 config.managed_config_path = Some(value);
1912 }
1913 if let Ok(value) = std::env::var("DEEPSEEK_REQUIREMENTS_PATH") {
1914 config.requirements_path = Some(value);
1915 }
1916 if let Ok(value) = std::env::var("DEEPSEEK_MAX_SUBAGENTS")
1917 && let Ok(parsed) = value.parse::<usize>()
1918 {
1919 config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));
1920 }
1921
1922 let capacity = config.capacity.get_or_insert(CapacityConfig {
1923 enabled: None,
1924 low_risk_max: None,
1925 medium_risk_max: None,
1926 severe_min_slack: None,
1927 severe_violation_ratio: None,
1928 refresh_cooldown_turns: None,
1929 replan_cooldown_turns: None,
1930 max_replay_per_turn: None,
1931 min_turns_before_guardrail: None,
1932 profile_window: None,
1933 deepseek_v3_2_chat_prior: None,
1934 deepseek_v3_2_reasoner_prior: None,
1935 deepseek_v4_pro_prior: None,
1936 deepseek_v4_flash_prior: None,
1937 fallback_default_prior: None,
1938 });
1939
1940 if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_ENABLED") {
1941 let val = value.trim().to_ascii_lowercase();
1942 capacity.enabled = Some(matches!(val.as_str(), "1" | "true" | "yes" | "on"));
1943 }
1944 if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_LOW_RISK_MAX")
1945 && let Ok(parsed) = value.parse::<f64>()
1946 {
1947 capacity.low_risk_max = Some(parsed);
1948 }
1949 if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX")
1950 && let Ok(parsed) = value.parse::<f64>()
1951 {
1952 capacity.medium_risk_max = Some(parsed);
1953 }
1954 if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK")
1955 && let Ok(parsed) = value.parse::<f64>()
1956 {
1957 capacity.severe_min_slack = Some(parsed);
1958 }
1959 if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO")
1960 && let Ok(parsed) = value.parse::<f64>()
1961 {
1962 capacity.severe_violation_ratio = Some(parsed);
1963 }
1964 if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS")
1965 && let Ok(parsed) = value.parse::<u64>()
1966 {
1967 capacity.refresh_cooldown_turns = Some(parsed);
1968 }
1969 if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS")
1970 && let Ok(parsed) = value.parse::<u64>()
1971 {
1972 capacity.replan_cooldown_turns = Some(parsed);
1973 }
1974 if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN")
1975 && let Ok(parsed) = value.parse::<usize>()
1976 {
1977 capacity.max_replay_per_turn = Some(parsed);
1978 }
1979 if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL")
1980 && let Ok(parsed) = value.parse::<u64>()
1981 {
1982 capacity.min_turns_before_guardrail = Some(parsed);
1983 }
1984 if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_PROFILE_WINDOW")
1985 && let Ok(parsed) = value.parse::<usize>()
1986 {
1987 capacity.profile_window = Some(parsed);
1988 }
1989 if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_PRIOR_CHAT")
1990 && let Ok(parsed) = value.parse::<f64>()
1991 {
1992 capacity.deepseek_v3_2_chat_prior = Some(parsed);
1993 }
1994 if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_PRIOR_REASONER")
1995 && let Ok(parsed) = value.parse::<f64>()
1996 {
1997 capacity.deepseek_v3_2_reasoner_prior = Some(parsed);
1998 }
1999 if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_PRIOR_V4_PRO")
2000 && let Ok(parsed) = value.parse::<f64>()
2001 {
2002 capacity.deepseek_v4_pro_prior = Some(parsed);
2003 }
2004 if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_PRIOR_V4_FLASH")
2005 && let Ok(parsed) = value.parse::<f64>()
2006 {
2007 capacity.deepseek_v4_flash_prior = Some(parsed);
2008 }
2009 if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_PRIOR_FALLBACK")
2010 && let Ok(parsed) = value.parse::<f64>()
2011 {
2012 capacity.fallback_default_prior = Some(parsed);
2013 }
2014
2015 if config.capacity.as_ref().is_some_and(|c| {
2016 c.enabled.is_none()
2017 && c.low_risk_max.is_none()
2018 && c.medium_risk_max.is_none()
2019 && c.severe_min_slack.is_none()
2020 && c.severe_violation_ratio.is_none()
2021 && c.refresh_cooldown_turns.is_none()
2022 && c.replan_cooldown_turns.is_none()
2023 && c.max_replay_per_turn.is_none()
2024 && c.min_turns_before_guardrail.is_none()
2025 && c.profile_window.is_none()
2026 && c.deepseek_v3_2_chat_prior.is_none()
2027 && c.deepseek_v3_2_reasoner_prior.is_none()
2028 && c.deepseek_v4_pro_prior.is_none()
2029 && c.deepseek_v4_flash_prior.is_none()
2030 && c.fallback_default_prior.is_none()
2031 }) {
2032 config.capacity = None;
2033 }
2034 }
2035
2036 fn normalize_model_config(config: &mut Config) {
2037 if let Some(model) = config.default_text_model.as_deref()
2038 && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)
2039 {
2040 config.default_text_model = Some(normalized);
2041 }
2042
2043 if let Some(providers) = config.providers.as_mut() {
2044 if let Some(model) = providers.deepseek.model.as_deref()
2045 && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)
2046 {
2047 providers.deepseek.model = Some(normalized);
2048 }
2049 if let Some(model) = providers.deepseek_cn.model.as_deref()
2050 && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)
2051 {
2052 providers.deepseek_cn.model = Some(normalized);
2053 }
2054 if let Some(model) = providers.nvidia_nim.model.as_deref()
2055 && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)
2056 {
2057 providers.nvidia_nim.model = Some(normalized);
2058 }
2059 if let Some(model) = providers.openrouter.model.as_deref()
2060 && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)
2061 {
2062 providers.openrouter.model = Some(normalized);
2063 }
2064 if let Some(model) = providers.novita.model.as_deref()
2065 && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)
2066 {
2067 providers.novita.model = Some(normalized);
2068 }
2069 if let Some(model) = providers.fireworks.model.as_deref()
2070 && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)
2071 {
2072 providers.fireworks.model = Some(normalized);
2073 }
2074 if let Some(model) = providers.sglang.model.as_deref()
2075 && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)
2076 {
2077 providers.sglang.model = Some(normalized);
2078 }
2079 if let Some(model) = providers.vllm.model.as_deref()
2080 && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)
2081 {
2082 providers.vllm.model = Some(normalized);
2083 }
2084 }
2085 }
2086
2087 fn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option<String> {
2088 normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))
2089 }
2090
2091 fn model_for_provider(provider: ApiProvider, normalized: String) -> String {
2092 let lowered = normalized.to_ascii_lowercase();
2093 match (provider, lowered.as_str()) {
2094 (ApiProvider::NvidiaNim, "deepseek-v4-pro") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),
2095 (ApiProvider::NvidiaNim, "deepseek-v4-flash") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
2096 (ApiProvider::Openrouter, "deepseek-v4-pro") => DEFAULT_OPENROUTER_MODEL.to_string(),
2097 (ApiProvider::Openrouter, "deepseek-v4-flash") => {
2098 DEFAULT_OPENROUTER_FLASH_MODEL.to_string()
2099 }
2100 (ApiProvider::Novita, "deepseek-v4-pro") => DEFAULT_NOVITA_MODEL.to_string(),
2101 (ApiProvider::Novita, "deepseek-v4-flash") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
2102 (ApiProvider::Fireworks, "deepseek-v4-pro") => DEFAULT_FIREWORKS_MODEL.to_string(),
2103 (ApiProvider::Fireworks, "deepseek-v4-flash") => {
2104 // Flash not yet available on Fireworks; fall through to normalized name
2105 "accounts/fireworks/models/deepseek-v4-flash".to_string()
2106 }
2107 (ApiProvider::Sglang, "deepseek-v4-pro") => DEFAULT_SGLANG_MODEL.to_string(),
2108 (ApiProvider::Sglang, "deepseek-v4-flash") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
2109 (ApiProvider::Vllm, "deepseek-v4-pro") => DEFAULT_VLLM_MODEL.to_string(),
2110 (ApiProvider::Vllm, "deepseek-v4-flash") => DEFAULT_VLLM_FLASH_MODEL.to_string(),
2111 _ => normalized,
2112 }
2113 }
2114
2115 fn normalize_base_url(base: &str) -> String {
2116 let trimmed = base.trim_end_matches('/');
2117 let deepseek_domains = ["api.deepseek.com", "api.deepseeki.com"];
2118 if deepseek_domains
2119 .iter()
2120 .any(|domain| trimmed.contains(domain))
2121 {
2122 return trimmed.trim_end_matches("/v1").to_string();
2123 }
2124 trimmed.to_string()
2125 }
2126
2127 fn parse_http_headers(raw: &str) -> Result<HashMap<String, String>> {
2128 let mut headers = HashMap::new();
2129 for pair in raw.trim().split(',') {
2130 let pair = pair.trim();
2131 if pair.is_empty() {
2132 continue;
2133 }
2134 let Some((name, value)) = pair.split_once('=') else {
2135 anyhow::bail!("invalid header pair '{pair}', expected name=value");
2136 };
2137 let name = name.trim();
2138 let value = value.trim();
2139 if name.is_empty() {
2140 anyhow::bail!("header name cannot be empty");
2141 }
2142 if value.is_empty() {
2143 continue;
2144 }
2145 headers.insert(name.to_string(), value.to_string());
2146 }
2147 Ok(headers)
2148 }
2149
2150 fn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result<Config> {
2151 if let Some(profile_name) = profile {
2152 let profiles = config.profiles.as_ref();
2153 match profiles.and_then(|profiles| profiles.get(profile_name)) {
2154 Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),
2155 None => {
2156 let available = profiles
2157 .map(|profiles| {
2158 let mut keys = profiles.keys().cloned().collect::<Vec<_>>();
2159 keys.sort();
2160 if keys.is_empty() {
2161 "none".to_string()
2162 } else {
2163 keys.join(", ")
2164 }
2165 })
2166 .unwrap_or_else(|| "none".to_string());
2167 anyhow::bail!(
2168 "Profile '{}' not found. Available profiles: {}",
2169 profile_name,
2170 available
2171 )
2172 }
2173 }
2174 } else {
2175 Ok(config.base)
2176 }
2177 }
2178
2179 fn merge_config(base: Config, override_cfg: Config) -> Config {
2180 Config {
2181 provider: override_cfg.provider.or(base.provider),
2182 api_key: override_cfg.api_key.or(base.api_key),
2183 base_url: override_cfg.base_url.or(base.base_url),
2184 http_headers: override_cfg.http_headers.or(base.http_headers),
2185 default_text_model: override_cfg.default_text_model.or(base.default_text_model),
2186 reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),
2187 tools_file: override_cfg.tools_file.or(base.tools_file),
2188 skills_dir: override_cfg.skills_dir.or(base.skills_dir),
2189 mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),
2190 notes_path: override_cfg.notes_path.or(base.notes_path),
2191 memory_path: override_cfg.memory_path.or(base.memory_path),
2192 // #454: project's instructions array replaces user's array
2193 // wholesale. The typical "merge" pattern is for users who want
2194 // both — they list `~/global.md` inside the project array.
2195 instructions: override_cfg.instructions.or(base.instructions),
2196 allow_shell: override_cfg.allow_shell.or(base.allow_shell),
2197 approval_policy: override_cfg.approval_policy.or(base.approval_policy),
2198 sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),
2199 sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),
2200 sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),
2201 sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),
2202 managed_config_path: override_cfg
2203 .managed_config_path
2204 .or(base.managed_config_path),
2205 requirements_path: override_cfg.requirements_path.or(base.requirements_path),
2206 max_subagents: override_cfg.max_subagents.or(base.max_subagents),
2207 retry: override_cfg.retry.or(base.retry),
2208 capacity: override_cfg.capacity.or(base.capacity),
2209 tui: override_cfg.tui.or(base.tui),
2210 hooks: override_cfg.hooks.or(base.hooks),
2211 providers: merge_providers(base.providers, override_cfg.providers),
2212 features: merge_features(base.features, override_cfg.features),
2213 notifications: override_cfg.notifications.or(base.notifications),
2214 network: override_cfg.network.or(base.network),
2215 skills: override_cfg.skills.or(base.skills),
2216 snapshots: override_cfg.snapshots.or(base.snapshots),
2217 memory: override_cfg.memory.or(base.memory),
2218 lsp: override_cfg.lsp.or(base.lsp),
2219 context: ContextConfig {
2220 enabled: override_cfg.context.enabled.or(base.context.enabled),
2221 verbatim_window_turns: override_cfg
2222 .context
2223 .verbatim_window_turns
2224 .or(base.context.verbatim_window_turns),
2225 l1_threshold: override_cfg
2226 .context
2227 .l1_threshold
2228 .or(base.context.l1_threshold),
2229 l2_threshold: override_cfg
2230 .context
2231 .l2_threshold
2232 .or(base.context.l2_threshold),
2233 l3_threshold: override_cfg
2234 .context
2235 .l3_threshold
2236 .or(base.context.l3_threshold),
2237 cycle_threshold: override_cfg
2238 .context
2239 .cycle_threshold
2240 .or(base.context.cycle_threshold),
2241 seam_model: override_cfg.context.seam_model.or(base.context.seam_model),
2242 per_model: override_cfg.context.per_model.or(base.context.per_model),
2243 },
2244 subagents: override_cfg.subagents.or(base.subagents),
2245 strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),
2246 runtime_api: override_cfg.runtime_api.or(base.runtime_api),
2247 workshop: override_cfg.workshop.or(base.workshop),
2248 }
2249 }
2250
2251 fn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {
2252 ProviderConfig {
2253 api_key: override_cfg.api_key.or(base.api_key),
2254 base_url: override_cfg.base_url.or(base.base_url),
2255 model: override_cfg.model.or(base.model),
2256 http_headers: override_cfg.http_headers.or(base.http_headers),
2257 }
2258 }
2259
2260 fn merge_providers(
2261 base: Option<ProvidersConfig>,
2262 override_cfg: Option<ProvidersConfig>,
2263 ) -> Option<ProvidersConfig> {
2264 match (base, override_cfg) {
2265 (None, None) => None,
2266 (Some(base), None) => Some(base),
2267 (None, Some(override_cfg)) => Some(override_cfg),
2268 (Some(base), Some(override_cfg)) => Some(ProvidersConfig {
2269 deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),
2270 deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),
2271 nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),
2272 openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),
2273 novita: merge_provider_config(base.novita, override_cfg.novita),
2274 fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),
2275 sglang: merge_provider_config(base.sglang, override_cfg.sglang),
2276 vllm: merge_provider_config(base.vllm, override_cfg.vllm),
2277 }),
2278 }
2279 }
2280
2281 fn load_single_config_file(path: &Path) -> Result<Config> {
2282 let contents = fs::read_to_string(path)
2283 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
2284 let parsed: ConfigFile = toml::from_str(&contents)
2285 .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
2286 Ok(parsed.base)
2287 }
2288
2289 fn apply_managed_overrides(config: &mut Config) -> Result<()> {
2290 let path = config
2291 .managed_config_path
2292 .as_deref()
2293 .map(expand_path)
2294 .or_else(default_managed_config_path);
2295 let Some(path) = path else {
2296 return Ok(());
2297 };
2298 if !path.exists() {
2299 return Ok(());
2300 }
2301 let managed = load_single_config_file(&path)?;
2302 *config = merge_config(config.clone(), managed);
2303 Ok(())
2304 }
2305
2306 fn apply_requirements(config: &mut Config) -> Result<()> {
2307 let path = config
2308 .requirements_path
2309 .as_deref()
2310 .map(expand_path)
2311 .or_else(default_requirements_path);
2312 let Some(path) = path else {
2313 return Ok(());
2314 };
2315 if !path.exists() {
2316 return Ok(());
2317 }
2318 let contents = fs::read_to_string(&path)
2319 .with_context(|| format!("Failed to read requirements file: {}", path.display()))?;
2320 let requirements: RequirementsFile = toml::from_str(&contents)
2321 .with_context(|| format!("Failed to parse requirements file: {}", path.display()))?;
2322
2323 if !requirements.allowed_approval_policies.is_empty()
2324 && let Some(policy) = config.approval_policy.as_ref()
2325 {
2326 let policy = policy.to_ascii_lowercase();
2327 if !requirements
2328 .allowed_approval_policies
2329 .iter()
2330 .any(|p| p.eq_ignore_ascii_case(&policy))
2331 {
2332 anyhow::bail!(
2333 "approval_policy '{policy}' is not allowed by requirements ({})",
2334 requirements.allowed_approval_policies.join(", ")
2335 );
2336 }
2337 }
2338 if !requirements.allowed_sandbox_modes.is_empty()
2339 && let Some(mode) = config.sandbox_mode.as_ref()
2340 {
2341 let mode = mode.to_ascii_lowercase();
2342 if !requirements
2343 .allowed_sandbox_modes
2344 .iter()
2345 .any(|m| m.eq_ignore_ascii_case(&mode))
2346 {
2347 anyhow::bail!(
2348 "sandbox_mode '{mode}' is not allowed by requirements ({})",
2349 requirements.allowed_sandbox_modes.join(", ")
2350 );
2351 }
2352 }
2353
2354 Ok(())
2355 }
2356
2357 fn merge_features(
2358 base: Option<FeaturesToml>,
2359 override_cfg: Option<FeaturesToml>,
2360 ) -> Option<FeaturesToml> {
2361 match (base, override_cfg) {
2362 (None, None) => None,
2363 (Some(mut base), Some(override_cfg)) => {
2364 for (key, value) in override_cfg.entries {
2365 base.entries.insert(key, value);
2366 }
2367 Some(base)
2368 }
2369 (Some(base), None) => Some(base),
2370 (None, Some(override_cfg)) => Some(override_cfg),
2371 }
2372 }
2373
2374 pub fn ensure_parent_dir(path: &Path) -> Result<()> {
2375 if let Some(parent) = path.parent() {
2376 fs::create_dir_all(parent)
2377 .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
2378 #[cfg(unix)]
2379 {
2380 // Tighten group/other bits on the parent dir as a hardening pass.
2381 // The dir lives under the user's home, so the chmod is best-effort:
2382 // filesystems that don't accept Unix permission bits (Docker
2383 // bind-mounts of NTFS, network shares, FAT, certain CI volumes —
2384 // see #897) return EPERM/ENOTSUP. The dir already exists by the
2385 // time we get here, so failing the whole save just because we
2386 // couldn't tighten perms strands the user mid-onboarding. Warn
2387 // loudly so a security-sensitive operator can still notice via
2388 // `RUST_LOG=warn`, then continue.
2389 if let Ok(meta) = fs::metadata(parent) {
2390 let mode = meta.permissions().mode();
2391 if mode & 0o077 != 0 {
2392 let mut perms = meta.permissions();
2393 perms.set_mode(mode & !0o077);
2394 if let Err(err) = fs::set_permissions(parent, perms) {
2395 tracing::warn!(
2396 target: "deepseek::config",
2397 path = %parent.display(),
2398 error = %err,
2399 "could not tighten parent dir permissions; \
2400 filesystem may not support Unix chmod \
2401 (Docker bind-mount, NTFS, network share). \
2402 Continuing — the file will still be written."
2403 );
2404 }
2405 }
2406 }
2407 }
2408 }
2409 Ok(())
2410 }
2411
2412 /// Write content to a config file with restrictive permissions (owner-only read/write).
2413 /// On Unix this sets mode 0o600 before writing.
2414 fn write_config_file_secure(path: &Path, content: &str) -> Result<()> {
2415 #[cfg(unix)]
2416 {
2417 let mut file = fs::OpenOptions::new()
2418 .write(true)
2419 .create(true)
2420 .truncate(true)
2421 .mode(0o600)
2422 .open(path)?;
2423 file.write_all(content.as_bytes())?;
2424 // The file was already opened with mode 0o600; the explicit
2425 // set_permissions re-asserts that on filesystems where mode-at-open
2426 // didn't take effect (or where the file already existed with broader
2427 // bits). Filesystems that don't accept Unix chmod at all (Docker
2428 // bind-mounts of NTFS, network shares — #897) return EPERM. Treat
2429 // that as a warning rather than failing the whole save: the file
2430 // contents are written, and on Windows/macOS hosts the parent file
2431 // system's native ACL model is doing the access control.
2432 if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {
2433 tracing::warn!(
2434 target: "deepseek::config",
2435 path = %path.display(),
2436 error = %err,
2437 "could not enforce 0o600 on config file; filesystem may \
2438 not support Unix chmod. File contents written; rely on \
2439 host ACLs for access control."
2440 );
2441 }
2442 }
2443 #[cfg(not(unix))]
2444 {
2445 fs::write(path, content)?;
2446 }
2447 Ok(())
2448 }
2449
2450 /// Where a saved credential ended up. Returned by [`save_api_key`] so
2451 /// the caller can show a confirmation message without leaking the key.
2452 #[derive(Debug, Clone, PartialEq, Eq)]
2453 pub enum SavedCredential {
2454 /// Stored in **both** the OS keyring and the deepseek config file.
2455 /// This is the default outcome on platforms with a working keyring
2456 /// backend: writing both layers defeats the
2457 /// `keyring → env → config-file` resolution-order shadow that
2458 /// would otherwise let a stale OS-keyring entry from a previous
2459 /// install hide the freshly-entered key (#593). The `backend`
2460 /// label is the value of [`deepseek_secrets::Secrets::backend_name`]
2461 /// at write time so the toast text can name the actual backend
2462 /// (`"system keyring"`, `"file-based (~/.deepseek/secrets/)"`).
2463 KeyringAndConfigFile {
2464 /// `Secrets::backend_name()` at write time.
2465 backend: String,
2466 /// Absolute path to the config file that was also updated.
2467 path: PathBuf,
2468 },
2469 /// Stored in the deepseek config file only. Fallback when no
2470 /// keyring backend is reachable, or under `cfg(test)` so unit
2471 /// tests don't pollute the host keyring.
2472 ConfigFile(PathBuf),
2473 }
2474
2475 impl SavedCredential {
2476 /// Human-readable description for status / log output. Never
2477 /// includes the key value.
2478 #[must_use]
2479 pub fn describe(&self) -> String {
2480 match self {
2481 Self::KeyringAndConfigFile { backend, path } => {
2482 format!("OS keyring ({backend}) and {}", path.display())
2483 }
2484 Self::ConfigFile(path) => path.display().to_string(),
2485 }
2486 }
2487 }
2488
2489 /// Save the active provider's API key.
2490 ///
2491 /// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`
2492 /// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]
2493 /// (when a backend is reachable). The runtime resolves credentials in
2494 /// `keyring → env → config-file` order; writing to the config file
2495 /// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry
2496 /// from a prior install silently shadow the fresh value the user just
2497 /// typed during in-TUI onboarding, producing the "no response" symptom
2498 /// reported in #593.
2499 ///
2500 /// The config file remains the inspectable durable record (works in
2501 /// npm installs, IDE terminals, and headless boxes alike), and the
2502 /// keyring acts as the layered override that defeats stale-shadow on
2503 /// the resolution path. When the keyring write fails (no backend, OS
2504 /// permission denied, etc.) the config-file write still stands and
2505 /// the function reports a [`SavedCredential::ConfigFile`] outcome —
2506 /// callers should not treat that as a failure.
2507 ///
2508 /// Skipped under `cfg(test)` so the suite never touches the host
2509 /// keyring. The `secrets` crate has its own test coverage for
2510 /// keyring set/get.
2511 pub fn save_api_key(api_key: &str) -> Result<SavedCredential> {
2512 let trimmed = api_key.trim();
2513 if trimmed.is_empty() {
2514 anyhow::bail!("Refusing to save an empty API key.");
2515 }
2516
2517 // Always write the inspectable copy first. The config file is the
2518 // durable record everyone — including macOS Keychain-prompted
2519 // first-run, headless CI, and IDE terminals — can rely on.
2520 let path = save_api_key_to_config_file(trimmed)?;
2521
2522 // Then mirror to the OS keyring when one is reachable. This
2523 // overwrites any stale entry from a prior install so
2524 // `Secrets::resolve` (keyring → env → config-file) no longer
2525 // shadows the fresh key. Skipped under `cfg(test)` so unit tests
2526 // can't pollute the host keyring (macOS Always-Allow prompts,
2527 // cross-test contamination).
2528 #[cfg(not(test))]
2529 {
2530 let secrets = deepseek_secrets::Secrets::auto_detect();
2531 match secrets.set("deepseek", trimmed) {
2532 Ok(()) => {
2533 let backend = secrets.backend_name().to_string();
2534 log_sensitive_event(
2535 "credential.save",
2536 json!({
2537 "backend": backend.clone(),
2538 "config_path": path.display().to_string(),
2539 "dual_write": true,
2540 }),
2541 );
2542 return Ok(SavedCredential::KeyringAndConfigFile { backend, path });
2543 }
2544 Err(err) => {
2545 tracing::warn!("OS keyring write failed; key saved to config.toml only: {err}");
2546 // Fall through to the ConfigFile-only outcome below.
2547 }
2548 }
2549 }
2550
2551 Ok(SavedCredential::ConfigFile(path))
2552 }
2553
2554 /// Write the `api_key` slot directly to `config.toml`.
2555 fn save_api_key_to_config_file(api_key: &str) -> Result<PathBuf> {
2556 fn is_api_key_assignment(line: &str) -> bool {
2557 let trimmed = line.trim_start();
2558 trimmed
2559 .strip_prefix("api_key")
2560 .is_some_and(|rest| rest.trim_start().starts_with('='))
2561 }
2562
2563 let config_path = default_config_path()
2564 .context("Failed to resolve config path: home directory not found.")?;
2565
2566 ensure_parent_dir(&config_path)?;
2567
2568 let key_to_write = api_key.to_string();
2569
2570 let content = if config_path.exists() {
2571 // Read existing config and update the api_key line
2572 let existing = fs::read_to_string(&config_path)?;
2573 if existing.contains("api_key") {
2574 // Replace existing api_key line
2575 let mut result = String::new();
2576 for line in existing.lines() {
2577 if is_api_key_assignment(line) {
2578 let _ = writeln!(result, "api_key = \"{key_to_write}\"");
2579 } else {
2580 result.push_str(line);
2581 result.push('\n');
2582 }
2583 }
2584 result
2585 } else {
2586 // Prepend api_key to existing config
2587 format!("api_key = \"{key_to_write}\"\n{existing}")
2588 }
2589 } else {
2590 // Create new minimal config
2591 format!(
2592 r#"# DeepSeek TUI Configuration
2593 # Get your API key from https://platform.deepseek.com
2594 # Or set DEEPSEEK_API_KEY environment variable
2595
2596 api_key = "{key_to_write}"
2597
2598 # Base URL (default: https://api.deepseek.com)
2599 # base_url = "https://api.deepseek.com"
2600
2601 # Default model
2602 default_text_model = "{default_model}"
2603
2604 # Thinking mode (DeepSeek V4 reasoning effort):
2605 # "off" | "low" | "medium" | "high" | "max"
2606 # Shift+Tab in the TUI cycles between off / high / max.
2607 reasoning_effort = "max"
2608 "#,
2609 default_model = DEFAULT_TEXT_MODEL
2610 )
2611 };
2612
2613 write_config_file_secure(&config_path, &content)
2614 .with_context(|| format!("Failed to write config to {}", config_path.display()))?;
2615 log_sensitive_event(
2616 "credential.save",
2617 json!({
2618 "backend": "config_file",
2619 "config_path": config_path.display().to_string(),
2620 }),
2621 );
2622
2623 Ok(config_path)
2624 }
2625
2626 /// Check if an API key is configured anywhere the runtime can resolve it.
2627 ///
2628 /// Order of inspection:
2629 /// 1. `DEEPSEEK_API_KEY` env var (fast, no I/O, no OS prompts).
2630 /// 2. In-memory override on the config (set by onboarding / picker).
2631 /// 3. Config-file `api_key` slot (cheap file read already done by
2632 /// the loaded `Config`).
2633 ///
2634 /// Platform credential stores are intentionally not queried here.
2635 /// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8
2636 /// keeps the default auth path to environment variables and
2637 /// `~/.deepseek/config.toml`.
2638 ///
2639 /// Used by [`crate::tui::app::App::new`] to decide whether to gate
2640 /// the user behind the in-TUI api-key onboarding screen — getting
2641 /// this wrong made users get prompted for credentials in situations
2642 /// where normal env/config auth was already available.
2643 pub fn has_api_key(config: &Config) -> bool {
2644 if std::env::var("DEEPSEEK_API_KEY").is_ok_and(|k| !k.trim().is_empty()) {
2645 return true;
2646 }
2647 if config
2648 .api_key
2649 .as_ref()
2650 .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
2651 {
2652 return true;
2653 }
2654 false
2655 }
2656
2657 #[must_use]
2658 pub fn active_provider_has_config_api_key(config: &Config) -> bool {
2659 let provider = config.api_provider();
2660
2661 if config
2662 .provider_config_for(provider)
2663 .and_then(|entry| entry.api_key.as_ref())
2664 .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
2665 {
2666 return true;
2667 }
2668
2669 matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
2670 && config
2671 .api_key
2672 .as_ref()
2673 .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
2674 }
2675
2676 #[must_use]
2677 pub fn active_provider_has_env_api_key(config: &Config) -> bool {
2678 match config.api_provider() {
2679 ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
2680 std::env::var("DEEPSEEK_API_KEY").is_ok_and(|k| !k.trim().is_empty())
2681 }
2682 ApiProvider::NvidiaNim => {
2683 std::env::var("NVIDIA_API_KEY").is_ok_and(|k| !k.trim().is_empty())
2684 || std::env::var("NVIDIA_NIM_API_KEY").is_ok_and(|k| !k.trim().is_empty())
2685 }
2686 ApiProvider::Openrouter => {
2687 std::env::var("OPENROUTER_API_KEY").is_ok_and(|k| !k.trim().is_empty())
2688 }
2689 ApiProvider::Novita => std::env::var("NOVITA_API_KEY").is_ok_and(|k| !k.trim().is_empty()),
2690 ApiProvider::Fireworks => {
2691 std::env::var("FIREWORKS_API_KEY").is_ok_and(|k| !k.trim().is_empty())
2692 }
2693 ApiProvider::Sglang => std::env::var("SGLANG_API_KEY").is_ok_and(|k| !k.trim().is_empty()),
2694 ApiProvider::Vllm => std::env::var("VLLM_API_KEY").is_ok_and(|k| !k.trim().is_empty()),
2695 }
2696 }
2697
2698 #[must_use]
2699 pub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {
2700 active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)
2701 }
2702
2703 /// Check whether the given provider has any usable API key — via env var,
2704 /// provider/root config. Used by the `/provider` picker to decide whether to
2705 /// prompt for a key inline.
2706 #[must_use]
2707 pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {
2708 let env_var = match provider {
2709 ApiProvider::Deepseek | ApiProvider::DeepseekCN => "DEEPSEEK_API_KEY",
2710 ApiProvider::NvidiaNim => "NVIDIA_API_KEY",
2711 ApiProvider::Openrouter => "OPENROUTER_API_KEY",
2712 ApiProvider::Novita => "NOVITA_API_KEY",
2713 ApiProvider::Fireworks => "FIREWORKS_API_KEY",
2714 ApiProvider::Sglang => "SGLANG_API_KEY",
2715 ApiProvider::Vllm => "VLLM_API_KEY",
2716 };
2717 if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {
2718 return true;
2719 }
2720 if matches!(provider, ApiProvider::NvidiaNim)
2721 && std::env::var("NVIDIA_NIM_API_KEY").is_ok_and(|k| !k.trim().is_empty())
2722 {
2723 return true;
2724 }
2725
2726 // SGLang is self-hosted and typically runs without authentication.
2727 if matches!(provider, ApiProvider::Sglang | ApiProvider::Vllm) {
2728 return true;
2729 }
2730
2731 if config
2732 .provider_config_for(provider)
2733 .and_then(|entry| entry.api_key.as_ref())
2734 .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
2735 {
2736 return true;
2737 }
2738
2739 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
2740 && config
2741 .api_key
2742 .as_ref()
2743 .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
2744 {
2745 return true;
2746 }
2747
2748 false
2749 }
2750
2751 /// Save an API key to the appropriate place for the given provider.
2752 /// DeepSeek goes through [`save_api_key`]. Other providers write
2753 /// `[providers.<name>] api_key = "..."` to `~/.deepseek/config.toml`.
2754 /// Returns the config file path.
2755 pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result<PathBuf> {
2756 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
2757 return match save_api_key(api_key)? {
2758 SavedCredential::KeyringAndConfigFile { path, .. }
2759 | SavedCredential::ConfigFile(path) => Ok(path),
2760 };
2761 }
2762
2763 let config_path = default_config_path()
2764 .context("Failed to resolve config path: home directory not found.")?;
2765 ensure_parent_dir(&config_path)?;
2766
2767 let table_name = match provider {
2768 ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
2769 return Err(anyhow::anyhow!(
2770 "save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage"
2771 ));
2772 }
2773 ApiProvider::NvidiaNim => "providers.nvidia_nim",
2774 ApiProvider::Openrouter => "providers.openrouter",
2775 ApiProvider::Novita => "providers.novita",
2776 ApiProvider::Fireworks => "providers.fireworks",
2777 ApiProvider::Sglang => "providers.sglang",
2778 ApiProvider::Vllm => "providers.vllm",
2779 };
2780
2781 // Parse existing TOML (or start fresh) so we can edit the right table
2782 // without disturbing other sections.
2783 let mut doc: toml::Value = if config_path.exists() {
2784 let raw = fs::read_to_string(&config_path)?;
2785 toml::from_str(&raw)
2786 .with_context(|| format!("Failed to parse config at {}", config_path.display()))?
2787 } else {
2788 toml::Value::Table(toml::value::Table::new())
2789 };
2790
2791 let table = doc
2792 .as_table_mut()
2793 .context("Config root must be a TOML table.")?;
2794 let providers = table
2795 .entry("providers".to_string())
2796 .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))
2797 .as_table_mut()
2798 .context("`providers` must be a table.")?;
2799 let key_inside = match provider {
2800 ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
2801 return Err(anyhow::anyhow!(
2802 "save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage"
2803 ));
2804 }
2805 ApiProvider::NvidiaNim => "nvidia_nim",
2806 ApiProvider::Openrouter => "openrouter",
2807 ApiProvider::Novita => "novita",
2808 ApiProvider::Fireworks => "fireworks",
2809 ApiProvider::Sglang => "sglang",
2810 ApiProvider::Vllm => "vllm",
2811 };
2812 let entry = providers
2813 .entry(key_inside.to_string())
2814 .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))
2815 .as_table_mut()
2816 .with_context(|| format!("`{table_name}` must be a table."))?;
2817 entry.insert(
2818 "api_key".to_string(),
2819 toml::Value::String(api_key.to_string()),
2820 );
2821
2822 let serialized = toml::to_string_pretty(&doc).context("failed to serialize updated config")?;
2823 write_config_file_secure(&config_path, &serialized)
2824 .with_context(|| format!("Failed to write config to {}", config_path.display()))?;
2825 log_sensitive_event(
2826 "credential.save",
2827 json!({
2828 "backend": "config_file",
2829 "provider": provider.as_str(),
2830 "config_path": config_path.display().to_string(),
2831 }),
2832 );
2833
2834 Ok(config_path)
2835 }
2836
2837 /// Clear the API key from config-file storage.
2838 ///
2839 /// `/logout` calls this to wipe credentials so the next request can't
2840 /// silently use a stale config key (#343). The function strips the legacy
2841 /// root `api_key = ...` line *and* every `api_key` line nested in a
2842 /// `[providers.<name>]` table.
2843 ///
2844 /// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally
2845 /// **not** unset — they are managed by the user's shell and outside the
2846 /// CLI's purview. `Config::deepseek_api_key`'s explicit-override path
2847 /// (Path 0) ensures a freshly-entered key still wins over a stale env
2848 /// var that lingers from a previous session.
2849 pub fn clear_api_key() -> Result<()> {
2850 // Strip api_key lines from config.toml, including provider-scoped nested
2851 // entries. Clearing a config file must not trigger platform credential
2852 // prompts.
2853 let config_path = default_config_path()
2854 .context("Failed to resolve config path: home directory not found.")?;
2855
2856 if !config_path.exists() {
2857 return Ok(());
2858 }
2859
2860 let existing = fs::read_to_string(&config_path)?;
2861 let mut result = String::new();
2862
2863 for line in existing.lines() {
2864 // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it
2865 // appears as the leading non-whitespace token.
2866 let trimmed = line.trim_start();
2867 if trimmed.strip_prefix("api_key").is_some_and(|rest| {
2868 let rest = rest.trim_start();
2869 rest.is_empty() || rest.starts_with('=')
2870 }) {
2871 continue;
2872 }
2873 result.push_str(line);
2874 result.push('\n');
2875 }
2876
2877 write_config_file_secure(&config_path, &result)
2878 .with_context(|| format!("Failed to write config to {}", config_path.display()))?;
2879 log_sensitive_event(
2880 "credential.clear",
2881 json!({
2882 "backend": "config_file",
2883 "config_path": config_path.display().to_string(),
2884 "scope": "root_and_provider_keys",
2885 }),
2886 );
2887
2888 Ok(())
2889 }
2890
2891 #[cfg(test)]
2892 mod tests {
2893 use super::*;
2894 use crate::test_support::lock_test_env;
2895 use std::collections::HashMap;
2896 use std::env;
2897 use std::ffi::OsString;
2898 #[cfg(unix)]
2899 use std::os::unix::fs::PermissionsExt;
2900 use std::time::{SystemTime, UNIX_EPOCH};
2901
2902 struct EnvGuard {
2903 home: Option<OsString>,
2904 userprofile: Option<OsString>,
2905 deepseek_config_path: Option<OsString>,
2906 deepseek_provider: Option<OsString>,
2907 deepseek_api_key: Option<OsString>,
2908 deepseek_base_url: Option<OsString>,
2909 deepseek_http_headers: Option<OsString>,
2910 deepseek_model: Option<OsString>,
2911 deepseek_default_text_model: Option<OsString>,
2912 nvidia_api_key: Option<OsString>,
2913 nvidia_nim_api_key: Option<OsString>,
2914 nim_base_url: Option<OsString>,
2915 nvidia_base_url: Option<OsString>,
2916 nvidia_nim_base_url: Option<OsString>,
2917 nvidia_nim_model: Option<OsString>,
2918 openrouter_api_key: Option<OsString>,
2919 openrouter_base_url: Option<OsString>,
2920 novita_api_key: Option<OsString>,
2921 novita_base_url: Option<OsString>,
2922 fireworks_api_key: Option<OsString>,
2923 fireworks_base_url: Option<OsString>,
2924 sglang_api_key: Option<OsString>,
2925 sglang_base_url: Option<OsString>,
2926 sglang_model: Option<OsString>,
2927 vllm_api_key: Option<OsString>,
2928 vllm_base_url: Option<OsString>,
2929 vllm_model: Option<OsString>,
2930 }
2931
2932 impl EnvGuard {
2933 fn new(home: &Path) -> Self {
2934 let home_str = OsString::from(home.as_os_str());
2935 let config_path = home.join(".deepseek").join("config.toml");
2936 let config_str = OsString::from(config_path.as_os_str());
2937 let home_prev = env::var_os("HOME");
2938 let userprofile_prev = env::var_os("USERPROFILE");
2939 let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH");
2940 let deepseek_provider_prev = env::var_os("DEEPSEEK_PROVIDER");
2941 let api_key_prev = env::var_os("DEEPSEEK_API_KEY");
2942 let base_url_prev = env::var_os("DEEPSEEK_BASE_URL");
2943 let http_headers_prev = env::var_os("DEEPSEEK_HTTP_HEADERS");
2944 let model_prev = env::var_os("DEEPSEEK_MODEL");
2945 let default_text_model_prev = env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL");
2946 let nvidia_api_key_prev = env::var_os("NVIDIA_API_KEY");
2947 let nvidia_nim_api_key_prev = env::var_os("NVIDIA_NIM_API_KEY");
2948 let nim_base_url_prev = env::var_os("NIM_BASE_URL");
2949 let nvidia_base_url_prev = env::var_os("NVIDIA_BASE_URL");
2950 let nvidia_nim_base_url_prev = env::var_os("NVIDIA_NIM_BASE_URL");
2951 let nvidia_nim_model_prev = env::var_os("NVIDIA_NIM_MODEL");
2952 let openrouter_api_key_prev = env::var_os("OPENROUTER_API_KEY");
2953 let openrouter_base_url_prev = env::var_os("OPENROUTER_BASE_URL");
2954 let novita_api_key_prev = env::var_os("NOVITA_API_KEY");
2955 let novita_base_url_prev = env::var_os("NOVITA_BASE_URL");
2956 let fireworks_api_key_prev = env::var_os("FIREWORKS_API_KEY");
2957 let fireworks_base_url_prev = env::var_os("FIREWORKS_BASE_URL");
2958 let sglang_api_key_prev = env::var_os("SGLANG_API_KEY");
2959 let sglang_base_url_prev = env::var_os("SGLANG_BASE_URL");
2960 let sglang_model_prev = env::var_os("SGLANG_MODEL");
2961 let vllm_api_key_prev = env::var_os("VLLM_API_KEY");
2962 let vllm_base_url_prev = env::var_os("VLLM_BASE_URL");
2963 let vllm_model_prev = env::var_os("VLLM_MODEL");
2964 // Safety: test-only environment mutation guarded by a global mutex.
2965 unsafe {
2966 env::set_var("HOME", &home_str);
2967 env::set_var("USERPROFILE", &home_str);
2968 env::set_var("DEEPSEEK_CONFIG_PATH", &config_str);
2969 env::remove_var("DEEPSEEK_PROVIDER");
2970 env::remove_var("DEEPSEEK_API_KEY");
2971 env::remove_var("DEEPSEEK_BASE_URL");
2972 env::remove_var("DEEPSEEK_HTTP_HEADERS");
2973 env::remove_var("DEEPSEEK_MODEL");
2974 env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL");
2975 env::remove_var("NVIDIA_API_KEY");
2976 env::remove_var("NVIDIA_NIM_API_KEY");
2977 env::remove_var("NIM_BASE_URL");
2978 env::remove_var("NVIDIA_BASE_URL");
2979 env::remove_var("NVIDIA_NIM_BASE_URL");
2980 env::remove_var("NVIDIA_NIM_MODEL");
2981 env::remove_var("OPENROUTER_API_KEY");
2982 env::remove_var("OPENROUTER_BASE_URL");
2983 env::remove_var("NOVITA_API_KEY");
2984 env::remove_var("NOVITA_BASE_URL");
2985 env::remove_var("FIREWORKS_API_KEY");
2986 env::remove_var("FIREWORKS_BASE_URL");
2987 env::remove_var("SGLANG_API_KEY");
2988 env::remove_var("SGLANG_BASE_URL");
2989 env::remove_var("SGLANG_MODEL");
2990 env::remove_var("VLLM_API_KEY");
2991 env::remove_var("VLLM_BASE_URL");
2992 env::remove_var("VLLM_MODEL");
2993 }
2994 Self {
2995 home: home_prev,
2996 userprofile: userprofile_prev,
2997 deepseek_config_path: deepseek_config_prev,
2998 deepseek_provider: deepseek_provider_prev,
2999 deepseek_api_key: api_key_prev,
3000 deepseek_base_url: base_url_prev,
3001 deepseek_http_headers: http_headers_prev,
3002 deepseek_model: model_prev,
3003 deepseek_default_text_model: default_text_model_prev,
3004 nvidia_api_key: nvidia_api_key_prev,
3005 nvidia_nim_api_key: nvidia_nim_api_key_prev,
3006 nim_base_url: nim_base_url_prev,
3007 nvidia_base_url: nvidia_base_url_prev,
3008 nvidia_nim_base_url: nvidia_nim_base_url_prev,
3009 nvidia_nim_model: nvidia_nim_model_prev,
3010 openrouter_api_key: openrouter_api_key_prev,
3011 openrouter_base_url: openrouter_base_url_prev,
3012 novita_api_key: novita_api_key_prev,
3013 novita_base_url: novita_base_url_prev,
3014 fireworks_api_key: fireworks_api_key_prev,
3015 fireworks_base_url: fireworks_base_url_prev,
3016 sglang_api_key: sglang_api_key_prev,
3017 sglang_base_url: sglang_base_url_prev,
3018 sglang_model: sglang_model_prev,
3019 vllm_api_key: vllm_api_key_prev,
3020 vllm_base_url: vllm_base_url_prev,
3021 vllm_model: vllm_model_prev,
3022 }
3023 }
3024 }
3025
3026 impl Drop for EnvGuard {
3027 fn drop(&mut self) {
3028 // Safety: test-only environment mutation guarded by a global mutex.
3029 unsafe {
3030 Self::restore_var("HOME", self.home.take());
3031 Self::restore_var("USERPROFILE", self.userprofile.take());
3032 Self::restore_var("DEEPSEEK_CONFIG_PATH", self.deepseek_config_path.take());
3033 Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take());
3034 Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take());
3035 Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take());
3036 Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take());
3037 Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take());
3038 Self::restore_var(
3039 "DEEPSEEK_DEFAULT_TEXT_MODEL",
3040 self.deepseek_default_text_model.take(),
3041 );
3042 Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take());
3043 Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take());
3044 Self::restore_var("NIM_BASE_URL", self.nim_base_url.take());
3045 Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take());
3046 Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take());
3047 Self::restore_var("NVIDIA_NIM_MODEL", self.nvidia_nim_model.take());
3048 Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take());
3049 Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take());
3050 Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take());
3051 Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take());
3052 Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take());
3053 Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take());
3054 Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take());
3055 Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take());
3056 Self::restore_var("SGLANG_MODEL", self.sglang_model.take());
3057 Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take());
3058 Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take());
3059 Self::restore_var("VLLM_MODEL", self.vllm_model.take());
3060 }
3061 }
3062 }
3063
3064 impl EnvGuard {
3065 /// Restore an env var to its prior value (or remove it if it was unset).
3066 ///
3067 /// # Safety
3068 /// Must only be called from test code guarded by a global mutex.
3069 unsafe fn restore_var(key: &str, prev: Option<OsString>) {
3070 if let Some(value) = prev {
3071 unsafe { env::set_var(key, value) };
3072 } else {
3073 unsafe { env::remove_var(key) };
3074 }
3075 }
3076 }
3077
3078 #[test]
3079 fn max_subagents_defaults_to_ten() {
3080 assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);
3081 assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);
3082 }
3083
3084 #[test]
3085 fn subagents_max_concurrent_overrides_top_level_cap() {
3086 let config = Config {
3087 max_subagents: Some(3),
3088 subagents: Some(SubagentsConfig {
3089 max_concurrent: Some(12),
3090 ..SubagentsConfig::default()
3091 }),
3092 ..Config::default()
3093 };
3094
3095 assert_eq!(config.max_subagents(), 12);
3096 }
3097
3098 #[test]
3099 fn max_subagents_clamps_subagents_max_concurrent() {
3100 let low = Config {
3101 subagents: Some(SubagentsConfig {
3102 max_concurrent: Some(0),
3103 ..SubagentsConfig::default()
3104 }),
3105 ..Config::default()
3106 };
3107 assert_eq!(low.max_subagents(), 1);
3108
3109 let high = Config {
3110 subagents: Some(SubagentsConfig {
3111 max_concurrent: Some(MAX_SUBAGENTS + 10),
3112 ..SubagentsConfig::default()
3113 }),
3114 ..Config::default()
3115 };
3116 assert_eq!(high.max_subagents(), MAX_SUBAGENTS);
3117 }
3118
3119 #[test]
3120 fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {
3121 // `save_api_key` writes to the shared user config file. This
3122 // pins the boring v0.8.8 setup path and avoids platform
3123 // credential prompts during onboarding.
3124 let _lock = lock_test_env();
3125 let nanos = SystemTime::now()
3126 .duration_since(UNIX_EPOCH)
3127 .unwrap()
3128 .as_nanos();
3129 let temp_root = env::temp_dir().join(format!(
3130 "deepseek-tui-test-{}-{}",
3131 std::process::id(),
3132 nanos
3133 ));
3134 fs::create_dir_all(&temp_root)?;
3135 let _guard = EnvGuard::new(&temp_root);
3136
3137 let saved = save_api_key("test-key")?;
3138 let expected = temp_root.join(".deepseek").join("config.toml");
3139 assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));
3140 assert_eq!(saved.describe(), expected.display().to_string());
3141
3142 let contents = fs::read_to_string(&expected)?;
3143 assert!(contents.contains("api_key = \""));
3144
3145 #[cfg(unix)]
3146 {
3147 assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);
3148 let parent = expected.parent().expect("config has parent dir");
3149 assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);
3150
3151 fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;
3152 save_api_key("second-test-key")?;
3153 assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);
3154 }
3155 Ok(())
3156 }
3157
3158 #[test]
3159 fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {
3160 let _lock = lock_test_env();
3161 let nanos = SystemTime::now()
3162 .duration_since(UNIX_EPOCH)
3163 .unwrap()
3164 .as_nanos();
3165 let temp_root = env::temp_dir().join(format!(
3166 "deepseek-tui-first-run-config-{}-{}",
3167 std::process::id(),
3168 nanos
3169 ));
3170 fs::create_dir_all(&temp_root)?;
3171 let _guard = EnvGuard::new(&temp_root);
3172
3173 let created = ensure_config_file_exists(None)?.expect("should create config");
3174 let content = fs::read_to_string(&created)?;
3175
3176 assert_eq!(created, temp_root.join(".deepseek").join("config.toml"));
3177 assert!(content.contains("default_text_model = \"deepseek-v4-pro\""));
3178 assert!(content.contains("reasoning_effort = \"auto\""));
3179 assert!(!content.contains("api_key ="));
3180 assert!(ensure_config_file_exists(None)?.is_none());
3181 Ok(())
3182 }
3183
3184 #[test]
3185 fn workspace_trust_round_trips_through_global_config() -> Result<()> {
3186 let _lock = lock_test_env();
3187 let nanos = SystemTime::now()
3188 .duration_since(UNIX_EPOCH)
3189 .unwrap()
3190 .as_nanos();
3191 let temp_root = env::temp_dir().join(format!(
3192 "deepseek-tui-workspace-trust-{}-{}",
3193 std::process::id(),
3194 nanos
3195 ));
3196 fs::create_dir_all(&temp_root)?;
3197 let _guard = EnvGuard::new(&temp_root);
3198 let workspace = temp_root.join("project");
3199 fs::create_dir_all(&workspace)?;
3200
3201 assert!(!is_workspace_trusted(&workspace));
3202 let saved = save_workspace_trust(&workspace)?;
3203
3204 assert_eq!(saved, temp_root.join(".deepseek").join("config.toml"));
3205 assert!(is_workspace_trusted(&workspace));
3206 assert!(!crate::tui::onboarding::needs_trust(&workspace));
3207 assert!(
3208 !workspace.join(".deepseek").exists(),
3209 "trust persistence must not create a project-local .deepseek directory"
3210 );
3211
3212 let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;
3213 assert_eq!(
3214 workspace_trust_level_from_doc(&parsed, &workspace),
3215 Some("trusted")
3216 );
3217 Ok(())
3218 }
3219
3220 #[test]
3221 fn workspace_trust_reads_existing_projects_table() -> Result<()> {
3222 let _lock = lock_test_env();
3223 let nanos = SystemTime::now()
3224 .duration_since(UNIX_EPOCH)
3225 .unwrap()
3226 .as_nanos();
3227 let temp_root = env::temp_dir().join(format!(
3228 "deepseek-tui-existing-project-trust-{}-{}",
3229 std::process::id(),
3230 nanos
3231 ));
3232 fs::create_dir_all(&temp_root)?;
3233 let _guard = EnvGuard::new(&temp_root);
3234 let workspace = temp_root.join("project");
3235 fs::create_dir_all(&workspace)?;
3236 let config_path = temp_root.join(".deepseek").join("config.toml");
3237 fs::create_dir_all(config_path.parent().unwrap())?;
3238 fs::write(
3239 &config_path,
3240 format!(
3241 "[projects.\"{}\"]\ntrust_level = \"trusted\"\n",
3242 workspace_config_key(&workspace)
3243 .replace('\\', "\\\\")
3244 .replace('"', "\\\"")
3245 ),
3246 )?;
3247
3248 assert!(is_workspace_trusted(&workspace));
3249 assert!(!crate::tui::onboarding::needs_trust(&workspace));
3250 Ok(())
3251 }
3252
3253 #[test]
3254 fn save_api_key_rejects_empty_input() {
3255 let _lock = lock_test_env();
3256 let err = save_api_key(" ").expect_err("empty should bail");
3257 assert!(
3258 err.to_string().contains("empty"),
3259 "expected error to mention empty, got: {err}"
3260 );
3261 }
3262
3263 #[test]
3264 fn saved_credential_describe_returns_config_file_path() {
3265 let cf = SavedCredential::ConfigFile(PathBuf::from("/tmp/x.toml"));
3266 assert_eq!(cf.describe(), "/tmp/x.toml");
3267 }
3268
3269 /// #593: the dual-write outcome describes both targets so the
3270 /// onboarding toast (`API key saved to {describe}`) tells the user
3271 /// the key landed in *both* the keyring and the config file —
3272 /// which is the whole point of the fix (defeats stale-keyring
3273 /// shadow while keeping the config file inspectable).
3274 #[test]
3275 fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {
3276 let dual = SavedCredential::KeyringAndConfigFile {
3277 backend: "system keyring".to_string(),
3278 path: PathBuf::from("/tmp/x.toml"),
3279 };
3280 assert_eq!(
3281 dual.describe(),
3282 "OS keyring (system keyring) and /tmp/x.toml"
3283 );
3284 }
3285
3286 #[test]
3287 fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {
3288 // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free
3289 // sources used by `Config::deepseek_api_key` (in-memory override,
3290 // env var, config-file slot).
3291 let _lock = lock_test_env();
3292 // Explicit in-memory key wins over every other source per
3293 // `Config::deepseek_api_key`'s "Path 0" override.
3294 let cfg = Config {
3295 api_key: Some("sk-in-memory-override".to_string()),
3296 ..Default::default()
3297 };
3298 assert!(
3299 has_api_key(&cfg),
3300 "in-memory override must be detected as a usable key"
3301 );
3302
3303 // Env var path.
3304 let env_cfg = Config::default();
3305 unsafe {
3306 std::env::set_var("DEEPSEEK_API_KEY", "sk-test-from-env");
3307 }
3308 assert!(
3309 has_api_key(&env_cfg),
3310 "env-var key must be detected even with empty config"
3311 );
3312 unsafe {
3313 std::env::remove_var("DEEPSEEK_API_KEY");
3314 }
3315 Ok(())
3316 }
3317
3318 /// Regression for #343: clear_api_key strips both the root `api_key`
3319 /// and any nested `[providers.<name>].api_key` lines from config.toml
3320 /// so a stale credential can't shadow a fresh login.
3321 #[test]
3322 fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {
3323 let _lock = lock_test_env();
3324 let nanos = SystemTime::now()
3325 .duration_since(UNIX_EPOCH)
3326 .unwrap()
3327 .as_nanos();
3328 let temp_root = env::temp_dir().join(format!(
3329 "deepseek-tui-clear-{}-{}",
3330 std::process::id(),
3331 nanos
3332 ));
3333 fs::create_dir_all(&temp_root)?;
3334 let _guard = EnvGuard::new(&temp_root);
3335
3336 let config_dir = temp_root.join(".deepseek");
3337 fs::create_dir_all(&config_dir)?;
3338 let config_path = config_dir.join("config.toml");
3339 fs::write(
3340 &config_path,
3341 r#"api_key = "old-root-key"
3342 default_text_model = "deepseek-v4-flash"
3343
3344 [providers.deepseek]
3345 api_key = "old-provider-key"
3346 base_url = "https://api.deepseek.com"
3347
3348 [providers.openrouter]
3349 api_key = "old-openrouter-key"
3350 "#,
3351 )?;
3352
3353 clear_api_key()?;
3354
3355 let after = fs::read_to_string(&config_path)?;
3356 assert!(
3357 !after.contains("old-root-key"),
3358 "root api_key must be stripped: {after}"
3359 );
3360 assert!(
3361 !after.contains("old-provider-key"),
3362 "provider-scoped deepseek key must be stripped: {after}"
3363 );
3364 assert!(
3365 !after.contains("old-openrouter-key"),
3366 "provider-scoped openrouter key must be stripped: {after}"
3367 );
3368 // Non-credential lines must survive.
3369 assert!(after.contains("default_text_model"));
3370 assert!(after.contains("base_url"));
3371 Ok(())
3372 }
3373
3374 /// Regression for #343: explicit in-memory `api_key` (non-empty,
3375 /// non-sentinel) wins over env/config so a freshly-typed onboarding
3376 /// key takes effect immediately.
3377 #[test]
3378 fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {
3379 let _lock = lock_test_env();
3380 let nanos = SystemTime::now()
3381 .duration_since(UNIX_EPOCH)
3382 .unwrap()
3383 .as_nanos();
3384 let temp_root = env::temp_dir().join(format!(
3385 "deepseek-tui-override-{}-{}",
3386 std::process::id(),
3387 nanos
3388 ));
3389 fs::create_dir_all(&temp_root)?;
3390 let _guard = EnvGuard::new(&temp_root);
3391
3392 let config = Config {
3393 api_key: Some("freshly-typed-key".to_string()),
3394 ..Config::default()
3395 };
3396 let resolved = config
3397 .deepseek_api_key()
3398 .expect("explicit override must resolve");
3399 assert_eq!(resolved, "freshly-typed-key");
3400 Ok(())
3401 }
3402
3403 #[test]
3404 fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {
3405 let _lock = lock_test_env();
3406 let nanos = SystemTime::now()
3407 .duration_since(UNIX_EPOCH)
3408 .unwrap()
3409 .as_nanos();
3410 let temp_root = env::temp_dir().join(format!(
3411 "deepseek-tui-config-over-env-{}-{}",
3412 std::process::id(),
3413 nanos
3414 ));
3415 fs::create_dir_all(&temp_root)?;
3416 let _guard = EnvGuard::new(&temp_root);
3417
3418 unsafe {
3419 env::set_var("DEEPSEEK_API_KEY", "stale-env-key");
3420 }
3421 let config = Config {
3422 api_key: Some("fresh-config-key".to_string()),
3423 ..Config::default()
3424 };
3425 assert_eq!(config.deepseek_api_key()?, "fresh-config-key");
3426 unsafe {
3427 env::remove_var("DEEPSEEK_API_KEY");
3428 }
3429 Ok(())
3430 }
3431
3432 #[test]
3433 fn active_provider_detects_env_only_api_key() -> Result<()> {
3434 let _lock = lock_test_env();
3435 let temp_root =
3436 env::temp_dir().join(format!("deepseek-tui-env-only-key-{}", std::process::id()));
3437 fs::create_dir_all(&temp_root)?;
3438 let _guard = EnvGuard::new(&temp_root);
3439
3440 unsafe {
3441 env::set_var("DEEPSEEK_API_KEY", "env-only-key");
3442 }
3443 let mut config = Config::default();
3444 assert!(active_provider_has_env_api_key(&config));
3445 assert!(!active_provider_has_config_api_key(&config));
3446 assert!(active_provider_uses_env_only_api_key(&config));
3447
3448 config.api_key = Some("config-key".to_string());
3449 assert!(active_provider_has_config_api_key(&config));
3450 assert!(!active_provider_uses_env_only_api_key(&config));
3451
3452 unsafe {
3453 env::remove_var("DEEPSEEK_API_KEY");
3454 }
3455 Ok(())
3456 }
3457
3458 #[test]
3459 fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {
3460 let _lock = lock_test_env();
3461 let nanos = SystemTime::now()
3462 .duration_since(UNIX_EPOCH)
3463 .unwrap()
3464 .as_nanos();
3465 let temp_root = env::temp_dir().join(format!(
3466 "deepseek-tui-sentinel-{}-{}",
3467 std::process::id(),
3468 nanos
3469 ));
3470 fs::create_dir_all(&temp_root)?;
3471 let _guard = EnvGuard::new(&temp_root);
3472
3473 let config = Config {
3474 api_key: Some(API_KEYRING_SENTINEL.to_string()),
3475 ..Config::default()
3476 };
3477 // Sentinel must not be treated as a real key — the resolver should
3478 // fall through to env / config-provider and ultimately bail out
3479 // with a "key not found" error.
3480 let _err = config
3481 .deepseek_api_key()
3482 .expect_err("sentinel placeholder must not satisfy the API key check");
3483 Ok(())
3484 }
3485
3486 #[test]
3487 fn test_tilde_expansion_in_paths() -> Result<()> {
3488 let _lock = lock_test_env();
3489 let nanos = SystemTime::now()
3490 .duration_since(UNIX_EPOCH)
3491 .unwrap()
3492 .as_nanos();
3493 let temp_root = env::temp_dir().join(format!(
3494 "deepseek-tui-tilde-test-{}-{}",
3495 std::process::id(),
3496 nanos
3497 ));
3498 fs::create_dir_all(&temp_root)?;
3499 let _guard = EnvGuard::new(&temp_root);
3500
3501 let config = Config {
3502 skills_dir: Some("~/.deepseek/skills".to_string()),
3503 ..Default::default()
3504 };
3505 let expected_skills = temp_root.join(".deepseek").join("skills");
3506 let actual_skills = config.skills_dir();
3507 assert_eq!(
3508 actual_skills.components().collect::<Vec<_>>(),
3509 expected_skills.components().collect::<Vec<_>>()
3510 );
3511
3512 Ok(())
3513 }
3514
3515 #[test]
3516 fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {
3517 let _lock = lock_test_env();
3518 let nanos = SystemTime::now()
3519 .duration_since(UNIX_EPOCH)
3520 .unwrap()
3521 .as_nanos();
3522 let temp_root = env::temp_dir().join(format!(
3523 "deepseek-tui-load-tilde-test-{}-{}",
3524 std::process::id(),
3525 nanos
3526 ));
3527 fs::create_dir_all(&temp_root)?;
3528 let _guard = EnvGuard::new(&temp_root);
3529
3530 let config_path = temp_root.join(".custom-deepseek").join("config.toml");
3531 ensure_parent_dir(&config_path)?;
3532 fs::write(&config_path, "api_key = \"test-key\"\n")?;
3533
3534 // Safety: test-only environment mutation guarded by a global mutex.
3535 unsafe {
3536 env::set_var("DEEPSEEK_CONFIG_PATH", "~/.custom-deepseek/config.toml");
3537 }
3538
3539 let config = Config::load(None, None)?;
3540 assert_eq!(config.api_key.as_deref(), Some("test-key"));
3541 Ok(())
3542 }
3543
3544 #[test]
3545 fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {
3546 let _lock = lock_test_env();
3547 let nanos = SystemTime::now()
3548 .duration_since(UNIX_EPOCH)
3549 .unwrap()
3550 .as_nanos();
3551 let temp_root = env::temp_dir().join(format!(
3552 "deepseek-tui-load-fallback-test-{}-{}",
3553 std::process::id(),
3554 nanos
3555 ));
3556 fs::create_dir_all(&temp_root)?;
3557 let _guard = EnvGuard::new(&temp_root);
3558
3559 let home_config = temp_root.join(".deepseek").join("config.toml");
3560 ensure_parent_dir(&home_config)?;
3561 fs::write(&home_config, "api_key = \"home-key\"\n")?;
3562
3563 // Safety: test-only environment mutation guarded by a global mutex.
3564 unsafe {
3565 env::set_var(
3566 "DEEPSEEK_CONFIG_PATH",
3567 temp_root.join("missing-config.toml").as_os_str(),
3568 );
3569 }
3570
3571 let config = Config::load(None, None)?;
3572 assert_eq!(config.api_key.as_deref(), Some("home-key"));
3573 Ok(())
3574 }
3575
3576 #[test]
3577 fn test_nonexistent_profile_error() {
3578 let mut profiles = HashMap::new();
3579 profiles.insert("work".to_string(), Config::default());
3580 let config = ConfigFile {
3581 base: Config::default(),
3582 profiles: Some(profiles),
3583 };
3584
3585 let err = apply_profile(config, Some("nonexistent")).unwrap_err();
3586 let message = err.to_string();
3587 assert!(message.contains("Profile 'nonexistent' not found"));
3588 assert!(message.contains("Available profiles"));
3589 assert!(message.contains("work"));
3590 }
3591
3592 #[test]
3593 fn test_profile_with_no_profiles_section() {
3594 let config = ConfigFile {
3595 base: Config::default(),
3596 profiles: None,
3597 };
3598
3599 let err = apply_profile(config, Some("missing")).unwrap_err();
3600 assert!(err.to_string().contains("Available profiles: none"));
3601 }
3602
3603 #[test]
3604 fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {
3605 let _lock = lock_test_env();
3606 let nanos = SystemTime::now()
3607 .duration_since(UNIX_EPOCH)
3608 .unwrap()
3609 .as_nanos();
3610 let temp_root = env::temp_dir().join(format!(
3611 "deepseek-tui-api-key-test-{}-{}",
3612 std::process::id(),
3613 nanos
3614 ));
3615 fs::create_dir_all(&temp_root)?;
3616 let _guard = EnvGuard::new(&temp_root);
3617
3618 let config_path = temp_root.join(".deepseek").join("config.toml");
3619 ensure_parent_dir(&config_path)?;
3620 fs::write(
3621 &config_path,
3622 "api_key_backup = \"old\"\napi_key = \"current\"\n",
3623 )?;
3624
3625 let saved = save_api_key("new-key")?;
3626 assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));
3627
3628 let contents = fs::read_to_string(&config_path)?;
3629 assert!(contents.contains("api_key_backup = \"old\""));
3630 assert!(contents.contains("api_key = \""));
3631 Ok(())
3632 }
3633
3634 #[test]
3635 fn test_empty_api_key_rejected() {
3636 let config = Config {
3637 api_key: Some(" ".to_string()),
3638 ..Default::default()
3639 };
3640 assert!(config.validate().is_err());
3641 }
3642
3643 #[test]
3644 fn test_missing_api_key_allowed() -> Result<()> {
3645 let config = Config::default();
3646 config.validate()?;
3647 Ok(())
3648 }
3649
3650 #[test]
3651 fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {
3652 let _lock = lock_test_env();
3653 let nanos = SystemTime::now()
3654 .duration_since(UNIX_EPOCH)
3655 .unwrap()
3656 .as_nanos();
3657 let temp_root = env::temp_dir().join(format!(
3658 "deepseek-tui-empty-key-{}-{}",
3659 std::process::id(),
3660 nanos
3661 ));
3662 fs::create_dir_all(&temp_root)?;
3663 let _guard = EnvGuard::new(&temp_root);
3664
3665 // Simulate a fresh user who copied .env.example to .env without
3666 // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.
3667 // Safety: test-only environment mutation guarded by a global mutex.
3668 unsafe {
3669 env::set_var("DEEPSEEK_API_KEY", "");
3670 }
3671
3672 let mut config = Config {
3673 api_key: Some("from-config-file".to_string()),
3674 ..Default::default()
3675 };
3676 apply_env_overrides(&mut config);
3677
3678 assert_eq!(config.api_key.as_deref(), Some("from-config-file"));
3679 config.validate()?;
3680 Ok(())
3681 }
3682
3683 #[test]
3684 fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {
3685 let _lock = lock_test_env();
3686 let nanos = SystemTime::now()
3687 .duration_since(UNIX_EPOCH)
3688 .unwrap()
3689 .as_nanos();
3690 let temp_root = env::temp_dir().join(format!(
3691 "deepseek-tui-env-key-not-config-{}-{}",
3692 std::process::id(),
3693 nanos
3694 ));
3695 fs::create_dir_all(&temp_root)?;
3696 let _guard = EnvGuard::new(&temp_root);
3697
3698 unsafe {
3699 env::set_var("DEEPSEEK_API_KEY", "env-key");
3700 }
3701 let mut config = Config::default();
3702 apply_env_overrides(&mut config);
3703
3704 assert_eq!(config.api_key, None);
3705 assert_eq!(config.deepseek_api_key()?, "env-key");
3706 unsafe {
3707 env::remove_var("DEEPSEEK_API_KEY");
3708 }
3709 Ok(())
3710 }
3711
3712 #[test]
3713 fn normalize_model_name_preserves_v_series_snapshots() {
3714 // v4 canonical forms still resolve
3715 assert_eq!(
3716 normalize_model_name("deepseek-v4-pro").as_deref(),
3717 Some("deepseek-v4-pro")
3718 );
3719 assert_eq!(
3720 normalize_model_name("deepseek-v4pro").as_deref(),
3721 Some("deepseek-v4-pro")
3722 );
3723 // v-series dated snapshots pass through unchanged
3724 assert_eq!(
3725 normalize_model_name("deepseek-v4-flash-20260423").as_deref(),
3726 Some("deepseek-v4-flash-20260423")
3727 );
3728 // future v-series identities pass through
3729 assert_eq!(
3730 normalize_model_name("deepseek-v5-pro-20270101").as_deref(),
3731 Some("deepseek-v5-pro-20270101")
3732 );
3733 // legacy names pass through unchanged — server decides
3734 assert_eq!(
3735 normalize_model_name("deepseek-chat").as_deref(),
3736 Some("deepseek-chat")
3737 );
3738 // cross-provider names still normalize
3739 assert_eq!(
3740 normalize_model_name("deepseek-ai/deepseek-v4-pro").as_deref(),
3741 Some("deepseek-ai/deepseek-v4-pro")
3742 );
3743 // preserve exact case for providers that require case-sensitive model IDs
3744 assert_eq!(
3745 normalize_model_name("DeepSeek-V4-Pro").as_deref(),
3746 Some("DeepSeek-V4-Pro")
3747 );
3748 assert_eq!(
3749 normalize_model_name("deepseek-ai/DeepSeek-V4-Pro").as_deref(),
3750 Some("deepseek-ai/DeepSeek-V4-Pro")
3751 );
3752 }
3753
3754 #[test]
3755 fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {
3756 assert_eq!(
3757 normalize_model_for_provider(ApiProvider::Deepseek, "DeepSeek-V4-Pro").as_deref(),
3758 Some("DeepSeek-V4-Pro")
3759 );
3760 assert_eq!(
3761 normalize_model_for_provider(ApiProvider::NvidiaNim, "DeepSeek-V4-Pro").as_deref(),
3762 Some(DEFAULT_NVIDIA_NIM_MODEL)
3763 );
3764 }
3765
3766 #[test]
3767 fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {
3768 assert!(normalize_model_name("gpt-4o").is_none());
3769 assert!(normalize_model_name("deepseek v4").is_none());
3770 assert!(normalize_model_name("").is_none());
3771 }
3772
3773 #[test]
3774 fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {
3775 assert_eq!(
3776 normalize_model_name("accounts/fireworks/models/deepseek-v4-flash").as_deref(),
3777 Some("accounts/fireworks/models/deepseek-v4-flash")
3778 );
3779 assert_eq!(
3780 normalize_model_name("provider/deepseek-ai/deepseek-v4-pro").as_deref(),
3781 Some("provider/deepseek-ai/deepseek-v4-pro")
3782 );
3783 }
3784
3785 #[test]
3786 fn default_context_seams_are_opt_in() {
3787 let config = Config::default();
3788 assert!(!config.context.enabled.unwrap_or(false));
3789 assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);
3790 assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);
3791 assert_eq!(
3792 config
3793 .context
3794 .seam_model
3795 .as_deref()
3796 .unwrap_or("deepseek-v4-flash"),
3797 "deepseek-v4-flash"
3798 );
3799 }
3800
3801 #[test]
3802 fn profile_without_context_does_not_disable_base_context() {
3803 let mut profiles = HashMap::new();
3804 profiles.insert("work".to_string(), Config::default());
3805 let config = ConfigFile {
3806 base: Config {
3807 context: ContextConfig {
3808 enabled: Some(true),
3809 ..Default::default()
3810 },
3811 ..Default::default()
3812 },
3813 profiles: Some(profiles),
3814 };
3815
3816 let merged = apply_profile(config, Some("work")).expect("profile");
3817 assert_eq!(merged.context.enabled, Some(true));
3818 }
3819
3820 #[test]
3821 fn validate_accepts_future_deepseek_model_id() -> Result<()> {
3822 let config = Config {
3823 default_text_model: Some("deepseek-v4".to_string()),
3824 ..Default::default()
3825 };
3826 config.validate()?;
3827 Ok(())
3828 }
3829
3830 #[test]
3831 fn validate_accepts_auto_default_text_model() -> Result<()> {
3832 let config = Config {
3833 default_text_model: Some("auto".to_string()),
3834 ..Default::default()
3835 };
3836 config.validate()?;
3837 assert_eq!(config.default_model(), "auto");
3838 Ok(())
3839 }
3840
3841 #[test]
3842 fn deepseek_model_env_overrides_default_text_model() -> Result<()> {
3843 let _lock = lock_test_env();
3844 let nanos = SystemTime::now()
3845 .duration_since(UNIX_EPOCH)
3846 .unwrap()
3847 .as_nanos();
3848 let temp_root = env::temp_dir().join(format!(
3849 "deepseek-tui-model-env-test-{}-{}",
3850 std::process::id(),
3851 nanos
3852 ));
3853 fs::create_dir_all(&temp_root)?;
3854 let _guard = EnvGuard::new(&temp_root);
3855
3856 // Safety: test-only environment mutation guarded by a global mutex.
3857 unsafe {
3858 env::set_var("DEEPSEEK_MODEL", "deepseek-v4-flash-20260423");
3859 }
3860
3861 let config = Config::load(None, None)?;
3862 // v-series snapshots pass through unchanged — no alias folding
3863 assert_eq!(
3864 config.default_text_model.as_deref(),
3865 Some("deepseek-v4-flash-20260423")
3866 );
3867 Ok(())
3868 }
3869
3870 #[test]
3871 fn http_headers_load_from_root_config() -> Result<()> {
3872 let _lock = lock_test_env();
3873 let nanos = SystemTime::now()
3874 .duration_since(UNIX_EPOCH)
3875 .unwrap()
3876 .as_nanos();
3877 let temp_root = env::temp_dir().join(format!(
3878 "deepseek-tui-http-headers-root-{}-{}",
3879 std::process::id(),
3880 nanos
3881 ));
3882 fs::create_dir_all(&temp_root)?;
3883 let _guard = EnvGuard::new(&temp_root);
3884
3885 let config_path = temp_root.join(".deepseek").join("config.toml");
3886 ensure_parent_dir(&config_path)?;
3887 fs::write(
3888 &config_path,
3889 r#"
3890 api_key = "test-key"
3891 http_headers = { "X-Model-Provider-Id" = "tongyi" }
3892 "#,
3893 )?;
3894
3895 let config = Config::load(None, None)?;
3896 assert_eq!(
3897 config
3898 .http_headers()
3899 .get("X-Model-Provider-Id")
3900 .map(String::as_str),
3901 Some("tongyi")
3902 );
3903 Ok(())
3904 }
3905
3906 #[test]
3907 fn provider_http_headers_extend_and_override_root_config() {
3908 let mut providers = ProvidersConfig::default();
3909 providers.deepseek.http_headers = Some(HashMap::from([
3910 ("X-Model-Provider-Id".to_string(), "tongyi".to_string()),
3911 ("X-Shared".to_string(), "provider".to_string()),
3912 ]));
3913 let config = Config {
3914 http_headers: Some(HashMap::from([
3915 ("X-Root".to_string(), "root".to_string()),
3916 ("X-Shared".to_string(), "root".to_string()),
3917 ])),
3918 providers: Some(providers),
3919 ..Default::default()
3920 };
3921
3922 let headers = config.http_headers();
3923 assert_eq!(
3924 headers.get("X-Model-Provider-Id").map(String::as_str),
3925 Some("tongyi")
3926 );
3927 assert_eq!(headers.get("X-Root").map(String::as_str), Some("root"));
3928 assert_eq!(
3929 headers.get("X-Shared").map(String::as_str),
3930 Some("provider")
3931 );
3932 }
3933
3934 #[test]
3935 fn http_headers_env_overrides_config() -> Result<()> {
3936 let _lock = lock_test_env();
3937 let nanos = SystemTime::now()
3938 .duration_since(UNIX_EPOCH)
3939 .unwrap()
3940 .as_nanos();
3941 let temp_root = env::temp_dir().join(format!(
3942 "deepseek-tui-http-headers-env-{}-{}",
3943 std::process::id(),
3944 nanos
3945 ));
3946 fs::create_dir_all(&temp_root)?;
3947 let _guard = EnvGuard::new(&temp_root);
3948
3949 let config_path = temp_root.join(".deepseek").join("config.toml");
3950 ensure_parent_dir(&config_path)?;
3951 fs::write(
3952 &config_path,
3953 r#"
3954 api_key = "test-key"
3955 http_headers = { "X-Model-Provider-Id" = "from-file" }
3956 "#,
3957 )?;
3958 // Safety: test-only environment mutation guarded by a global mutex.
3959 unsafe {
3960 env::set_var("DEEPSEEK_HTTP_HEADERS", "X-Model-Provider-Id=from-env");
3961 }
3962
3963 let config = Config::load(None, None)?;
3964 assert_eq!(
3965 config
3966 .http_headers()
3967 .get("X-Model-Provider-Id")
3968 .map(String::as_str),
3969 Some("from-env")
3970 );
3971 Ok(())
3972 }
3973
3974 #[test]
3975 fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {
3976 let config = Config {
3977 provider: Some("nvidia-nim".to_string()),
3978 ..Default::default()
3979 };
3980
3981 config.validate()?;
3982 assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
3983 assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);
3984 assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);
3985 Ok(())
3986 }
3987
3988 #[test]
3989 fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {
3990 let _lock = lock_test_env();
3991 let nanos = SystemTime::now()
3992 .duration_since(UNIX_EPOCH)
3993 .unwrap()
3994 .as_nanos();
3995 let temp_root = env::temp_dir().join(format!(
3996 "deepseek-tui-nim-model-alias-test-{}-{}",
3997 std::process::id(),
3998 nanos
3999 ));
4000 fs::create_dir_all(&temp_root)?;
4001 let _guard = EnvGuard::new(&temp_root);
4002
4003 let config_path = temp_root.join(".deepseek").join("config.toml");
4004 ensure_parent_dir(&config_path)?;
4005 fs::write(
4006 &config_path,
4007 "provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-pro\"\napi_key = \"nim-key\"\n",
4008 )?;
4009
4010 let config = Config::load(None, None)?;
4011 assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
4012 assert_eq!(
4013 config.default_text_model.as_deref(),
4014 Some(DEFAULT_NVIDIA_NIM_MODEL)
4015 );
4016 Ok(())
4017 }
4018
4019 #[test]
4020 fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {
4021 let config = Config {
4022 provider: Some("nvidia-nim".to_string()),
4023 default_text_model: Some("deepseek-v4-flash".to_string()),
4024 ..Default::default()
4025 };
4026
4027 config.validate()?;
4028 assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);
4029 Ok(())
4030 }
4031
4032 #[test]
4033 fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {
4034 let _lock = lock_test_env();
4035 let nanos = SystemTime::now()
4036 .duration_since(UNIX_EPOCH)
4037 .unwrap()
4038 .as_nanos();
4039 let temp_root = env::temp_dir().join(format!(
4040 "deepseek-tui-nim-env-test-{}-{}",
4041 std::process::id(),
4042 nanos
4043 ));
4044 fs::create_dir_all(&temp_root)?;
4045 let _guard = EnvGuard::new(&temp_root);
4046
4047 // Safety: test-only environment mutation guarded by a global mutex.
4048 unsafe {
4049 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
4050 env::set_var("NVIDIA_API_KEY", "nim-env-key");
4051 env::set_var("NVIDIA_NIM_MODEL", "deepseek-ai/deepseek-v4-pro");
4052 }
4053
4054 let config = Config::load(None, None)?;
4055 assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
4056 assert_eq!(config.deepseek_api_key()?, "nim-env-key");
4057 assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);
4058 Ok(())
4059 }
4060
4061 #[test]
4062 fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {
4063 let _lock = lock_test_env();
4064 let nanos = SystemTime::now()
4065 .duration_since(UNIX_EPOCH)
4066 .unwrap()
4067 .as_nanos();
4068 let temp_root = env::temp_dir().join(format!(
4069 "deepseek-tui-nim-base-url-alias-test-{}-{}",
4070 std::process::id(),
4071 nanos
4072 ));
4073 fs::create_dir_all(&temp_root)?;
4074 let _guard = EnvGuard::new(&temp_root);
4075
4076 // Safety: test-only environment mutation guarded by a global mutex.
4077 unsafe {
4078 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
4079 env::set_var("NIM_BASE_URL", "https://short-nim.example/v1");
4080 }
4081
4082 let config = Config::load(None, None)?;
4083 assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
4084 assert_eq!(config.deepseek_base_url(), "https://short-nim.example/v1");
4085 Ok(())
4086 }
4087
4088 #[test]
4089 fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {
4090 let _lock = lock_test_env();
4091 let nanos = SystemTime::now()
4092 .duration_since(UNIX_EPOCH)
4093 .unwrap()
4094 .as_nanos();
4095 let temp_root = env::temp_dir().join(format!(
4096 "deepseek-tui-nim-forwarded-base-url-test-{}-{}",
4097 std::process::id(),
4098 nanos
4099 ));
4100 fs::create_dir_all(&temp_root)?;
4101 let _guard = EnvGuard::new(&temp_root);
4102
4103 // Safety: test-only environment mutation guarded by a global mutex.
4104 unsafe {
4105 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
4106 env::set_var("DEEPSEEK_BASE_URL", "https://forwarded-nim.example/v1");
4107 }
4108
4109 let config = Config::load(None, None)?;
4110 assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
4111 assert_eq!(
4112 config.deepseek_base_url(),
4113 "https://forwarded-nim.example/v1"
4114 );
4115 Ok(())
4116 }
4117
4118 #[test]
4119 fn openrouter_provider_uses_canonical_defaults() -> Result<()> {
4120 let _lock = lock_test_env();
4121 let nanos = SystemTime::now()
4122 .duration_since(UNIX_EPOCH)
4123 .unwrap()
4124 .as_nanos();
4125 let temp_root = env::temp_dir().join(format!(
4126 "deepseek-tui-or-defaults-{}-{}",
4127 std::process::id(),
4128 nanos
4129 ));
4130 fs::create_dir_all(&temp_root)?;
4131 let _guard = EnvGuard::new(&temp_root);
4132
4133 let config = Config {
4134 provider: Some("openrouter".to_string()),
4135 ..Default::default()
4136 };
4137 config.validate()?;
4138 assert_eq!(config.api_provider(), ApiProvider::Openrouter);
4139 assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);
4140 assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);
4141 Ok(())
4142 }
4143
4144 #[test]
4145 fn novita_provider_uses_canonical_defaults() -> Result<()> {
4146 let _lock = lock_test_env();
4147 let nanos = SystemTime::now()
4148 .duration_since(UNIX_EPOCH)
4149 .unwrap()
4150 .as_nanos();
4151 let temp_root = env::temp_dir().join(format!(
4152 "deepseek-tui-novita-defaults-{}-{}",
4153 std::process::id(),
4154 nanos
4155 ));
4156 fs::create_dir_all(&temp_root)?;
4157 let _guard = EnvGuard::new(&temp_root);
4158
4159 let config = Config {
4160 provider: Some("novita".to_string()),
4161 ..Default::default()
4162 };
4163 config.validate()?;
4164 assert_eq!(config.api_provider(), ApiProvider::Novita);
4165 assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);
4166 assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);
4167 Ok(())
4168 }
4169
4170 #[test]
4171 fn fireworks_provider_uses_canonical_defaults() -> Result<()> {
4172 let _lock = lock_test_env();
4173 let nanos = SystemTime::now()
4174 .duration_since(UNIX_EPOCH)
4175 .unwrap()
4176 .as_nanos();
4177 let temp_root = env::temp_dir().join(format!(
4178 "deepseek-tui-fireworks-defaults-{}-{}",
4179 std::process::id(),
4180 nanos
4181 ));
4182 fs::create_dir_all(&temp_root)?;
4183 let _guard = EnvGuard::new(&temp_root);
4184
4185 let config = Config {
4186 provider: Some("fireworks".to_string()),
4187 ..Default::default()
4188 };
4189 config.validate()?;
4190 assert_eq!(config.api_provider(), ApiProvider::Fireworks);
4191 assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);
4192 assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);
4193 Ok(())
4194 }
4195
4196 #[test]
4197 fn sglang_provider_works_without_api_key() -> Result<()> {
4198 let _lock = lock_test_env();
4199 let nanos = SystemTime::now()
4200 .duration_since(UNIX_EPOCH)
4201 .unwrap()
4202 .as_nanos();
4203 let temp_root = env::temp_dir().join(format!(
4204 "deepseek-tui-sglang-defaults-{}-{}",
4205 std::process::id(),
4206 nanos
4207 ));
4208 fs::create_dir_all(&temp_root)?;
4209 let _guard = EnvGuard::new(&temp_root);
4210
4211 let config = Config {
4212 provider: Some("sglang".to_string()),
4213 ..Default::default()
4214 };
4215 config.validate()?;
4216 assert_eq!(config.api_provider(), ApiProvider::Sglang);
4217 assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);
4218 assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);
4219 assert_eq!(config.deepseek_api_key()?, "");
4220 assert!(has_api_key_for(&config, ApiProvider::Sglang));
4221 Ok(())
4222 }
4223
4224 #[test]
4225 fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {
4226 let _lock = lock_test_env();
4227 let nanos = SystemTime::now()
4228 .duration_since(UNIX_EPOCH)
4229 .unwrap()
4230 .as_nanos();
4231 let temp_root = env::temp_dir().join(format!(
4232 "deepseek-tui-or-env-key-{}-{}",
4233 std::process::id(),
4234 nanos
4235 ));
4236 fs::create_dir_all(&temp_root)?;
4237 let _guard = EnvGuard::new(&temp_root);
4238
4239 // Safety: test-only environment mutation guarded by a global mutex.
4240 unsafe {
4241 env::set_var("DEEPSEEK_PROVIDER", "openrouter");
4242 env::set_var("OPENROUTER_API_KEY", "or-env-key");
4243 }
4244
4245 let config = Config::load(None, None)?;
4246 assert_eq!(config.api_provider(), ApiProvider::Openrouter);
4247 assert_eq!(config.deepseek_api_key()?, "or-env-key");
4248 Ok(())
4249 }
4250
4251 #[test]
4252 fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {
4253 let _lock = lock_test_env();
4254 let nanos = SystemTime::now()
4255 .duration_since(UNIX_EPOCH)
4256 .unwrap()
4257 .as_nanos();
4258 let temp_root = env::temp_dir().join(format!(
4259 "deepseek-tui-novita-env-key-{}-{}",
4260 std::process::id(),
4261 nanos
4262 ));
4263 fs::create_dir_all(&temp_root)?;
4264 let _guard = EnvGuard::new(&temp_root);
4265
4266 // Safety: test-only environment mutation guarded by a global mutex.
4267 unsafe {
4268 env::set_var("DEEPSEEK_PROVIDER", "novita");
4269 env::set_var("NOVITA_API_KEY", "novita-env-key");
4270 }
4271
4272 let config = Config::load(None, None)?;
4273 assert_eq!(config.api_provider(), ApiProvider::Novita);
4274 assert_eq!(config.deepseek_api_key()?, "novita-env-key");
4275 Ok(())
4276 }
4277
4278 #[test]
4279 fn openrouter_base_url_env_overrides_default() -> Result<()> {
4280 let _lock = lock_test_env();
4281 let nanos = SystemTime::now()
4282 .duration_since(UNIX_EPOCH)
4283 .unwrap()
4284 .as_nanos();
4285 let temp_root = env::temp_dir().join(format!(
4286 "deepseek-tui-or-base-url-{}-{}",
4287 std::process::id(),
4288 nanos
4289 ));
4290 fs::create_dir_all(&temp_root)?;
4291 let _guard = EnvGuard::new(&temp_root);
4292
4293 // Safety: test-only environment mutation guarded by a global mutex.
4294 unsafe {
4295 env::set_var("DEEPSEEK_PROVIDER", "openrouter");
4296 env::set_var("OPENROUTER_BASE_URL", "https://or-mirror.example/v1");
4297 }
4298
4299 let config = Config::load(None, None)?;
4300 assert_eq!(config.api_provider(), ApiProvider::Openrouter);
4301 assert_eq!(config.deepseek_base_url(), "https://or-mirror.example/v1");
4302 Ok(())
4303 }
4304
4305 #[test]
4306 fn openrouter_reads_provider_table_from_config_file() -> Result<()> {
4307 let _lock = lock_test_env();
4308 let nanos = SystemTime::now()
4309 .duration_since(UNIX_EPOCH)
4310 .unwrap()
4311 .as_nanos();
4312 let temp_root = env::temp_dir().join(format!(
4313 "deepseek-tui-or-table-{}-{}",
4314 std::process::id(),
4315 nanos
4316 ));
4317 fs::create_dir_all(&temp_root)?;
4318 let _guard = EnvGuard::new(&temp_root);
4319
4320 let config_path = temp_root.join(".deepseek").join("config.toml");
4321 ensure_parent_dir(&config_path)?;
4322 fs::write(
4323 &config_path,
4324 r#"provider = "openrouter"
4325
4326 [providers.openrouter]
4327 api_key = "or-table-key"
4328 base_url = "https://or-table.example/v1"
4329 "#,
4330 )?;
4331
4332 let config = Config::load(None, None)?;
4333 assert_eq!(config.api_provider(), ApiProvider::Openrouter);
4334 assert_eq!(config.deepseek_api_key()?, "or-table-key");
4335 assert_eq!(config.deepseek_base_url(), "https://or-table.example/v1");
4336 Ok(())
4337 }
4338
4339 #[test]
4340 fn novita_reads_provider_table_from_config_file() -> Result<()> {
4341 let _lock = lock_test_env();
4342 let nanos = SystemTime::now()
4343 .duration_since(UNIX_EPOCH)
4344 .unwrap()
4345 .as_nanos();
4346 let temp_root = env::temp_dir().join(format!(
4347 "deepseek-tui-novita-table-{}-{}",
4348 std::process::id(),
4349 nanos
4350 ));
4351 fs::create_dir_all(&temp_root)?;
4352 let _guard = EnvGuard::new(&temp_root);
4353
4354 let config_path = temp_root.join(".deepseek").join("config.toml");
4355 ensure_parent_dir(&config_path)?;
4356 fs::write(
4357 &config_path,
4358 r#"provider = "novita"
4359
4360 [providers.novita]
4361 api_key = "novita-table-key"
4362 "#,
4363 )?;
4364
4365 let config = Config::load(None, None)?;
4366 assert_eq!(config.api_provider(), ApiProvider::Novita);
4367 assert_eq!(config.deepseek_api_key()?, "novita-table-key");
4368 assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);
4369 Ok(())
4370 }
4371
4372 #[test]
4373 fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {
4374 let _lock = lock_test_env();
4375 let nanos = SystemTime::now()
4376 .duration_since(UNIX_EPOCH)
4377 .unwrap()
4378 .as_nanos();
4379 let temp_root = env::temp_dir().join(format!(
4380 "deepseek-tui-has-key-{}-{}",
4381 std::process::id(),
4382 nanos
4383 ));
4384 fs::create_dir_all(&temp_root)?;
4385 let _guard = EnvGuard::new(&temp_root);
4386
4387 let mut config = Config::default();
4388 assert!(!has_api_key_for(&config, ApiProvider::Openrouter));
4389 assert!(
4390 has_api_key_for(&config, ApiProvider::Sglang),
4391 "SGLang is self-hosted and does not require a key by default"
4392 );
4393 assert!(
4394 has_api_key_for(&config, ApiProvider::Vllm),
4395 "vLLM is self-hosted and does not require a key by default"
4396 );
4397
4398 // Safety: test-only environment mutation guarded by a global mutex.
4399 unsafe {
4400 env::set_var("OPENROUTER_API_KEY", "or-env");
4401 }
4402 assert!(has_api_key_for(&config, ApiProvider::Openrouter));
4403 assert!(!has_api_key_for(&config, ApiProvider::Novita));
4404
4405 // Safety: test-only environment mutation guarded by a global mutex.
4406 unsafe {
4407 env::remove_var("OPENROUTER_API_KEY");
4408 }
4409 let mut providers = ProvidersConfig::default();
4410 providers.novita.api_key = Some("file-novita".to_string());
4411 config.providers = Some(providers);
4412 assert!(has_api_key_for(&config, ApiProvider::Novita));
4413 assert!(!has_api_key_for(&config, ApiProvider::Openrouter));
4414 Ok(())
4415 }
4416
4417 #[test]
4418 fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {
4419 let _lock = lock_test_env();
4420 let nanos = SystemTime::now()
4421 .duration_since(UNIX_EPOCH)
4422 .unwrap()
4423 .as_nanos();
4424 let temp_root = env::temp_dir().join(format!(
4425 "deepseek-tui-has-key-cn-{}-{}",
4426 std::process::id(),
4427 nanos
4428 ));
4429 fs::create_dir_all(&temp_root)?;
4430 let _guard = EnvGuard::new(&temp_root);
4431
4432 let mut providers = ProvidersConfig::default();
4433 providers.deepseek_cn.api_key = Some("cn-file-key".to_string());
4434 let config = Config {
4435 providers: Some(providers),
4436 ..Config::default()
4437 };
4438
4439 assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));
4440 Ok(())
4441 }
4442
4443 #[test]
4444 fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {
4445 let _lock = lock_test_env();
4446 let nanos = SystemTime::now()
4447 .duration_since(UNIX_EPOCH)
4448 .unwrap()
4449 .as_nanos();
4450 let temp_root = env::temp_dir().join(format!(
4451 "deepseek-tui-save-key-or-{}-{}",
4452 std::process::id(),
4453 nanos
4454 ));
4455 fs::create_dir_all(&temp_root)?;
4456 let _guard = EnvGuard::new(&temp_root);
4457
4458 let path = save_api_key_for(ApiProvider::Openrouter, "or-saved-key")?;
4459 let contents = fs::read_to_string(&path)?;
4460 let parsed: toml::Value = toml::from_str(&contents)?;
4461 assert_eq!(
4462 parsed
4463 .get("providers")
4464 .and_then(|p| p.get("openrouter"))
4465 .and_then(|t| t.get("api_key"))
4466 .and_then(toml::Value::as_str),
4467 Some("or-saved-key")
4468 );
4469 // Re-saving must not duplicate or wipe sibling tables.
4470 save_api_key_for(ApiProvider::Novita, "novita-saved-key")?;
4471 let contents = fs::read_to_string(&path)?;
4472 let parsed: toml::Value = toml::from_str(&contents)?;
4473 assert_eq!(
4474 parsed
4475 .get("providers")
4476 .and_then(|p| p.get("openrouter"))
4477 .and_then(|t| t.get("api_key"))
4478 .and_then(toml::Value::as_str),
4479 Some("or-saved-key")
4480 );
4481 assert_eq!(
4482 parsed
4483 .get("providers")
4484 .and_then(|p| p.get("novita"))
4485 .and_then(|t| t.get("api_key"))
4486 .and_then(toml::Value::as_str),
4487 Some("novita-saved-key")
4488 );
4489 save_api_key_for(ApiProvider::Fireworks, "fireworks-saved-key")?;
4490 save_api_key_for(ApiProvider::Sglang, "sglang-saved-key")?;
4491 let contents = fs::read_to_string(&path)?;
4492 let parsed: toml::Value = toml::from_str(&contents)?;
4493 assert_eq!(
4494 parsed
4495 .get("providers")
4496 .and_then(|p| p.get("fireworks"))
4497 .and_then(|t| t.get("api_key"))
4498 .and_then(toml::Value::as_str),
4499 Some("fireworks-saved-key")
4500 );
4501 assert_eq!(
4502 parsed
4503 .get("providers")
4504 .and_then(|p| p.get("sglang"))
4505 .and_then(|t| t.get("api_key"))
4506 .and_then(toml::Value::as_str),
4507 Some("sglang-saved-key")
4508 );
4509 Ok(())
4510 }
4511
4512 #[test]
4513 fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {
4514 let _lock = lock_test_env();
4515 let nanos = SystemTime::now()
4516 .duration_since(UNIX_EPOCH)
4517 .unwrap()
4518 .as_nanos();
4519 let temp_root = env::temp_dir().join(format!(
4520 "deepseek-tui-save-key-cn-{}-{}",
4521 std::process::id(),
4522 nanos
4523 ));
4524 fs::create_dir_all(&temp_root)?;
4525 let _guard = EnvGuard::new(&temp_root);
4526
4527 let path = save_api_key_for(ApiProvider::DeepseekCN, "cn-saved-key")?;
4528 let contents = fs::read_to_string(&path)?;
4529 let parsed: toml::Value = toml::from_str(&contents)?;
4530
4531 assert_eq!(
4532 parsed.get("api_key").and_then(toml::Value::as_str),
4533 Some("cn-saved-key")
4534 );
4535 Ok(())
4536 }
4537
4538 #[test]
4539 fn nvidia_nim_reads_facade_provider_table() -> Result<()> {
4540 let _lock = lock_test_env();
4541 let nanos = SystemTime::now()
4542 .duration_since(UNIX_EPOCH)
4543 .unwrap()
4544 .as_nanos();
4545 let temp_root = env::temp_dir().join(format!(
4546 "deepseek-tui-nim-provider-table-test-{}-{}",
4547 std::process::id(),
4548 nanos
4549 ));
4550 fs::create_dir_all(&temp_root)?;
4551 let _guard = EnvGuard::new(&temp_root);
4552
4553 let config_path = temp_root.join(".deepseek").join("config.toml");
4554 ensure_parent_dir(&config_path)?;
4555 fs::write(
4556 &config_path,
4557 r#"provider = "nvidia-nim"
4558 default_text_model = "deepseek-v4-flash"
4559
4560 [providers.nvidia_nim]
4561 api_key = "nim-table-key"
4562 base_url = "https://nim-table.example/v1"
4563 model = "deepseek-v4-pro"
4564 "#,
4565 )?;
4566
4567 let config = Config::load(None, None)?;
4568 assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
4569 assert_eq!(config.deepseek_api_key()?, "nim-table-key");
4570 assert_eq!(config.deepseek_base_url(), "https://nim-table.example/v1");
4571 assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);
4572 Ok(())
4573 }
4574
4575 // ========================================================================
4576 // Provider Capability Matrix tests
4577 // ========================================================================
4578
4579 #[test]
4580 fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {
4581 let cap = provider_capability(ApiProvider::Deepseek, "deepseek-v4-pro");
4582 assert_eq!(
4583 cap.context_window,
4584 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
4585 );
4586 assert_eq!(cap.max_output, 262_144);
4587 assert!(cap.thinking_supported);
4588 assert!(cap.cache_telemetry_supported);
4589 assert_eq!(
4590 cap.request_payload_mode,
4591 RequestPayloadMode::ChatCompletions
4592 );
4593 }
4594
4595 #[test]
4596 fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {
4597 let cap = provider_capability(ApiProvider::Deepseek, "deepseek-v4-flash");
4598 assert_eq!(
4599 cap.context_window,
4600 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
4601 );
4602 assert_eq!(cap.max_output, 262_144);
4603 assert!(cap.thinking_supported);
4604 assert!(cap.cache_telemetry_supported);
4605 }
4606
4607 #[test]
4608 fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {
4609 let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);
4610 assert_eq!(
4611 cap.context_window,
4612 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
4613 );
4614 assert_eq!(cap.max_output, 262_144);
4615 assert!(cap.thinking_supported);
4616 assert!(cap.cache_telemetry_supported);
4617 assert_eq!(
4618 cap.request_payload_mode,
4619 RequestPayloadMode::ChatCompletions
4620 );
4621 }
4622
4623 #[test]
4624 fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {
4625 let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);
4626 assert_eq!(
4627 cap.context_window,
4628 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
4629 );
4630 assert_eq!(cap.max_output, 262_144);
4631 assert!(cap.thinking_supported);
4632 assert!(cap.cache_telemetry_supported);
4633 }
4634
4635 #[test]
4636 fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {
4637 let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);
4638 assert_eq!(
4639 cap.context_window,
4640 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
4641 );
4642 assert_eq!(cap.max_output, 262_144);
4643 assert!(cap.thinking_supported);
4644 // OpenRouter does not return DeepSeek prompt-cache telemetry.
4645 assert!(!cap.cache_telemetry_supported);
4646 assert_eq!(
4647 cap.request_payload_mode,
4648 RequestPayloadMode::ChatCompletions
4649 );
4650 }
4651
4652 #[test]
4653 fn provider_capability_novita_v4_pro_has_thinking_no_cache() {
4654 let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);
4655 assert_eq!(
4656 cap.context_window,
4657 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
4658 );
4659 assert_eq!(cap.max_output, 262_144);
4660 assert!(cap.thinking_supported);
4661 assert!(!cap.cache_telemetry_supported);
4662 }
4663
4664 #[test]
4665 fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {
4666 let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);
4667 assert_eq!(
4668 cap.context_window,
4669 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
4670 );
4671 assert_eq!(cap.max_output, 262_144);
4672 assert!(cap.thinking_supported);
4673 assert!(!cap.cache_telemetry_supported);
4674 }
4675
4676 #[test]
4677 fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {
4678 let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);
4679 assert_eq!(
4680 cap.context_window,
4681 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
4682 );
4683 assert_eq!(cap.max_output, 262_144);
4684 assert!(cap.thinking_supported);
4685 assert!(!cap.cache_telemetry_supported);
4686 }
4687
4688 #[test]
4689 fn provider_capability_non_v4_model_has_smaller_window() {
4690 let cap = provider_capability(ApiProvider::Deepseek, "deepseek-coder");
4691 assert_eq!(
4692 cap.context_window,
4693 crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS
4694 );
4695 assert_eq!(cap.max_output, 4096);
4696 assert!(!cap.thinking_supported);
4697 }
4698
4699 #[test]
4700 fn provider_capability_roundtrip_serialization() {
4701 let cap = provider_capability(ApiProvider::Deepseek, "deepseek-v4-pro");
4702 let json = serde_json::to_value(&cap).unwrap();
4703 let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();
4704 assert_eq!(cap, deserialized);
4705 }
4706 }
4707
4707 lines RUST