Skip to main content

codewhale_config/
lib.rs

1use std::collections::BTreeMap;
2use std::fs;
3#[cfg(unix)]
4use std::io::Write;
5use std::path::{Component, Path, PathBuf};
6use std::sync::OnceLock;
7
8use anyhow::{Context, Result, bail};
9use codewhale_secrets::SecretSource;
10pub use codewhale_secrets::Secrets;
11use serde::{Deserialize, Serialize};
12
13#[cfg(unix)]
14use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
15
16pub const CONFIG_FILE_NAME: &str = "config.toml";
17const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro";
18const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
19const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
20const DEFAULT_OPENAI_MODEL: &str = "deepseek-v4-pro";
21const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta";
22const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
23const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
24const DEFAULT_ATLASCLOUD_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
25const DEFAULT_ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1";
26const DEFAULT_WANJIE_ARK_MODEL: &str = "deepseek-reasoner";
27const DEFAULT_WANJIE_ARK_BASE_URL: &str = "https://maas-openapi.wanjiedata.com/api/v1";
28const DEFAULT_VOLCENGINE_MODEL: &str = "DeepSeek-V4-Pro";
29const DEFAULT_VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/coding/v3";
30const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro";
31const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
32const OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL: &str = "arcee-ai/trinity-large-thinking";
33const OPENROUTER_GEMMA_4_31B_MODEL: &str = "google/gemma-4-31b-it";
34const OPENROUTER_GEMMA_4_26B_A4B_MODEL: &str = "google/gemma-4-26b-a4b-it";
35const OPENROUTER_GLM_5_1_MODEL: &str = "z-ai/glm-5.1";
36const OPENROUTER_KIMI_K2_6_MODEL: &str = "moonshotai/kimi-k2.6";
37const OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL: &str =
38    "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free";
39const OPENROUTER_QWEN_3_6_35B_A3B_MODEL: &str = "qwen/qwen3.6-35b-a3b";
40const OPENROUTER_QWEN_3_6_27B_MODEL: &str = "qwen/qwen3.6-27b";
41const OPENROUTER_TENCENT_HY3_PREVIEW_MODEL: &str = "tencent/hy3-preview";
42const OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL: &str = "xiaomi/mimo-v2.5-pro";
43const OPENROUTER_XIAOMI_MIMO_V2_5_MODEL: &str = "xiaomi/mimo-v2.5";
44const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro";
45const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro";
46const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
47const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro";
48const DEFAULT_SILICONFLOW_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
49const DEFAULT_SILICONFLOW_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
50const DEFAULT_MOONSHOT_MODEL: &str = "kimi-k2.6";
51const DEFAULT_MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1";
52const DEFAULT_KIMI_CODE_MODEL: &str = "kimi-for-coding";
53const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding/v1";
54const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
55const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
56const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
57const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://api.xiaomimimo.com/v1";
58const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/v1";
59const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
60const DEFAULT_SILICONFLOW_BASE_URL: &str = "https://api.siliconflow.com/v1";
61const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1";
62const DEFAULT_VLLM_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
63const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
64const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1";
65const DEFAULT_OLLAMA_MODEL: &str = "deepseek-coder:1.3b";
66const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
67
68#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
69#[serde(rename_all = "kebab-case")]
70pub enum ProviderKind {
71    #[default]
72    #[serde(
73        alias = "deepseek-cn",
74        alias = "deepseek_china",
75        alias = "deepseekcn",
76        alias = "deepseek-china"
77    )]
78    Deepseek,
79    NvidiaNim,
80    #[serde(alias = "open-ai")]
81    Openai,
82    Atlascloud,
83    #[serde(
84        alias = "wanjie",
85        alias = "wanjie_ark",
86        alias = "ark-wanjie",
87        alias = "ark_wanjie",
88        alias = "wanjie-maas",
89        alias = "wanjie_maas"
90    )]
91    WanjieArk,
92    #[serde(alias = "volcengine-ark", alias = "volcengine_ark", alias = "ark")]
93    Volcengine,
94    Openrouter,
95    #[serde(alias = "mimo", alias = "xiaomi", alias = "xiaomi_mimo")]
96    XiaomiMimo,
97    Novita,
98    Fireworks,
99    #[serde(alias = "silicon-flow", alias = "silicon_flow")]
100    Siliconflow,
101    Moonshot,
102    Sglang,
103    Vllm,
104    Ollama,
105}
106
107impl ProviderKind {
108    #[must_use]
109    pub fn as_str(self) -> &'static str {
110        match self {
111            Self::Deepseek => "deepseek",
112            Self::NvidiaNim => "nvidia-nim",
113            Self::Openai => "openai",
114            Self::Atlascloud => "atlascloud",
115            Self::WanjieArk => "wanjie-ark",
116            Self::Volcengine => "volcengine",
117            Self::Openrouter => "openrouter",
118            Self::XiaomiMimo => "xiaomi-mimo",
119            Self::Novita => "novita",
120            Self::Fireworks => "fireworks",
121            Self::Siliconflow => "siliconflow",
122            Self::Moonshot => "moonshot",
123            Self::Sglang => "sglang",
124            Self::Vllm => "vllm",
125            Self::Ollama => "ollama",
126        }
127    }
128
129    #[must_use]
130    pub fn parse(value: &str) -> Option<Self> {
131        match value.trim().to_ascii_lowercase().as_str() {
132            "deepseek" | "deep-seek" | "deepseek-cn" | "deepseek_china" | "deepseekcn"
133            | "deepseek-china" => Some(Self::Deepseek),
134            "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => Some(Self::NvidiaNim),
135            "openai" | "open-ai" => Some(Self::Openai),
136            "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => Some(Self::Atlascloud),
137            "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
138            | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => Some(Self::WanjieArk),
139            "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark"
140            | "volcengineark" => Some(Self::Volcengine),
141            "openrouter" | "open_router" => Some(Self::Openrouter),
142            "xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
143                Some(Self::XiaomiMimo)
144            }
145            "novita" => Some(Self::Novita),
146            "fireworks" | "fireworks-ai" => Some(Self::Fireworks),
147            "siliconflow" | "silicon-flow" | "silicon_flow" => Some(Self::Siliconflow),
148            "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => Some(Self::Moonshot),
149            "sglang" | "sg-lang" => Some(Self::Sglang),
150            "vllm" | "v-llm" => Some(Self::Vllm),
151            "ollama" | "ollama-local" => Some(Self::Ollama),
152            _ => None,
153        }
154    }
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, Default)]
158pub struct ProviderConfigToml {
159    pub api_key: Option<String>,
160    pub base_url: Option<String>,
161    pub model: Option<String>,
162    pub auth_mode: Option<String>,
163    #[serde(default)]
164    pub http_headers: BTreeMap<String, String>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize, Default)]
168pub struct ProvidersToml {
169    #[serde(default)]
170    pub deepseek: ProviderConfigToml,
171    #[serde(default)]
172    pub nvidia_nim: ProviderConfigToml,
173    #[serde(default)]
174    pub openai: ProviderConfigToml,
175    #[serde(default)]
176    pub atlascloud: ProviderConfigToml,
177    #[serde(default)]
178    pub wanjie_ark: ProviderConfigToml,
179    #[serde(default)]
180    pub volcengine: ProviderConfigToml,
181    #[serde(default)]
182    pub openrouter: ProviderConfigToml,
183    #[serde(default)]
184    pub xiaomi_mimo: ProviderConfigToml,
185    #[serde(default)]
186    pub novita: ProviderConfigToml,
187    #[serde(default)]
188    pub fireworks: ProviderConfigToml,
189    #[serde(default)]
190    pub siliconflow: ProviderConfigToml,
191    #[serde(default)]
192    pub moonshot: ProviderConfigToml,
193    #[serde(default)]
194    pub sglang: ProviderConfigToml,
195    #[serde(default)]
196    pub vllm: ProviderConfigToml,
197    #[serde(default)]
198    pub ollama: ProviderConfigToml,
199}
200
201impl ProvidersToml {
202    #[must_use]
203    pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
204        match provider {
205            ProviderKind::Deepseek => &self.deepseek,
206            ProviderKind::NvidiaNim => &self.nvidia_nim,
207            ProviderKind::Openai => &self.openai,
208            ProviderKind::Atlascloud => &self.atlascloud,
209            ProviderKind::WanjieArk => &self.wanjie_ark,
210            ProviderKind::Volcengine => &self.volcengine,
211            ProviderKind::Openrouter => &self.openrouter,
212            ProviderKind::XiaomiMimo => &self.xiaomi_mimo,
213            ProviderKind::Novita => &self.novita,
214            ProviderKind::Fireworks => &self.fireworks,
215            ProviderKind::Siliconflow => &self.siliconflow,
216            ProviderKind::Moonshot => &self.moonshot,
217            ProviderKind::Sglang => &self.sglang,
218            ProviderKind::Vllm => &self.vllm,
219            ProviderKind::Ollama => &self.ollama,
220        }
221    }
222
223    pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
224        match provider {
225            ProviderKind::Deepseek => &mut self.deepseek,
226            ProviderKind::NvidiaNim => &mut self.nvidia_nim,
227            ProviderKind::Openai => &mut self.openai,
228            ProviderKind::Atlascloud => &mut self.atlascloud,
229            ProviderKind::WanjieArk => &mut self.wanjie_ark,
230            ProviderKind::Volcengine => &mut self.volcengine,
231            ProviderKind::Openrouter => &mut self.openrouter,
232            ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo,
233            ProviderKind::Novita => &mut self.novita,
234            ProviderKind::Fireworks => &mut self.fireworks,
235            ProviderKind::Siliconflow => &mut self.siliconflow,
236            ProviderKind::Moonshot => &mut self.moonshot,
237            ProviderKind::Sglang => &mut self.sglang,
238            ProviderKind::Vllm => &mut self.vllm,
239            ProviderKind::Ollama => &mut self.ollama,
240        }
241    }
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, Default)]
245pub struct ConfigToml {
246    /// TUI-compatible DeepSeek API key. Kept at the root so both `deepseek`
247    /// and `codewhale-tui` can share a single config file.
248    pub api_key: Option<String>,
249    /// TUI-compatible DeepSeek base URL.
250    pub base_url: Option<String>,
251    /// Optional extra HTTP headers forwarded to model API requests.
252    #[serde(default)]
253    pub http_headers: BTreeMap<String, String>,
254    /// TUI-compatible default DeepSeek model.
255    pub default_text_model: Option<String>,
256    #[serde(default)]
257    pub provider: ProviderKind,
258    pub model: Option<String>,
259    pub auth_mode: Option<String>,
260    pub output_mode: Option<String>,
261    pub log_level: Option<String>,
262    pub telemetry: Option<bool>,
263    pub approval_policy: Option<String>,
264    pub sandbox_mode: Option<String>,
265    /// Native tool catalog controls shared with `codewhale-tui`.
266    #[serde(default)]
267    pub tools: Option<ToolsToml>,
268    #[serde(default)]
269    pub providers: ProvidersToml,
270    /// Per-domain network policy (#135). When absent, network tools fall back
271    /// to a permissive default that mirrors pre-v0.7.0 behavior.
272    #[serde(default)]
273    pub network: Option<NetworkPolicyToml>,
274    /// Community skill installer settings (#140). Mirrors
275    /// [`SkillsToml`] from the TUI side; the dispatcher consults
276    /// `registry_url` when running `deepseek skill install`.
277    #[serde(default)]
278    pub skills: Option<SkillsToml>,
279    /// Workspace side-git snapshots (#137). The live TUI defaults this to
280    /// enabled with 7-day retention when absent.
281    #[serde(default)]
282    pub snapshots: Option<SnapshotsToml>,
283    /// Post-edit LSP diagnostics injection (#136). When absent, the engine
284    /// applies the defaults documented in [`LspConfigToml`].
285    #[serde(default)]
286    pub lsp: Option<LspConfigToml>,
287    /// App-server hook sink configuration. Kept separate from the TUI
288    /// lifecycle `[hooks]` table so config rewrites preserve existing hooks.
289    #[serde(default)]
290    pub hook_sinks: Option<HookSinksToml>,
291    #[serde(flatten)]
292    pub extras: BTreeMap<String, toml::Value>,
293}
294
295/// On-disk schema for the `[hook_sinks]` table.
296#[derive(Debug, Clone, Serialize, Deserialize, Default)]
297pub struct HookSinksToml {
298    /// Unix domain socket path used by the app-server event sink.
299    ///
300    /// When unset, no Unix socket sink is registered. There is deliberately no
301    /// shared `/tmp` default because socket ownership should be explicit.
302    #[serde(default)]
303    pub unix_socket_path: Option<PathBuf>,
304}
305
306/// On-disk schema for the `[skills]` table (#140). See `config.example.toml`
307/// for documentation.
308#[derive(Debug, Clone, Serialize, Deserialize, Default)]
309pub struct SkillsToml {
310    /// Curated registry index URL. When unset, the TUI falls back to the
311    /// bundled default (community-curated GitHub raw).
312    #[serde(default)]
313    pub registry_url: Option<String>,
314    /// Per-skill maximum *uncompressed* size in bytes. When unset, the TUI
315    /// uses 5 MiB.
316    #[serde(default)]
317    pub max_install_size_bytes: Option<u64>,
318}
319
320/// On-disk schema for the `[tools]` table (#2076).
321#[derive(Debug, Clone, Serialize, Deserialize, Default)]
322pub struct ToolsToml {
323    /// Native tool names to keep loaded outside the default core catalog.
324    #[serde(default)]
325    pub always_load: Vec<String>,
326}
327
328/// On-disk schema for the `[snapshots]` table (#137). See
329/// `config.example.toml` for documentation.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct SnapshotsToml {
332    #[serde(default = "default_snapshots_enabled")]
333    pub enabled: bool,
334    #[serde(default = "default_snapshot_max_age_days")]
335    pub max_age_days: u64,
336}
337
338fn default_snapshots_enabled() -> bool {
339    true
340}
341
342fn default_snapshot_max_age_days() -> u64 {
343    7
344}
345
346impl Default for SnapshotsToml {
347    fn default() -> Self {
348        Self {
349            enabled: default_snapshots_enabled(),
350            max_age_days: default_snapshot_max_age_days(),
351        }
352    }
353}
354
355/// On-disk schema for the `[network]` table (#135). See `config.example.toml`
356/// for documentation.
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct NetworkPolicyToml {
359    /// Decision for hosts that are not in `allow` or `deny`. One of
360    /// `"allow" | "deny" | "prompt"`. Defaults to `"prompt"`.
361    #[serde(default = "default_network_decision")]
362    pub default: String,
363    /// Hosts that are always allowed. Subdomain rules: a leading dot
364    /// (`.example.com`) matches subdomains but not the apex.
365    #[serde(default)]
366    pub allow: Vec<String>,
367    /// Hosts that are always denied. Deny entries win over allow entries.
368    #[serde(default)]
369    pub deny: Vec<String>,
370    /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an
371    /// explicitly trusted proxy setup. Literal IP URLs remain blocked.
372    #[serde(default)]
373    pub proxy: Vec<String>,
374    /// Whether to record one audit-log line per outbound network call.
375    #[serde(default = "default_network_audit")]
376    pub audit: bool,
377}
378
379fn default_network_decision() -> String {
380    "prompt".to_string()
381}
382
383fn default_network_audit() -> bool {
384    true
385}
386
387impl Default for NetworkPolicyToml {
388    fn default() -> Self {
389        Self {
390            default: default_network_decision(),
391            allow: Vec::new(),
392            deny: Vec::new(),
393            proxy: Vec::new(),
394            audit: default_network_audit(),
395        }
396    }
397}
398
399/// On-disk schema for the `[lsp]` table (#136). See `config.example.toml`
400/// for documentation. All fields are optional so the TUI runtime can fall
401/// back to its own defaults when keys are absent.
402#[derive(Debug, Clone, Serialize, Deserialize, Default)]
403pub struct LspConfigToml {
404    /// Master switch.
405    pub enabled: Option<bool>,
406    /// Maximum time to wait for diagnostics after an edit, in milliseconds.
407    pub poll_after_edit_ms: Option<u64>,
408    /// Cap on diagnostics surfaced per file.
409    pub max_diagnostics_per_file: Option<usize>,
410    /// When `true`, warnings (severity 2) are surfaced in addition to errors.
411    pub include_warnings: Option<bool>,
412    /// Optional override for the `language -> [cmd, ...args]` table.
413    pub servers: Option<BTreeMap<String, Vec<String>>>,
414}
415
416impl ConfigToml {
417    /// Merge safe project-level overrides from `$WORKSPACE/.codewhale/config.toml`
418    /// or legacy `$WORKSPACE/.deepseek/config.toml`.
419    ///
420    /// Repo-local config is untrusted input. This helper intentionally ignores
421    /// credentials, endpoints, provider selection, auth/session values, telemetry,
422    /// network policy, skill registry, LSP command tables, and unknown extras.
423    /// Approval and sandbox values may only tighten the existing user/global
424    /// posture.
425    pub fn merge_project_overrides(&mut self, project: ConfigToml) {
426        if project.default_text_model.is_some() {
427            self.default_text_model = project.default_text_model;
428        }
429        if project.model.is_some() {
430            self.model = project.model;
431        }
432        if project.output_mode.is_some() {
433            self.output_mode = project.output_mode;
434        }
435        if project.log_level.is_some() {
436            self.log_level = project.log_level;
437        }
438        if let Some(policy) = project.approval_policy
439            && project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy)
440        {
441            self.approval_policy = Some(policy);
442        }
443        if let Some(mode) = project.sandbox_mode
444            && project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode)
445        {
446            self.sandbox_mode = Some(mode);
447        }
448        if project.tools.is_some() {
449            self.tools = project.tools;
450        }
451        merge_project_provider_config(&mut self.providers.deepseek, &project.providers.deepseek);
452        merge_project_provider_config(
453            &mut self.providers.nvidia_nim,
454            &project.providers.nvidia_nim,
455        );
456        merge_project_provider_config(&mut self.providers.openai, &project.providers.openai);
457        merge_project_provider_config(
458            &mut self.providers.atlascloud,
459            &project.providers.atlascloud,
460        );
461        merge_project_provider_config(
462            &mut self.providers.wanjie_ark,
463            &project.providers.wanjie_ark,
464        );
465        merge_project_provider_config(
466            &mut self.providers.openrouter,
467            &project.providers.openrouter,
468        );
469        merge_project_provider_config(
470            &mut self.providers.xiaomi_mimo,
471            &project.providers.xiaomi_mimo,
472        );
473        merge_project_provider_config(&mut self.providers.novita, &project.providers.novita);
474        merge_project_provider_config(&mut self.providers.fireworks, &project.providers.fireworks);
475        merge_project_provider_config(
476            &mut self.providers.siliconflow,
477            &project.providers.siliconflow,
478        );
479        merge_project_provider_config(&mut self.providers.sglang, &project.providers.sglang);
480        merge_project_provider_config(&mut self.providers.vllm, &project.providers.vllm);
481        merge_project_provider_config(&mut self.providers.ollama, &project.providers.ollama);
482    }
483
484    #[must_use]
485    pub fn get_value(&self, key: &str) -> Option<String> {
486        match key {
487            "provider" => Some(self.provider.as_str().to_string()),
488            "api_key" => self.api_key.clone(),
489            "base_url" => self.base_url.clone(),
490            "http_headers" => serialize_http_headers(&self.http_headers),
491            "default_text_model" => self.default_text_model.clone(),
492            "model" => self.model.clone(),
493            "auth.mode" => self.auth_mode.clone(),
494            "output_mode" => self.output_mode.clone(),
495            "log_level" => self.log_level.clone(),
496            "telemetry" => self.telemetry.map(|v| v.to_string()),
497            "approval_policy" => self.approval_policy.clone(),
498            "sandbox_mode" => self.sandbox_mode.clone(),
499            "tools.always_load" => self.tools.as_ref().map(|tools| tools.always_load.join(",")),
500            "hook_sinks.unix_socket_path" => self
501                .hook_sinks
502                .as_ref()
503                .and_then(|sinks| sinks.unix_socket_path.as_ref())
504                .map(|path| path.display().to_string()),
505            "providers.deepseek.api_key" => self.providers.deepseek.api_key.clone(),
506            "providers.deepseek.base_url" => self.providers.deepseek.base_url.clone(),
507            "providers.deepseek.model" => self.providers.deepseek.model.clone(),
508            "providers.deepseek.http_headers" => {
509                serialize_http_headers(&self.providers.deepseek.http_headers)
510            }
511            "providers.nvidia_nim.api_key" => self.providers.nvidia_nim.api_key.clone(),
512            "providers.nvidia_nim.base_url" => self.providers.nvidia_nim.base_url.clone(),
513            "providers.nvidia_nim.model" => self.providers.nvidia_nim.model.clone(),
514            "providers.nvidia_nim.http_headers" => {
515                serialize_http_headers(&self.providers.nvidia_nim.http_headers)
516            }
517            "providers.openai.api_key" => self.providers.openai.api_key.clone(),
518            "providers.openai.base_url" => self.providers.openai.base_url.clone(),
519            "providers.openai.model" => self.providers.openai.model.clone(),
520            "providers.openai.http_headers" => {
521                serialize_http_headers(&self.providers.openai.http_headers)
522            }
523            "providers.atlascloud.api_key" => self.providers.atlascloud.api_key.clone(),
524            "providers.atlascloud.base_url" => self.providers.atlascloud.base_url.clone(),
525            "providers.atlascloud.model" => self.providers.atlascloud.model.clone(),
526            "providers.atlascloud.http_headers" => {
527                serialize_http_headers(&self.providers.atlascloud.http_headers)
528            }
529            "providers.wanjie_ark.api_key" => self.providers.wanjie_ark.api_key.clone(),
530            "providers.wanjie_ark.base_url" => self.providers.wanjie_ark.base_url.clone(),
531            "providers.wanjie_ark.model" => self.providers.wanjie_ark.model.clone(),
532            "providers.volcengine.api_key" => self.providers.volcengine.api_key.clone(),
533            "providers.volcengine.base_url" => self.providers.volcengine.base_url.clone(),
534            "providers.volcengine.model" => self.providers.volcengine.model.clone(),
535            "providers.wanjie_ark.http_headers" => {
536                serialize_http_headers(&self.providers.wanjie_ark.http_headers)
537            }
538            "providers.openrouter.api_key" => self.providers.openrouter.api_key.clone(),
539            "providers.openrouter.base_url" => self.providers.openrouter.base_url.clone(),
540            "providers.openrouter.model" => self.providers.openrouter.model.clone(),
541            "providers.openrouter.http_headers" => {
542                serialize_http_headers(&self.providers.openrouter.http_headers)
543            }
544            "providers.xiaomi_mimo.api_key" => self.providers.xiaomi_mimo.api_key.clone(),
545            "providers.xiaomi_mimo.base_url" => self.providers.xiaomi_mimo.base_url.clone(),
546            "providers.xiaomi_mimo.model" => self.providers.xiaomi_mimo.model.clone(),
547            "providers.xiaomi_mimo.http_headers" => {
548                serialize_http_headers(&self.providers.xiaomi_mimo.http_headers)
549            }
550            "providers.novita.api_key" => self.providers.novita.api_key.clone(),
551            "providers.novita.base_url" => self.providers.novita.base_url.clone(),
552            "providers.novita.model" => self.providers.novita.model.clone(),
553            "providers.novita.http_headers" => {
554                serialize_http_headers(&self.providers.novita.http_headers)
555            }
556            "providers.fireworks.api_key" => self.providers.fireworks.api_key.clone(),
557            "providers.fireworks.base_url" => self.providers.fireworks.base_url.clone(),
558            "providers.fireworks.model" => self.providers.fireworks.model.clone(),
559            "providers.fireworks.http_headers" => {
560                serialize_http_headers(&self.providers.fireworks.http_headers)
561            }
562            "providers.siliconflow.api_key" => self.providers.siliconflow.api_key.clone(),
563            "providers.siliconflow.base_url" => self.providers.siliconflow.base_url.clone(),
564            "providers.siliconflow.model" => self.providers.siliconflow.model.clone(),
565            "providers.siliconflow.http_headers" => {
566                serialize_http_headers(&self.providers.siliconflow.http_headers)
567            }
568            "providers.moonshot.api_key" => self.providers.moonshot.api_key.clone(),
569            "providers.moonshot.base_url" => self.providers.moonshot.base_url.clone(),
570            "providers.moonshot.model" => self.providers.moonshot.model.clone(),
571            "providers.moonshot.auth_mode" => self.providers.moonshot.auth_mode.clone(),
572            "providers.moonshot.http_headers" => {
573                serialize_http_headers(&self.providers.moonshot.http_headers)
574            }
575            "providers.sglang.api_key" => self.providers.sglang.api_key.clone(),
576            "providers.sglang.base_url" => self.providers.sglang.base_url.clone(),
577            "providers.sglang.model" => self.providers.sglang.model.clone(),
578            "providers.sglang.http_headers" => {
579                serialize_http_headers(&self.providers.sglang.http_headers)
580            }
581            "providers.vllm.api_key" => self.providers.vllm.api_key.clone(),
582            "providers.vllm.base_url" => self.providers.vllm.base_url.clone(),
583            "providers.vllm.model" => self.providers.vllm.model.clone(),
584            "providers.vllm.http_headers" => {
585                serialize_http_headers(&self.providers.vllm.http_headers)
586            }
587            "providers.ollama.api_key" => self.providers.ollama.api_key.clone(),
588            "providers.ollama.base_url" => self.providers.ollama.base_url.clone(),
589            "providers.ollama.model" => self.providers.ollama.model.clone(),
590            "providers.ollama.http_headers" => {
591                serialize_http_headers(&self.providers.ollama.http_headers)
592            }
593            _ => self.extras.get(key).map(toml::Value::to_string),
594        }
595    }
596
597    #[must_use]
598    pub fn get_display_value(&self, key: &str) -> Option<String> {
599        self.get_value(key).map(|value| {
600            if is_sensitive_config_key(key) {
601                redact_secret(&value)
602            } else {
603                value
604            }
605        })
606    }
607
608    pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
609        match key {
610            "provider" => {
611                self.provider = ProviderKind::parse(value)
612                    .with_context(|| format!("unknown provider '{value}'"))?;
613            }
614            "api_key" => self.api_key = Some(value.to_string()),
615            "base_url" => self.base_url = Some(value.to_string()),
616            "http_headers" => self.http_headers = parse_http_headers(value)?,
617            "default_text_model" => self.default_text_model = Some(value.to_string()),
618            "model" => self.model = Some(value.to_string()),
619            "auth.mode" => self.auth_mode = Some(value.to_string()),
620            "output_mode" => self.output_mode = Some(value.to_string()),
621            "log_level" => self.log_level = Some(value.to_string()),
622            "telemetry" => {
623                self.telemetry = Some(parse_bool(value)?);
624            }
625            "approval_policy" => self.approval_policy = Some(value.to_string()),
626            "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
627            "hook_sinks.unix_socket_path" => {
628                self.hook_sinks
629                    .get_or_insert_with(HookSinksToml::default)
630                    .unix_socket_path = Some(PathBuf::from(value));
631            }
632            "providers.deepseek.api_key" => {
633                let value = value.to_string();
634                self.providers.deepseek.api_key = Some(value.clone());
635                self.api_key = Some(value);
636            }
637            "providers.deepseek.base_url" => {
638                let value = value.to_string();
639                self.providers.deepseek.base_url = Some(value.clone());
640                self.base_url = Some(value);
641            }
642            "providers.deepseek.model" => {
643                let value = value.to_string();
644                self.providers.deepseek.model = Some(value.clone());
645                self.default_text_model = Some(value);
646            }
647            "providers.deepseek.http_headers" => {
648                let headers = parse_http_headers(value)?;
649                self.providers.deepseek.http_headers = headers.clone();
650                self.http_headers = headers;
651            }
652            "providers.openai.api_key" => self.providers.openai.api_key = Some(value.to_string()),
653            "providers.openai.base_url" => self.providers.openai.base_url = Some(value.to_string()),
654            "providers.openai.model" => self.providers.openai.model = Some(value.to_string()),
655            "providers.openai.http_headers" => {
656                self.providers.openai.http_headers = parse_http_headers(value)?;
657            }
658            "providers.atlascloud.api_key" => {
659                self.providers.atlascloud.api_key = Some(value.to_string());
660            }
661            "providers.atlascloud.base_url" => {
662                self.providers.atlascloud.base_url = Some(value.to_string());
663            }
664            "providers.atlascloud.model" => {
665                self.providers.atlascloud.model = Some(value.to_string());
666            }
667            "providers.atlascloud.http_headers" => {
668                self.providers.atlascloud.http_headers = parse_http_headers(value)?;
669            }
670            "providers.wanjie_ark.api_key" => {
671                self.providers.wanjie_ark.api_key = Some(value.to_string());
672            }
673            "providers.wanjie_ark.base_url" => {
674                self.providers.wanjie_ark.base_url = Some(value.to_string());
675            }
676            "providers.wanjie_ark.model" => {
677                self.providers.wanjie_ark.model = Some(value.to_string());
678            }
679            "providers.volcengine.api_key" => {
680                self.providers.volcengine.api_key = Some(value.to_string());
681            }
682            "providers.volcengine.base_url" => {
683                self.providers.volcengine.base_url = Some(value.to_string());
684            }
685            "providers.volcengine.model" => {
686                self.providers.volcengine.model = Some(value.to_string());
687            }
688            "providers.wanjie_ark.http_headers" => {
689                self.providers.wanjie_ark.http_headers = parse_http_headers(value)?;
690            }
691            "providers.nvidia_nim.api_key" => {
692                self.providers.nvidia_nim.api_key = Some(value.to_string());
693            }
694            "providers.nvidia_nim.base_url" => {
695                self.providers.nvidia_nim.base_url = Some(value.to_string());
696            }
697            "providers.nvidia_nim.model" => {
698                self.providers.nvidia_nim.model = Some(value.to_string());
699            }
700            "providers.nvidia_nim.http_headers" => {
701                self.providers.nvidia_nim.http_headers = parse_http_headers(value)?;
702            }
703            "providers.openrouter.api_key" => {
704                self.providers.openrouter.api_key = Some(value.to_string());
705            }
706            "providers.openrouter.base_url" => {
707                self.providers.openrouter.base_url = Some(value.to_string());
708            }
709            "providers.openrouter.model" => {
710                self.providers.openrouter.model = Some(value.to_string());
711            }
712            "providers.openrouter.http_headers" => {
713                self.providers.openrouter.http_headers = parse_http_headers(value)?;
714            }
715            "providers.xiaomi_mimo.api_key" => {
716                self.providers.xiaomi_mimo.api_key = Some(value.to_string());
717            }
718            "providers.xiaomi_mimo.base_url" => {
719                self.providers.xiaomi_mimo.base_url = Some(value.to_string());
720            }
721            "providers.xiaomi_mimo.model" => {
722                self.providers.xiaomi_mimo.model = Some(value.to_string());
723            }
724            "providers.xiaomi_mimo.http_headers" => {
725                self.providers.xiaomi_mimo.http_headers = parse_http_headers(value)?;
726            }
727            "providers.novita.api_key" => {
728                self.providers.novita.api_key = Some(value.to_string());
729            }
730            "providers.novita.base_url" => {
731                self.providers.novita.base_url = Some(value.to_string());
732            }
733            "providers.novita.model" => {
734                self.providers.novita.model = Some(value.to_string());
735            }
736            "providers.novita.http_headers" => {
737                self.providers.novita.http_headers = parse_http_headers(value)?;
738            }
739            "providers.fireworks.api_key" => {
740                self.providers.fireworks.api_key = Some(value.to_string());
741            }
742            "providers.fireworks.base_url" => {
743                self.providers.fireworks.base_url = Some(value.to_string());
744            }
745            "providers.fireworks.model" => {
746                self.providers.fireworks.model = Some(value.to_string());
747            }
748            "providers.fireworks.http_headers" => {
749                self.providers.fireworks.http_headers = parse_http_headers(value)?;
750            }
751            "providers.siliconflow.api_key" => {
752                self.providers.siliconflow.api_key = Some(value.to_string());
753            }
754            "providers.siliconflow.base_url" => {
755                self.providers.siliconflow.base_url = Some(value.to_string());
756            }
757            "providers.siliconflow.model" => {
758                self.providers.siliconflow.model = Some(value.to_string());
759            }
760            "providers.siliconflow.http_headers" => {
761                self.providers.siliconflow.http_headers = parse_http_headers(value)?;
762            }
763            "providers.moonshot.api_key" => {
764                self.providers.moonshot.api_key = Some(value.to_string());
765            }
766            "providers.moonshot.base_url" => {
767                self.providers.moonshot.base_url = Some(value.to_string());
768            }
769            "providers.moonshot.model" => {
770                self.providers.moonshot.model = Some(value.to_string());
771            }
772            "providers.moonshot.auth_mode" => {
773                self.providers.moonshot.auth_mode = Some(value.to_string());
774            }
775            "providers.moonshot.http_headers" => {
776                self.providers.moonshot.http_headers = parse_http_headers(value)?;
777            }
778            "providers.sglang.api_key" => {
779                self.providers.sglang.api_key = Some(value.to_string());
780            }
781            "providers.sglang.base_url" => {
782                self.providers.sglang.base_url = Some(value.to_string());
783            }
784            "providers.sglang.model" => {
785                self.providers.sglang.model = Some(value.to_string());
786            }
787            "providers.sglang.http_headers" => {
788                self.providers.sglang.http_headers = parse_http_headers(value)?;
789            }
790            "providers.vllm.api_key" => {
791                self.providers.vllm.api_key = Some(value.to_string());
792            }
793            "providers.vllm.base_url" => {
794                self.providers.vllm.base_url = Some(value.to_string());
795            }
796            "providers.vllm.model" => {
797                self.providers.vllm.model = Some(value.to_string());
798            }
799            "providers.vllm.http_headers" => {
800                self.providers.vllm.http_headers = parse_http_headers(value)?;
801            }
802            "providers.ollama.api_key" => {
803                self.providers.ollama.api_key = Some(value.to_string());
804            }
805            "providers.ollama.base_url" => {
806                self.providers.ollama.base_url = Some(value.to_string());
807            }
808            "providers.ollama.model" => {
809                self.providers.ollama.model = Some(value.to_string());
810            }
811            "providers.ollama.http_headers" => {
812                self.providers.ollama.http_headers = parse_http_headers(value)?;
813            }
814            _ => {
815                self.extras
816                    .insert(key.to_string(), toml::Value::String(value.to_string()));
817            }
818        }
819        Ok(())
820    }
821
822    pub fn unset_value(&mut self, key: &str) -> Result<()> {
823        match key {
824            "provider" => self.provider = ProviderKind::Deepseek,
825            "api_key" => self.api_key = None,
826            "base_url" => self.base_url = None,
827            "http_headers" => self.http_headers.clear(),
828            "default_text_model" => self.default_text_model = None,
829            "model" => self.model = None,
830            "auth.mode" => self.auth_mode = None,
831            "output_mode" => self.output_mode = None,
832            "log_level" => self.log_level = None,
833            "telemetry" => self.telemetry = None,
834            "approval_policy" => self.approval_policy = None,
835            "sandbox_mode" => self.sandbox_mode = None,
836            "hook_sinks.unix_socket_path" => {
837                if let Some(sinks) = self.hook_sinks.as_mut() {
838                    sinks.unix_socket_path = None;
839                }
840            }
841            "providers.deepseek.api_key" => {
842                self.providers.deepseek.api_key = None;
843                self.api_key = None;
844            }
845            "providers.deepseek.base_url" => {
846                self.providers.deepseek.base_url = None;
847                self.base_url = None;
848            }
849            "providers.deepseek.model" => {
850                self.providers.deepseek.model = None;
851                self.default_text_model = None;
852            }
853            "providers.deepseek.http_headers" => {
854                self.providers.deepseek.http_headers.clear();
855                self.http_headers.clear();
856            }
857            "providers.openai.api_key" => self.providers.openai.api_key = None,
858            "providers.openai.base_url" => self.providers.openai.base_url = None,
859            "providers.openai.model" => self.providers.openai.model = None,
860            "providers.openai.http_headers" => self.providers.openai.http_headers.clear(),
861            "providers.atlascloud.api_key" => self.providers.atlascloud.api_key = None,
862            "providers.atlascloud.base_url" => self.providers.atlascloud.base_url = None,
863            "providers.atlascloud.model" => self.providers.atlascloud.model = None,
864            "providers.atlascloud.http_headers" => self.providers.atlascloud.http_headers.clear(),
865            "providers.wanjie_ark.api_key" => self.providers.wanjie_ark.api_key = None,
866            "providers.wanjie_ark.base_url" => self.providers.wanjie_ark.base_url = None,
867            "providers.wanjie_ark.model" => self.providers.wanjie_ark.model = None,
868            "providers.volcengine.api_key" => self.providers.volcengine.api_key = None,
869            "providers.volcengine.base_url" => self.providers.volcengine.base_url = None,
870            "providers.volcengine.model" => self.providers.volcengine.model = None,
871            "providers.wanjie_ark.http_headers" => {
872                self.providers.wanjie_ark.http_headers.clear();
873            }
874            "providers.nvidia_nim.api_key" => self.providers.nvidia_nim.api_key = None,
875            "providers.nvidia_nim.base_url" => self.providers.nvidia_nim.base_url = None,
876            "providers.nvidia_nim.model" => self.providers.nvidia_nim.model = None,
877            "providers.nvidia_nim.http_headers" => self.providers.nvidia_nim.http_headers.clear(),
878            "providers.openrouter.api_key" => self.providers.openrouter.api_key = None,
879            "providers.openrouter.base_url" => self.providers.openrouter.base_url = None,
880            "providers.openrouter.model" => self.providers.openrouter.model = None,
881            "providers.openrouter.http_headers" => self.providers.openrouter.http_headers.clear(),
882            "providers.xiaomi_mimo.api_key" => self.providers.xiaomi_mimo.api_key = None,
883            "providers.xiaomi_mimo.base_url" => self.providers.xiaomi_mimo.base_url = None,
884            "providers.xiaomi_mimo.model" => self.providers.xiaomi_mimo.model = None,
885            "providers.xiaomi_mimo.http_headers" => {
886                self.providers.xiaomi_mimo.http_headers.clear();
887            }
888            "providers.novita.api_key" => self.providers.novita.api_key = None,
889            "providers.novita.base_url" => self.providers.novita.base_url = None,
890            "providers.novita.model" => self.providers.novita.model = None,
891            "providers.novita.http_headers" => self.providers.novita.http_headers.clear(),
892            "providers.fireworks.api_key" => self.providers.fireworks.api_key = None,
893            "providers.fireworks.base_url" => self.providers.fireworks.base_url = None,
894            "providers.fireworks.model" => self.providers.fireworks.model = None,
895            "providers.fireworks.http_headers" => self.providers.fireworks.http_headers.clear(),
896            "providers.siliconflow.api_key" => self.providers.siliconflow.api_key = None,
897            "providers.siliconflow.base_url" => self.providers.siliconflow.base_url = None,
898            "providers.siliconflow.model" => self.providers.siliconflow.model = None,
899            "providers.siliconflow.http_headers" => {
900                self.providers.siliconflow.http_headers.clear();
901            }
902            "providers.moonshot.api_key" => self.providers.moonshot.api_key = None,
903            "providers.moonshot.base_url" => self.providers.moonshot.base_url = None,
904            "providers.moonshot.model" => self.providers.moonshot.model = None,
905            "providers.moonshot.auth_mode" => self.providers.moonshot.auth_mode = None,
906            "providers.moonshot.http_headers" => self.providers.moonshot.http_headers.clear(),
907            "providers.sglang.api_key" => self.providers.sglang.api_key = None,
908            "providers.sglang.base_url" => self.providers.sglang.base_url = None,
909            "providers.sglang.model" => self.providers.sglang.model = None,
910            "providers.sglang.http_headers" => self.providers.sglang.http_headers.clear(),
911            "providers.vllm.api_key" => self.providers.vllm.api_key = None,
912            "providers.vllm.base_url" => self.providers.vllm.base_url = None,
913            "providers.vllm.model" => self.providers.vllm.model = None,
914            "providers.vllm.http_headers" => self.providers.vllm.http_headers.clear(),
915            "providers.ollama.api_key" => self.providers.ollama.api_key = None,
916            "providers.ollama.base_url" => self.providers.ollama.base_url = None,
917            "providers.ollama.model" => self.providers.ollama.model = None,
918            "providers.ollama.http_headers" => self.providers.ollama.http_headers.clear(),
919            _ => {
920                self.extras.remove(key);
921            }
922        }
923        Ok(())
924    }
925
926    #[must_use]
927    pub fn list_values(&self) -> BTreeMap<String, String> {
928        let mut out = BTreeMap::new();
929        out.insert("provider".to_string(), self.provider.as_str().to_string());
930
931        if let Some(v) = self.api_key.as_ref() {
932            out.insert("api_key".to_string(), redact_secret(v));
933        }
934        if let Some(v) = self.base_url.as_ref() {
935            out.insert("base_url".to_string(), v.clone());
936        }
937        if let Some(v) = serialize_http_headers(&self.http_headers) {
938            out.insert("http_headers".to_string(), v);
939        }
940        if let Some(v) = self.default_text_model.as_ref() {
941            out.insert("default_text_model".to_string(), v.clone());
942        }
943        if let Some(v) = self.model.as_ref() {
944            out.insert("model".to_string(), v.clone());
945        }
946        if let Some(v) = self.auth_mode.as_ref() {
947            out.insert("auth.mode".to_string(), v.clone());
948        }
949        if let Some(v) = self.output_mode.as_ref() {
950            out.insert("output_mode".to_string(), v.clone());
951        }
952        if let Some(v) = self.log_level.as_ref() {
953            out.insert("log_level".to_string(), v.clone());
954        }
955        if let Some(v) = self.telemetry {
956            out.insert("telemetry".to_string(), v.to_string());
957        }
958        if let Some(v) = self.approval_policy.as_ref() {
959            out.insert("approval_policy".to_string(), v.clone());
960        }
961        if let Some(v) = self.sandbox_mode.as_ref() {
962            out.insert("sandbox_mode".to_string(), v.clone());
963        }
964        if let Some(v) = self
965            .hook_sinks
966            .as_ref()
967            .and_then(|sinks| sinks.unix_socket_path.as_ref())
968        {
969            out.insert(
970                "hook_sinks.unix_socket_path".to_string(),
971                v.display().to_string(),
972            );
973        }
974        if let Some(v) = self.providers.deepseek.api_key.as_ref() {
975            out.insert("providers.deepseek.api_key".to_string(), redact_secret(v));
976        }
977        if let Some(v) = self.providers.deepseek.base_url.as_ref() {
978            out.insert("providers.deepseek.base_url".to_string(), v.clone());
979        }
980        if let Some(v) = self.providers.deepseek.model.as_ref() {
981            out.insert("providers.deepseek.model".to_string(), v.clone());
982        }
983        if let Some(v) = serialize_http_headers(&self.providers.deepseek.http_headers) {
984            out.insert("providers.deepseek.http_headers".to_string(), v);
985        }
986        if let Some(v) = self.providers.openai.api_key.as_ref() {
987            out.insert("providers.openai.api_key".to_string(), redact_secret(v));
988        }
989        if let Some(v) = self.providers.openai.base_url.as_ref() {
990            out.insert("providers.openai.base_url".to_string(), v.clone());
991        }
992        if let Some(v) = self.providers.openai.model.as_ref() {
993            out.insert("providers.openai.model".to_string(), v.clone());
994        }
995        if let Some(v) = serialize_http_headers(&self.providers.openai.http_headers) {
996            out.insert("providers.openai.http_headers".to_string(), v);
997        }
998        if let Some(v) = self.providers.atlascloud.api_key.as_ref() {
999            out.insert("providers.atlascloud.api_key".to_string(), redact_secret(v));
1000        }
1001        if let Some(v) = self.providers.atlascloud.base_url.as_ref() {
1002            out.insert("providers.atlascloud.base_url".to_string(), v.clone());
1003        }
1004        if let Some(v) = self.providers.atlascloud.model.as_ref() {
1005            out.insert("providers.atlascloud.model".to_string(), v.clone());
1006        }
1007        if let Some(v) = serialize_http_headers(&self.providers.atlascloud.http_headers) {
1008            out.insert("providers.atlascloud.http_headers".to_string(), v);
1009        }
1010        if let Some(v) = self.providers.volcengine.api_key.as_ref() {
1011            out.insert("providers.volcengine.api_key".to_string(), redact_secret(v));
1012        }
1013        if let Some(v) = self.providers.volcengine.base_url.as_ref() {
1014            out.insert("providers.volcengine.base_url".to_string(), v.clone());
1015        }
1016        if let Some(v) = self.providers.volcengine.model.as_ref() {
1017            out.insert("providers.volcengine.model".to_string(), v.clone());
1018        }
1019        if let Some(v) = self.providers.wanjie_ark.api_key.as_ref() {
1020            out.insert("providers.wanjie_ark.api_key".to_string(), redact_secret(v));
1021        }
1022        if let Some(v) = self.providers.wanjie_ark.base_url.as_ref() {
1023            out.insert("providers.wanjie_ark.base_url".to_string(), v.clone());
1024        }
1025        if let Some(v) = self.providers.wanjie_ark.model.as_ref() {
1026            out.insert("providers.wanjie_ark.model".to_string(), v.clone());
1027        }
1028        if let Some(v) = serialize_http_headers(&self.providers.volcengine.http_headers) {
1029            out.insert("providers.volcengine.http_headers".to_string(), v);
1030        }
1031        if let Some(v) = serialize_http_headers(&self.providers.wanjie_ark.http_headers) {
1032            out.insert("providers.wanjie_ark.http_headers".to_string(), v);
1033        }
1034        if let Some(v) = self.providers.nvidia_nim.api_key.as_ref() {
1035            out.insert("providers.nvidia_nim.api_key".to_string(), redact_secret(v));
1036        }
1037        if let Some(v) = self.providers.nvidia_nim.base_url.as_ref() {
1038            out.insert("providers.nvidia_nim.base_url".to_string(), v.clone());
1039        }
1040        if let Some(v) = self.providers.nvidia_nim.model.as_ref() {
1041            out.insert("providers.nvidia_nim.model".to_string(), v.clone());
1042        }
1043        if let Some(v) = serialize_http_headers(&self.providers.nvidia_nim.http_headers) {
1044            out.insert("providers.nvidia_nim.http_headers".to_string(), v);
1045        }
1046        if let Some(v) = self.providers.openrouter.api_key.as_ref() {
1047            out.insert("providers.openrouter.api_key".to_string(), redact_secret(v));
1048        }
1049        if let Some(v) = self.providers.openrouter.base_url.as_ref() {
1050            out.insert("providers.openrouter.base_url".to_string(), v.clone());
1051        }
1052        if let Some(v) = self.providers.openrouter.model.as_ref() {
1053            out.insert("providers.openrouter.model".to_string(), v.clone());
1054        }
1055        if let Some(v) = serialize_http_headers(&self.providers.openrouter.http_headers) {
1056            out.insert("providers.openrouter.http_headers".to_string(), v);
1057        }
1058        if let Some(v) = self.providers.xiaomi_mimo.api_key.as_ref() {
1059            out.insert(
1060                "providers.xiaomi_mimo.api_key".to_string(),
1061                redact_secret(v),
1062            );
1063        }
1064        if let Some(v) = self.providers.xiaomi_mimo.base_url.as_ref() {
1065            out.insert("providers.xiaomi_mimo.base_url".to_string(), v.clone());
1066        }
1067        if let Some(v) = self.providers.xiaomi_mimo.model.as_ref() {
1068            out.insert("providers.xiaomi_mimo.model".to_string(), v.clone());
1069        }
1070        if let Some(v) = serialize_http_headers(&self.providers.xiaomi_mimo.http_headers) {
1071            out.insert("providers.xiaomi_mimo.http_headers".to_string(), v);
1072        }
1073        if let Some(v) = self.providers.novita.api_key.as_ref() {
1074            out.insert("providers.novita.api_key".to_string(), redact_secret(v));
1075        }
1076        if let Some(v) = self.providers.novita.base_url.as_ref() {
1077            out.insert("providers.novita.base_url".to_string(), v.clone());
1078        }
1079        if let Some(v) = self.providers.novita.model.as_ref() {
1080            out.insert("providers.novita.model".to_string(), v.clone());
1081        }
1082        if let Some(v) = serialize_http_headers(&self.providers.novita.http_headers) {
1083            out.insert("providers.novita.http_headers".to_string(), v);
1084        }
1085        if let Some(v) = self.providers.fireworks.api_key.as_ref() {
1086            out.insert("providers.fireworks.api_key".to_string(), redact_secret(v));
1087        }
1088        if let Some(v) = self.providers.fireworks.base_url.as_ref() {
1089            out.insert("providers.fireworks.base_url".to_string(), v.clone());
1090        }
1091        if let Some(v) = self.providers.fireworks.model.as_ref() {
1092            out.insert("providers.fireworks.model".to_string(), v.clone());
1093        }
1094        if let Some(v) = serialize_http_headers(&self.providers.fireworks.http_headers) {
1095            out.insert("providers.fireworks.http_headers".to_string(), v);
1096        }
1097        if let Some(v) = self.providers.siliconflow.api_key.as_ref() {
1098            out.insert(
1099                "providers.siliconflow.api_key".to_string(),
1100                redact_secret(v),
1101            );
1102        }
1103        if let Some(v) = self.providers.siliconflow.base_url.as_ref() {
1104            out.insert("providers.siliconflow.base_url".to_string(), v.clone());
1105        }
1106        if let Some(v) = self.providers.siliconflow.model.as_ref() {
1107            out.insert("providers.siliconflow.model".to_string(), v.clone());
1108        }
1109        if let Some(v) = serialize_http_headers(&self.providers.siliconflow.http_headers) {
1110            out.insert("providers.siliconflow.http_headers".to_string(), v);
1111        }
1112        if let Some(v) = self.providers.moonshot.api_key.as_ref() {
1113            out.insert("providers.moonshot.api_key".to_string(), redact_secret(v));
1114        }
1115        if let Some(v) = self.providers.moonshot.base_url.as_ref() {
1116            out.insert("providers.moonshot.base_url".to_string(), v.clone());
1117        }
1118        if let Some(v) = self.providers.moonshot.model.as_ref() {
1119            out.insert("providers.moonshot.model".to_string(), v.clone());
1120        }
1121        if let Some(v) = self.providers.moonshot.auth_mode.as_ref() {
1122            out.insert("providers.moonshot.auth_mode".to_string(), v.clone());
1123        }
1124        if let Some(v) = serialize_http_headers(&self.providers.moonshot.http_headers) {
1125            out.insert("providers.moonshot.http_headers".to_string(), v);
1126        }
1127        if let Some(v) = self.providers.sglang.api_key.as_ref() {
1128            out.insert("providers.sglang.api_key".to_string(), redact_secret(v));
1129        }
1130        if let Some(v) = self.providers.sglang.base_url.as_ref() {
1131            out.insert("providers.sglang.base_url".to_string(), v.clone());
1132        }
1133        if let Some(v) = self.providers.sglang.model.as_ref() {
1134            out.insert("providers.sglang.model".to_string(), v.clone());
1135        }
1136        if let Some(v) = serialize_http_headers(&self.providers.sglang.http_headers) {
1137            out.insert("providers.sglang.http_headers".to_string(), v);
1138        }
1139        if let Some(v) = self.providers.vllm.api_key.as_ref() {
1140            out.insert("providers.vllm.api_key".to_string(), redact_secret(v));
1141        }
1142        if let Some(v) = self.providers.vllm.base_url.as_ref() {
1143            out.insert("providers.vllm.base_url".to_string(), v.clone());
1144        }
1145        if let Some(v) = self.providers.vllm.model.as_ref() {
1146            out.insert("providers.vllm.model".to_string(), v.clone());
1147        }
1148        if let Some(v) = serialize_http_headers(&self.providers.vllm.http_headers) {
1149            out.insert("providers.vllm.http_headers".to_string(), v);
1150        }
1151        if let Some(v) = self.providers.ollama.api_key.as_ref() {
1152            out.insert("providers.ollama.api_key".to_string(), redact_secret(v));
1153        }
1154        if let Some(v) = self.providers.ollama.base_url.as_ref() {
1155            out.insert("providers.ollama.base_url".to_string(), v.clone());
1156        }
1157        if let Some(v) = self.providers.ollama.model.as_ref() {
1158            out.insert("providers.ollama.model".to_string(), v.clone());
1159        }
1160        if let Some(v) = serialize_http_headers(&self.providers.ollama.http_headers) {
1161            out.insert("providers.ollama.http_headers".to_string(), v);
1162        }
1163
1164        for (k, v) in &self.extras {
1165            out.insert(k.clone(), v.to_string());
1166        }
1167        out
1168    }
1169
1170    /// Resolve runtime options without touching platform credential stores.
1171    ///
1172    /// This method keeps library callers prompt-free: CLI flag → config file
1173    /// → environment. Call `resolve_runtime_options_with_secrets` when a
1174    /// user-facing dispatcher should recover credentials from the configured
1175    /// secret store.
1176    #[must_use]
1177    pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
1178        let no_keyring = Secrets::new(std::sync::Arc::new(
1179            codewhale_secrets::InMemoryKeyringStore::new(),
1180        ));
1181        self.resolve_runtime_options_with_secrets(cli, &no_keyring)
1182    }
1183
1184    /// Resolve runtime options using an explicit secrets façade.
1185    ///
1186    /// API-key precedence is **CLI flag → config-file → secret store → environment**.
1187    #[must_use]
1188    pub fn resolve_runtime_options_with_secrets(
1189        &self,
1190        cli: &CliRuntimeOverrides,
1191        secrets: &Secrets,
1192    ) -> ResolvedRuntimeOptions {
1193        let env = EnvRuntimeOverrides::load();
1194        let provider = cli.provider.or(env.provider).unwrap_or(self.provider);
1195
1196        let provider_cfg = self.providers.for_provider(provider);
1197        let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
1198            .then(|| self.api_key.clone())
1199            .flatten();
1200        let root_deepseek_base_url = (provider == ProviderKind::Deepseek)
1201            .then(|| self.base_url.clone())
1202            .flatten();
1203        let root_deepseek_model = (provider == ProviderKind::Deepseek)
1204            .then(|| self.default_text_model.clone())
1205            .flatten();
1206        let auth_mode = cli
1207            .auth_mode
1208            .clone()
1209            .or_else(|| env.auth_mode.clone())
1210            .or_else(|| provider_cfg.auth_mode.clone())
1211            .or_else(|| self.auth_mode.clone());
1212        let base_url = cli
1213            .base_url
1214            .clone()
1215            .or_else(|| env.base_url_for(provider))
1216            .or_else(|| provider_cfg.base_url.clone())
1217            .or(root_deepseek_base_url)
1218            .unwrap_or_else(|| match provider {
1219                ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
1220                ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
1221                ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
1222                ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
1223                ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
1224                ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
1225                ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
1226                ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
1227                ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
1228                ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
1229                ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
1230                ProviderKind::Moonshot => {
1231                    if auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) {
1232                        DEFAULT_KIMI_CODE_BASE_URL.to_string()
1233                    } else {
1234                        DEFAULT_MOONSHOT_BASE_URL.to_string()
1235                    }
1236                }
1237                ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
1238                ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
1239                ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
1240            });
1241        // CLI flag wins outright. Otherwise: config-file → injected secrets/env.
1242        // This makes `deepseek auth set` a reliable fix even when the user's
1243        // shell still exports an old key. When the file is empty, the injected
1244        // secrets façade recovers configured secret-store credentials before
1245        // falling back to ambient env.
1246        let uses_kimi_oauth = provider == ProviderKind::Moonshot
1247            && auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth);
1248        let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
1249        let (api_key, api_key_source) = if let Some(value) = cli.api_key.clone() {
1250            (Some(value), Some(RuntimeApiKeySource::Cli))
1251        } else if uses_kimi_oauth {
1252            (None, None)
1253        } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) {
1254            (Some(value), Some(RuntimeApiKeySource::ConfigFile))
1255        } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
1256            match codewhale_secrets::env_for(provider.as_str()) {
1257                Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
1258                None => (None, None),
1259            }
1260        } else {
1261            match secrets.resolve_with_source(provider.as_str()) {
1262                Some((value, source)) => {
1263                    let source = match source {
1264                        SecretSource::Keyring => RuntimeApiKeySource::Keyring,
1265                        SecretSource::Env => RuntimeApiKeySource::Env,
1266                    };
1267                    (Some(value), Some(source))
1268                }
1269                None => (None, None),
1270            }
1271        };
1272
1273        let env_provider_model = env.model_for(provider, &base_url);
1274        let explicit_model = cli.model.is_some()
1275            || env.model.is_some()
1276            || env_provider_model.is_some()
1277            || provider_cfg.model.is_some()
1278            || root_deepseek_model.is_some()
1279            || self.model.is_some();
1280        let model = cli
1281            .model
1282            .clone()
1283            .or_else(|| env.model.clone())
1284            .or(env_provider_model)
1285            .or_else(|| provider_cfg.model.clone())
1286            .or(root_deepseek_model)
1287            .or_else(|| self.model.clone())
1288            .unwrap_or_else(|| {
1289                if provider == ProviderKind::Moonshot
1290                    && (auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth)
1291                        || moonshot_base_url_uses_kimi_code(&base_url))
1292                {
1293                    DEFAULT_KIMI_CODE_MODEL.to_string()
1294                } else {
1295                    default_model_for_provider(provider).to_string()
1296                }
1297            });
1298        let model =
1299            if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
1300                model.trim().to_string()
1301            } else {
1302                normalize_model_for_provider(provider, &model)
1303            };
1304
1305        let mut http_headers = self.http_headers.clone();
1306        http_headers.extend(provider_cfg.http_headers.clone());
1307        if let Some(env_headers) = env.http_headers {
1308            http_headers.extend(env_headers);
1309        }
1310        http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
1311
1312        let output_mode = cli
1313            .output_mode
1314            .clone()
1315            .or_else(|| env.output_mode.clone())
1316            .or_else(|| self.output_mode.clone());
1317        let log_level = cli
1318            .log_level
1319            .clone()
1320            .or_else(|| env.log_level.clone())
1321            .or_else(|| self.log_level.clone());
1322        let telemetry = cli
1323            .telemetry
1324            .or(env.telemetry)
1325            .or(self.telemetry)
1326            .unwrap_or(false);
1327        let approval_policy = cli
1328            .approval_policy
1329            .clone()
1330            .or_else(|| env.approval_policy.clone())
1331            .or_else(|| self.approval_policy.clone());
1332        let sandbox_mode = cli
1333            .sandbox_mode
1334            .clone()
1335            .or_else(|| env.sandbox_mode.clone())
1336            .or_else(|| self.sandbox_mode.clone());
1337        let yolo = cli.yolo.or(env.yolo);
1338
1339        ResolvedRuntimeOptions {
1340            provider,
1341            model,
1342            api_key,
1343            api_key_source,
1344            base_url,
1345            auth_mode,
1346            output_mode,
1347            log_level,
1348            telemetry,
1349            approval_policy,
1350            sandbox_mode,
1351            yolo,
1352            http_headers,
1353        }
1354    }
1355}
1356
1357fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
1358    if source.model.is_some() {
1359        target.model = source.model.clone();
1360    }
1361}
1362
1363#[must_use]
1364pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
1365    let Some(project_rank) = approval_policy_rank(project) else {
1366        return false;
1367    };
1368    match current.and_then(approval_policy_rank) {
1369        Some(current_rank) => project_rank >= current_rank,
1370        None => project_rank >= 2,
1371    }
1372}
1373
1374#[must_use]
1375pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
1376    let normalized_project = project.trim().to_ascii_lowercase();
1377    if normalized_project == "external-sandbox" {
1378        return current
1379            .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
1380            .unwrap_or(false);
1381    }
1382
1383    let Some(project_rank) = sandbox_mode_rank(project) else {
1384        return false;
1385    };
1386    match current.and_then(sandbox_mode_rank) {
1387        Some(current_rank) => project_rank >= current_rank,
1388        None => project_rank >= 2,
1389    }
1390}
1391
1392fn approval_policy_rank(value: &str) -> Option<u8> {
1393    match value.trim().to_ascii_lowercase().as_str() {
1394        "auto" => Some(0),
1395        "suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
1396        "never" | "deny" | "denied" => Some(2),
1397        _ => None,
1398    }
1399}
1400
1401fn sandbox_mode_rank(value: &str) -> Option<u8> {
1402    match value.trim().to_ascii_lowercase().as_str() {
1403        "danger-full-access" => Some(0),
1404        "external-sandbox" => Some(0),
1405        "workspace-write" => Some(1),
1406        "read-only" => Some(2),
1407        _ => None,
1408    }
1409}
1410
1411/// Load a project-level config from the workspace.
1412///
1413/// Checks `$WORKSPACE/.codewhale/config.toml` first, falling back to
1414/// `$WORKSPACE/.deepseek/config.toml` for backward compatibility.
1415/// Returns `None` if neither file exists or can't be parsed.
1416pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
1417    for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
1418        let path = workspace.join(dir).join(CONFIG_FILE_NAME);
1419        if path.exists()
1420            && let Ok(raw) = fs::read_to_string(&path)
1421        {
1422            return toml::from_str(&raw).ok();
1423        }
1424    }
1425    None
1426}
1427
1428fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
1429    if matches!(
1430        provider,
1431        ProviderKind::Atlascloud
1432            | ProviderKind::WanjieArk
1433            | ProviderKind::Volcengine
1434            | ProviderKind::XiaomiMimo
1435            | ProviderKind::Ollama
1436    ) {
1437        return model.to_string();
1438    }
1439
1440    let normalized = model.trim().to_ascii_lowercase();
1441    if provider == ProviderKind::Openrouter
1442        && let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
1443    {
1444        return canonical.to_string();
1445    }
1446    match (provider, normalized.as_str()) {
1447        (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
1448            DEFAULT_NVIDIA_NIM_MODEL.to_string()
1449        }
1450        (
1451            ProviderKind::NvidiaNim,
1452            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
1453            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
1454        ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
1455        (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
1456            DEFAULT_OPENROUTER_MODEL.to_string()
1457        }
1458        (
1459            ProviderKind::Openrouter,
1460            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
1461            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
1462        ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
1463        (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
1464            DEFAULT_NOVITA_MODEL.to_string()
1465        }
1466        (
1467            ProviderKind::Novita,
1468            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
1469            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
1470        ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
1471        (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
1472            DEFAULT_FIREWORKS_MODEL.to_string()
1473        }
1474        (
1475            ProviderKind::Siliconflow,
1476            "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
1477        ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
1478        (
1479            ProviderKind::Siliconflow,
1480            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
1481        ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
1482        (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2") => DEFAULT_MOONSHOT_MODEL.to_string(),
1483        (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
1484            DEFAULT_SGLANG_MODEL.to_string()
1485        }
1486        (
1487            ProviderKind::Sglang,
1488            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
1489            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
1490        ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
1491        (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
1492            DEFAULT_VLLM_MODEL.to_string()
1493        }
1494        (
1495            ProviderKind::Vllm,
1496            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
1497            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
1498        ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
1499        _ => model.to_string(),
1500    }
1501}
1502
1503fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
1504    let normalized = model.trim().to_ascii_lowercase();
1505    let normalized = normalized.replace(['_', ' '], "-");
1506    match normalized.as_str() {
1507        OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
1508        | "trinity"
1509        | "trinity-large-thinking"
1510        | "arcee-trinity"
1511        | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
1512        OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
1513            Some(OPENROUTER_GEMMA_4_31B_MODEL)
1514        }
1515        OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
1516            Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
1517        }
1518        OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
1519            Some(OPENROUTER_GLM_5_1_MODEL)
1520        }
1521        OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
1522            Some(OPENROUTER_KIMI_K2_6_MODEL)
1523        }
1524        OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
1525        | "nemotron-3-nano-omni"
1526        | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
1527        OPENROUTER_QWEN_3_6_35B_A3B_MODEL
1528        | "qwen3.6-35b-a3b"
1529        | "qwen-3.6-35b-a3b"
1530        | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
1531        OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
1532            Some(OPENROUTER_QWEN_3_6_27B_MODEL)
1533        }
1534        OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
1535            Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
1536        }
1537        OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
1538        | "mimo-v2.5-pro"
1539        | "mimo-v2-5-pro"
1540        | "xiaomi-mimo-v2.5-pro"
1541        | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
1542        OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
1543        | "mimo-v2.5"
1544        | "mimo-v2-5"
1545        | "xiaomi-mimo-v2.5"
1546        | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
1547        _ => None,
1548    }
1549}
1550
1551fn default_model_for_provider(provider: ProviderKind) -> &'static str {
1552    match provider {
1553        ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
1554        ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
1555        ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
1556        ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
1557        ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
1558        ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
1559        ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
1560        ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
1561        ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
1562        ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
1563        ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_MODEL,
1564        ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
1565        ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
1566        ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
1567        ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
1568    }
1569}
1570
1571fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
1572    match provider {
1573        ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
1574        ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
1575        ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
1576        ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
1577        ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
1578        ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
1579        ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
1580        ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
1581        ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
1582        ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
1583        ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
1584        ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
1585        ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
1586        ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
1587        ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
1588    }
1589}
1590
1591fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
1592    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
1593    normalized == DEFAULT_KIMI_CODE_BASE_URL
1594        || normalized == "https://api.kimi.com/coding"
1595        || normalized.starts_with("https://api.kimi.com/coding/")
1596}
1597
1598fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
1599    if provider == ProviderKind::Siliconflow && siliconflow_base_url_is_official(base_url) {
1600        return false;
1601    }
1602    let actual = base_url.trim_end_matches('/');
1603    let default = default_base_url_for_provider(provider).trim_end_matches('/');
1604    actual != default
1605}
1606
1607fn siliconflow_base_url_is_official(base_url: &str) -> bool {
1608    matches!(
1609        base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
1610        "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
1611    )
1612}
1613
1614fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
1615    base_url_is_custom_for_provider(provider, base_url)
1616}
1617
1618fn should_skip_secret_store_for_provider(
1619    provider: ProviderKind,
1620    base_url: &str,
1621    auth_mode: Option<&str>,
1622) -> bool {
1623    if auth_mode_requires_api_key(auth_mode) {
1624        return false;
1625    }
1626    if auth_mode_disables_api_key(auth_mode) {
1627        return true;
1628    }
1629
1630    matches!(
1631        provider,
1632        ProviderKind::Sglang | ProviderKind::Vllm | ProviderKind::Ollama
1633    ) || base_url_uses_local_host(base_url)
1634}
1635
1636fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
1637    matches!(
1638        auth_mode
1639            .map(str::trim)
1640            .filter(|value| !value.is_empty())
1641            .map(|value| value.to_ascii_lowercase()),
1642        Some(value)
1643            if matches!(
1644                value.as_str(),
1645                "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
1646            )
1647    )
1648}
1649
1650fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
1651    matches!(
1652        auth_mode
1653            .map(str::trim)
1654            .filter(|value| !value.is_empty())
1655            .map(|value| value.to_ascii_lowercase()),
1656        Some(value)
1657            if matches!(
1658                value.as_str(),
1659                "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
1660            )
1661    )
1662}
1663
1664fn auth_mode_uses_kimi_oauth(auth_mode: &str) -> bool {
1665    matches!(
1666        auth_mode
1667            .trim()
1668            .to_ascii_lowercase()
1669            .replace('-', "_")
1670            .as_str(),
1671        "kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
1672    )
1673}
1674
1675fn base_url_uses_local_host(base_url: &str) -> bool {
1676    let Some(host) = base_url_host(base_url) else {
1677        return false;
1678    };
1679    let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
1680    if matches!(host.as_str(), "localhost" | "0.0.0.0") {
1681        return true;
1682    }
1683    host.parse::<std::net::IpAddr>()
1684        .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
1685}
1686
1687fn base_url_host(base_url: &str) -> Option<&str> {
1688    let without_scheme = base_url
1689        .split_once("://")
1690        .map_or(base_url, |(_, rest)| rest);
1691    let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
1692    if let Some(rest) = authority.strip_prefix('[') {
1693        return rest.split_once(']').map(|(host, _)| host);
1694    }
1695    authority.split(':').next().filter(|host| !host.is_empty())
1696}
1697
1698#[derive(Debug, Clone, Default)]
1699pub struct CliRuntimeOverrides {
1700    pub provider: Option<ProviderKind>,
1701    pub model: Option<String>,
1702    pub api_key: Option<String>,
1703    pub base_url: Option<String>,
1704    pub auth_mode: Option<String>,
1705    pub output_mode: Option<String>,
1706    pub log_level: Option<String>,
1707    pub telemetry: Option<bool>,
1708    pub approval_policy: Option<String>,
1709    pub sandbox_mode: Option<String>,
1710    pub yolo: Option<bool>,
1711}
1712
1713#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1714pub enum RuntimeApiKeySource {
1715    Cli,
1716    ConfigFile,
1717    Keyring,
1718    Env,
1719}
1720
1721impl RuntimeApiKeySource {
1722    #[must_use]
1723    pub fn as_env_value(self) -> &'static str {
1724        match self {
1725            Self::Cli => "cli",
1726            Self::ConfigFile => "config",
1727            Self::Keyring => "keyring",
1728            Self::Env => "env",
1729        }
1730    }
1731}
1732
1733#[derive(Debug, Clone)]
1734pub struct ResolvedRuntimeOptions {
1735    pub provider: ProviderKind,
1736    pub model: String,
1737    pub api_key: Option<String>,
1738    pub api_key_source: Option<RuntimeApiKeySource>,
1739    pub base_url: String,
1740    pub auth_mode: Option<String>,
1741    pub output_mode: Option<String>,
1742    pub log_level: Option<String>,
1743    pub telemetry: bool,
1744    pub approval_policy: Option<String>,
1745    pub sandbox_mode: Option<String>,
1746    pub yolo: Option<bool>,
1747    pub http_headers: BTreeMap<String, String>,
1748}
1749
1750#[derive(Debug, Clone)]
1751pub struct ConfigStore {
1752    path: PathBuf,
1753    pub config: ConfigToml,
1754}
1755
1756impl ConfigStore {
1757    pub fn load(path: Option<PathBuf>) -> Result<Self> {
1758        let path = resolve_config_path(path)?;
1759        if !path.exists() {
1760            return Ok(Self {
1761                path,
1762                config: ConfigToml::default(),
1763            });
1764        }
1765
1766        let raw = fs::read_to_string(&path)
1767            .with_context(|| format!("failed to read config at {}", path.display()))?;
1768        let parsed: ConfigToml = toml::from_str(&raw)
1769            .with_context(|| format!("failed to parse config at {}", path.display()))?;
1770
1771        Ok(Self {
1772            path,
1773            config: parsed,
1774        })
1775    }
1776
1777    pub fn save(&self) -> Result<()> {
1778        if let Some(parent) = self.path.parent() {
1779            fs::create_dir_all(parent).with_context(|| {
1780                format!("failed to create config directory {}", parent.display())
1781            })?;
1782        }
1783        let body = toml::to_string_pretty(&self.config).context("failed to serialize config")?;
1784        #[cfg(unix)]
1785        {
1786            let mut file = fs::OpenOptions::new()
1787                .write(true)
1788                .create(true)
1789                .truncate(true)
1790                .mode(0o600)
1791                .open(&self.path)
1792                .with_context(|| format!("failed to write config at {}", self.path.display()))?;
1793            file.write_all(body.as_bytes())
1794                .with_context(|| format!("failed to write config at {}", self.path.display()))?;
1795            file.set_permissions(fs::Permissions::from_mode(0o600))
1796                .with_context(|| {
1797                    format!(
1798                        "failed to set config permissions at {}",
1799                        self.path.display()
1800                    )
1801                })?;
1802        }
1803        #[cfg(not(unix))]
1804        {
1805            fs::write(&self.path, body)
1806                .with_context(|| format!("failed to write config at {}", self.path.display()))?;
1807        }
1808        Ok(())
1809    }
1810
1811    #[must_use]
1812    pub fn path(&self) -> &Path {
1813        &self.path
1814    }
1815}
1816
1817/// Process-wide default [`Secrets`] façade. The first caller wins; the
1818/// lock is exposed so test or CLI code can install an explicit
1819/// backend (e.g. an [`codewhale_secrets::InMemoryKeyringStore`]) before
1820/// any resolver runs.
1821pub fn default_secrets() -> &'static Secrets {
1822    static SECRETS: OnceLock<Secrets> = OnceLock::new();
1823    SECRETS.get_or_init(|| {
1824        // Tests should never poke real platform credential stores. Cargo sets the
1825        // `RUST_TEST_*` family of env vars (and `CARGO_PKG_NAME` is
1826        // always populated), but the `cfg(test)` flag is the canonical
1827        // signal here. See `install_test_secrets` for explicit installs.
1828        #[cfg(test)]
1829        {
1830            Secrets::new(std::sync::Arc::new(
1831                codewhale_secrets::InMemoryKeyringStore::new(),
1832            ))
1833        }
1834        #[cfg(not(test))]
1835        {
1836            Secrets::auto_detect()
1837        }
1838    })
1839}
1840
1841// ── CodeWhale state root (v0.8.44) ──────────────────────────────────
1842//
1843// v0.8.44 migrates product-owned app state from ~/.deepseek/ to
1844// ~/.codewhale/ while keeping ~/.deepseek/ as a compatibility fallback.
1845// New installs write to ~/.codewhale/. Existing installs with only
1846// ~/.deepseek/ continue working without data loss.
1847
1848/// Canonical CodeWhale app directory name under $HOME.
1849pub const CODEWHALE_APP_DIR: &str = ".codewhale";
1850
1851/// Legacy DeepSeek-branded app directory name (compatibility fallback).
1852pub const LEGACY_APP_DIR: &str = ".deepseek";
1853
1854/// Resolve the primary CodeWhale home directory.
1855///
1856/// `$CODEWHALE_HOME` takes precedence when set. Otherwise defaults to
1857/// `$HOME/.codewhale`. This is the write target for new product state.
1858pub fn codewhale_home() -> Result<PathBuf> {
1859    if let Ok(val) = std::env::var("CODEWHALE_HOME") {
1860        let trimmed = val.trim();
1861        if !trimmed.is_empty() {
1862            return Ok(PathBuf::from(trimmed));
1863        }
1864    }
1865    let home = dirs::home_dir().context("failed to resolve home directory")?;
1866    Ok(home.join(CODEWHALE_APP_DIR))
1867}
1868
1869/// Resolve the legacy DeepSeek home directory (`$HOME/.deepseek`).
1870///
1871/// Always returns the legacy path regardless of whether it exists.
1872pub fn legacy_deepseek_home() -> Result<PathBuf> {
1873    let home = dirs::home_dir().context("failed to resolve home directory")?;
1874    Ok(home.join(LEGACY_APP_DIR))
1875}
1876
1877/// Resolve a state subdirectory, preferring the CodeWhale root if
1878/// it already exists, otherwise falling back to the legacy root.
1879///
1880/// This is the read-path resolver: it returns the primary path when
1881/// migration has occurred or on a fresh install, but keeps reading
1882/// from the legacy path for users who haven't migrated yet.
1883pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
1884    let primary = codewhale_home()?.join(subdir);
1885    if primary.exists() {
1886        return Ok(primary);
1887    }
1888    let legacy = legacy_deepseek_home()?.join(subdir);
1889    if legacy.exists() {
1890        return Ok(legacy);
1891    }
1892    // Neither exists — return primary for first-write creation.
1893    Ok(primary)
1894}
1895
1896/// Ensure a state subdirectory exists under the primary CodeWhale root,
1897/// creating it if necessary. This is the write-path resolver.
1898pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
1899    let dir = codewhale_home()?.join(subdir);
1900    std::fs::create_dir_all(&dir)
1901        .with_context(|| format!("failed to create {}/", dir.display()))?;
1902    Ok(dir)
1903}
1904
1905/// Resolve a project-local state subdirectory, preferring `.codewhale/`
1906/// when it exists, falling back to `.deepseek/` for legacy projects.
1907///
1908/// Returns `(true, path)` when the primary `.codewhale/` path is used,
1909/// `(false, path)` for the legacy fallback. The boolean helps callers
1910/// emit a deprecation notice on legacy paths.
1911pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> (bool, PathBuf) {
1912    let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
1913    if primary.exists() {
1914        return (true, primary);
1915    }
1916    let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
1917    (false, legacy)
1918}
1919
1920/// Ensure a project-local state subdirectory exists under `.codewhale/`,
1921/// creating it if necessary. Returns the directory path.
1922pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
1923    let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
1924    std::fs::create_dir_all(&dir)
1925        .with_context(|| format!("failed to create {}/", dir.display()))?;
1926    Ok(dir)
1927}
1928
1929pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
1930    let path = if let Some(path) = explicit {
1931        path
1932    } else if let Ok(path) = std::env::var("CODEWHALE_CONFIG_PATH") {
1933        let trimmed = path.trim();
1934        if !trimmed.is_empty() {
1935            PathBuf::from(trimmed)
1936        } else {
1937            return default_config_path();
1938        }
1939    } else if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
1940        let trimmed = path.trim();
1941        if !trimmed.is_empty() {
1942            PathBuf::from(trimmed)
1943        } else {
1944            return default_config_path();
1945        }
1946    } else {
1947        return default_config_path();
1948    };
1949    normalize_config_file_path(path)
1950}
1951
1952pub fn default_config_path() -> Result<PathBuf> {
1953    // Prefer ~/.codewhale/config.toml when it exists (fresh install or
1954    // migrated), otherwise fall back to ~/.deepseek/config.toml.
1955    let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
1956    if primary.exists() {
1957        return Ok(primary);
1958    }
1959    let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
1960    if legacy.exists() {
1961        return Ok(legacy);
1962    }
1963    // Neither exists — return primary so first write creates it there.
1964    Ok(primary)
1965}
1966
1967/// v0.8.44: one-time migration from `~/.deepseek/config.toml` to
1968/// `~/.codewhale/config.toml`. Called on first launch after the config
1969/// is loaded; copies the legacy file if the primary doesn't exist yet.
1970/// Never overwrites an existing primary config.
1971pub fn migrate_config_if_needed() -> Result<()> {
1972    let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
1973    if primary.exists() {
1974        return Ok(());
1975    }
1976    let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
1977    if !legacy.exists() {
1978        return Ok(());
1979    }
1980    // Copy the config to the new home.
1981    if let Some(parent) = primary.parent() {
1982        std::fs::create_dir_all(parent).context("failed to create codewhale config directory")?;
1983    }
1984    std::fs::copy(&legacy, &primary)
1985        .context("failed to migrate config from deepseek to codewhale home")?;
1986    tracing::info!(
1987        "Migrated config from {} to {}",
1988        legacy.display(),
1989        primary.display()
1990    );
1991    Ok(())
1992}
1993
1994fn parse_bool(raw: &str) -> Result<bool> {
1995    match raw.trim().to_ascii_lowercase().as_str() {
1996        "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
1997        "0" | "false" | "no" | "off" | "disabled" => Ok(false),
1998        _ => bail!("invalid boolean '{raw}'"),
1999    }
2000}
2001
2002fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
2003    let mut headers = BTreeMap::new();
2004    for pair in raw.trim().split(',') {
2005        let pair = pair.trim();
2006        if pair.is_empty() {
2007            continue;
2008        }
2009        let Some((name, value)) = pair.split_once('=') else {
2010            bail!("invalid header pair '{pair}', expected name=value");
2011        };
2012        let name = name.trim();
2013        let value = value.trim();
2014        if name.is_empty() {
2015            bail!("header name cannot be empty");
2016        }
2017        if value.is_empty() {
2018            continue;
2019        }
2020        headers.insert(name.to_string(), value.to_string());
2021    }
2022    Ok(headers)
2023}
2024
2025fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
2026    if headers.is_empty() {
2027        return None;
2028    }
2029    Some(
2030        headers
2031            .iter()
2032            .map(|(name, value)| format!("{name}={value}"))
2033            .collect::<Vec<_>>()
2034            .join(","),
2035    )
2036}
2037
2038fn redact_secret(secret: &str) -> String {
2039    let chars: Vec<char> = secret.chars().collect();
2040    if chars.len() <= 16 {
2041        return "********".to_string();
2042    }
2043    let prefix: String = chars.iter().take(4).collect();
2044    let suffix: String = chars
2045        .iter()
2046        .rev()
2047        .take(4)
2048        .collect::<Vec<_>>()
2049        .into_iter()
2050        .rev()
2051        .collect();
2052    format!("{prefix}***{suffix}")
2053}
2054
2055#[must_use]
2056pub fn is_sensitive_config_key(key: &str) -> bool {
2057    key == "api_key" || key.ends_with(".api_key")
2058}
2059
2060fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
2061    if path.as_os_str().is_empty() {
2062        bail!("config path cannot be empty");
2063    }
2064    if path
2065        .components()
2066        .any(|component| matches!(component, Component::ParentDir))
2067    {
2068        bail!("config path cannot contain '..' components");
2069    }
2070    if path.file_name().is_none() {
2071        bail!("config path must include a file name");
2072    }
2073    if path.is_absolute() {
2074        return Ok(path);
2075    }
2076    Ok(std::env::current_dir()
2077        .context("failed to resolve current directory for config path")?
2078        .join(path))
2079}
2080
2081#[derive(Debug, Clone, Default)]
2082struct EnvRuntimeOverrides {
2083    provider: Option<ProviderKind>,
2084    model: Option<String>,
2085    volcengine_model: Option<String>,
2086    wanjie_ark_model: Option<String>,
2087    moonshot_model: Option<String>,
2088    xiaomi_mimo_model: Option<String>,
2089    output_mode: Option<String>,
2090    auth_mode: Option<String>,
2091    log_level: Option<String>,
2092    telemetry: Option<bool>,
2093    approval_policy: Option<String>,
2094    sandbox_mode: Option<String>,
2095    yolo: Option<bool>,
2096    http_headers: Option<BTreeMap<String, String>>,
2097    deepseek_base_url: Option<String>,
2098    nvidia_base_url: Option<String>,
2099    openai_base_url: Option<String>,
2100    atlascloud_base_url: Option<String>,
2101    volcengine_base_url: Option<String>,
2102    wanjie_ark_base_url: Option<String>,
2103    openrouter_base_url: Option<String>,
2104    xiaomi_mimo_base_url: Option<String>,
2105    novita_base_url: Option<String>,
2106    fireworks_base_url: Option<String>,
2107    siliconflow_base_url: Option<String>,
2108    siliconflow_model: Option<String>,
2109    moonshot_base_url: Option<String>,
2110    sglang_base_url: Option<String>,
2111    vllm_base_url: Option<String>,
2112    ollama_base_url: Option<String>,
2113}
2114
2115impl EnvRuntimeOverrides {
2116    fn load() -> Self {
2117        Self {
2118            provider: std::env::var("CODEWHALE_PROVIDER")
2119                .or_else(|_| std::env::var("DEEPSEEK_PROVIDER"))
2120                .ok()
2121                .and_then(|v| ProviderKind::parse(&v)),
2122            model: std::env::var("CODEWHALE_MODEL")
2123                .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
2124                .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
2125                .ok()
2126                .filter(|v| !v.trim().is_empty()),
2127            volcengine_model: std::env::var("VOLCENGINE_MODEL")
2128                .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
2129                .ok()
2130                .filter(|v| !v.trim().is_empty()),
2131            wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
2132                .or_else(|_| std::env::var("WANJIE_MODEL"))
2133                .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
2134                .ok()
2135                .filter(|v| !v.trim().is_empty()),
2136            moonshot_model: std::env::var("MOONSHOT_MODEL")
2137                .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
2138                .or_else(|_| std::env::var("KIMI_MODEL"))
2139                .ok()
2140                .filter(|v| !v.trim().is_empty()),
2141            xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
2142                .or_else(|_| std::env::var("MIMO_MODEL"))
2143                .ok()
2144                .filter(|v| !v.trim().is_empty()),
2145            output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(),
2146            auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(),
2147            log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(),
2148            telemetry: std::env::var("DEEPSEEK_TELEMETRY")
2149                .ok()
2150                .and_then(|v| parse_bool(&v).ok()),
2151            approval_policy: std::env::var("DEEPSEEK_APPROVAL_POLICY").ok(),
2152            sandbox_mode: std::env::var("DEEPSEEK_SANDBOX_MODE").ok(),
2153            yolo: std::env::var("DEEPSEEK_YOLO")
2154                .ok()
2155                .and_then(|v| parse_bool(&v).ok()),
2156            http_headers: std::env::var("DEEPSEEK_HTTP_HEADERS")
2157                .ok()
2158                .and_then(|value| parse_http_headers(&value).ok())
2159                .filter(|headers| !headers.is_empty()),
2160            deepseek_base_url: std::env::var("CODEWHALE_BASE_URL")
2161                .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
2162                .ok()
2163                .filter(|v| !v.trim().is_empty()),
2164            nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
2165                .or_else(|_| std::env::var("NIM_BASE_URL"))
2166                .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
2167                .ok()
2168                .filter(|v| !v.trim().is_empty()),
2169            openai_base_url: std::env::var("OPENAI_BASE_URL")
2170                .ok()
2171                .filter(|v| !v.trim().is_empty()),
2172            atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
2173                .ok()
2174                .filter(|v| !v.trim().is_empty()),
2175            volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
2176                .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
2177                .or_else(|_| std::env::var("ARK_BASE_URL"))
2178                .ok()
2179                .filter(|v| !v.trim().is_empty()),
2180            wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
2181                .or_else(|_| std::env::var("WANJIE_BASE_URL"))
2182                .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
2183                .ok()
2184                .filter(|v| !v.trim().is_empty()),
2185            openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
2186                .ok()
2187                .filter(|v| !v.trim().is_empty()),
2188            xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
2189                .or_else(|_| std::env::var("MIMO_BASE_URL"))
2190                .ok()
2191                .filter(|v| !v.trim().is_empty()),
2192            novita_base_url: std::env::var("NOVITA_BASE_URL")
2193                .ok()
2194                .filter(|v| !v.trim().is_empty()),
2195            fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
2196                .ok()
2197                .filter(|v| !v.trim().is_empty()),
2198            siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
2199                .ok()
2200                .filter(|v| !v.trim().is_empty()),
2201            siliconflow_model: std::env::var("SILICONFLOW_MODEL")
2202                .ok()
2203                .filter(|v| !v.trim().is_empty()),
2204            moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
2205                .or_else(|_| std::env::var("KIMI_BASE_URL"))
2206                .ok()
2207                .filter(|v| !v.trim().is_empty()),
2208            sglang_base_url: std::env::var("SGLANG_BASE_URL")
2209                .ok()
2210                .filter(|v| !v.trim().is_empty()),
2211            vllm_base_url: std::env::var("VLLM_BASE_URL")
2212                .ok()
2213                .filter(|v| !v.trim().is_empty()),
2214            ollama_base_url: std::env::var("OLLAMA_BASE_URL")
2215                .ok()
2216                .filter(|v| !v.trim().is_empty()),
2217        }
2218    }
2219
2220    fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
2221        // Defaults belong in the resolver's final fallback so config-file
2222        // values (`providers.<name>.base_url`) still win when env is unset.
2223        match provider {
2224            ProviderKind::Deepseek => self.deepseek_base_url.clone(),
2225            ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
2226            ProviderKind::Openai => self.openai_base_url.clone(),
2227            ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
2228            ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
2229            ProviderKind::Volcengine => self.volcengine_base_url.clone(),
2230            ProviderKind::Openrouter => self.openrouter_base_url.clone(),
2231            ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
2232            ProviderKind::Novita => self.novita_base_url.clone(),
2233            ProviderKind::Fireworks => self.fireworks_base_url.clone(),
2234            ProviderKind::Siliconflow => self.siliconflow_base_url.clone(),
2235            ProviderKind::Moonshot => self.moonshot_base_url.clone(),
2236            ProviderKind::Sglang => self.sglang_base_url.clone(),
2237            ProviderKind::Vllm => self.vllm_base_url.clone(),
2238            ProviderKind::Ollama => self.ollama_base_url.clone(),
2239        }
2240    }
2241
2242    fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
2243        let model = match provider {
2244            ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
2245            ProviderKind::Volcengine => self.volcengine_model.clone(),
2246            ProviderKind::Siliconflow => self.siliconflow_model.clone(),
2247            ProviderKind::Moonshot => self.moonshot_model.clone(),
2248            ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
2249            _ => None,
2250        }?;
2251
2252        if provider_preserves_custom_base_url_model(provider, base_url) {
2253            Some(model.trim().to_string())
2254        } else {
2255            Some(normalize_model_for_provider(provider, &model))
2256        }
2257    }
2258}
2259
2260#[cfg(test)]
2261mod tests {
2262    use super::*;
2263    use std::env;
2264    use std::ffi::OsString;
2265    use std::sync::Arc;
2266    use std::sync::{Mutex, OnceLock};
2267
2268    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
2269        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2270        LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
2271    }
2272
2273    #[test]
2274    fn network_policy_toml_deserializes_proxy_hosts() {
2275        let policy: NetworkPolicyToml = toml::from_str(
2276            r#"
2277            default = "allow"
2278            proxy = ["github.com", ".githubusercontent.com"]
2279            "#,
2280        )
2281        .expect("network policy toml");
2282
2283        assert_eq!(policy.default, "allow");
2284        assert_eq!(policy.proxy, ["github.com", ".githubusercontent.com"]);
2285        assert!(policy.audit);
2286    }
2287
2288    struct EnvGuard {
2289        deepseek_api_key: Option<OsString>,
2290        deepseek_base_url: Option<OsString>,
2291        deepseek_http_headers: Option<OsString>,
2292        deepseek_model: Option<OsString>,
2293        deepseek_default_text_model: Option<OsString>,
2294        deepseek_provider: Option<OsString>,
2295        deepseek_auth_mode: Option<OsString>,
2296        nvidia_api_key: Option<OsString>,
2297        nvidia_nim_api_key: Option<OsString>,
2298        nim_base_url: Option<OsString>,
2299        nvidia_base_url: Option<OsString>,
2300        nvidia_nim_base_url: Option<OsString>,
2301        openrouter_api_key: Option<OsString>,
2302        openrouter_base_url: Option<OsString>,
2303        xiaomi_mimo_api_key: Option<OsString>,
2304        xiaomi_api_key: Option<OsString>,
2305        mimo_api_key: Option<OsString>,
2306        xiaomi_mimo_base_url: Option<OsString>,
2307        mimo_base_url: Option<OsString>,
2308        xiaomi_mimo_model: Option<OsString>,
2309        mimo_model: Option<OsString>,
2310        wanjie_ark_api_key: Option<OsString>,
2311        wanjie_ark_base_url: Option<OsString>,
2312        wanjie_base_url: Option<OsString>,
2313        wanjie_maas_base_url: Option<OsString>,
2314        volcengine_model: Option<OsString>,
2315        wanjie_ark_model: Option<OsString>,
2316        wanjie_model: Option<OsString>,
2317        wanjie_maas_model: Option<OsString>,
2318        novita_api_key: Option<OsString>,
2319        novita_base_url: Option<OsString>,
2320        fireworks_api_key: Option<OsString>,
2321        fireworks_base_url: Option<OsString>,
2322        siliconflow_api_key: Option<OsString>,
2323        siliconflow_base_url: Option<OsString>,
2324        siliconflow_model: Option<OsString>,
2325        moonshot_api_key: Option<OsString>,
2326        moonshot_base_url: Option<OsString>,
2327        moonshot_model: Option<OsString>,
2328        kimi_api_key: Option<OsString>,
2329        kimi_base_url: Option<OsString>,
2330        kimi_model: Option<OsString>,
2331        kimi_model_name: Option<OsString>,
2332        sglang_api_key: Option<OsString>,
2333        sglang_base_url: Option<OsString>,
2334        vllm_api_key: Option<OsString>,
2335        vllm_base_url: Option<OsString>,
2336        ollama_api_key: Option<OsString>,
2337        ollama_base_url: Option<OsString>,
2338        codewhale_provider: Option<OsString>,
2339        codewhale_model: Option<OsString>,
2340        codewhale_base_url: Option<OsString>,
2341    }
2342
2343    impl EnvGuard {
2344        fn without_deepseek_runtime_overrides() -> Self {
2345            let guard = Self {
2346                deepseek_api_key: env::var_os("DEEPSEEK_API_KEY"),
2347                deepseek_base_url: env::var_os("DEEPSEEK_BASE_URL"),
2348                deepseek_http_headers: env::var_os("DEEPSEEK_HTTP_HEADERS"),
2349                deepseek_model: env::var_os("DEEPSEEK_MODEL"),
2350                deepseek_default_text_model: env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL"),
2351                deepseek_provider: env::var_os("DEEPSEEK_PROVIDER"),
2352                deepseek_auth_mode: env::var_os("DEEPSEEK_AUTH_MODE"),
2353                codewhale_provider: env::var_os("CODEWHALE_PROVIDER"),
2354                codewhale_model: env::var_os("CODEWHALE_MODEL"),
2355                codewhale_base_url: env::var_os("CODEWHALE_BASE_URL"),
2356                nvidia_api_key: env::var_os("NVIDIA_API_KEY"),
2357                nvidia_nim_api_key: env::var_os("NVIDIA_NIM_API_KEY"),
2358                nim_base_url: env::var_os("NIM_BASE_URL"),
2359                nvidia_base_url: env::var_os("NVIDIA_BASE_URL"),
2360                nvidia_nim_base_url: env::var_os("NVIDIA_NIM_BASE_URL"),
2361                openrouter_api_key: env::var_os("OPENROUTER_API_KEY"),
2362                openrouter_base_url: env::var_os("OPENROUTER_BASE_URL"),
2363                xiaomi_mimo_api_key: env::var_os("XIAOMI_MIMO_API_KEY"),
2364                xiaomi_api_key: env::var_os("XIAOMI_API_KEY"),
2365                mimo_api_key: env::var_os("MIMO_API_KEY"),
2366                xiaomi_mimo_base_url: env::var_os("XIAOMI_MIMO_BASE_URL"),
2367                mimo_base_url: env::var_os("MIMO_BASE_URL"),
2368                xiaomi_mimo_model: env::var_os("XIAOMI_MIMO_MODEL"),
2369                mimo_model: env::var_os("MIMO_MODEL"),
2370                wanjie_ark_api_key: env::var_os("WANJIE_ARK_API_KEY"),
2371                wanjie_ark_base_url: env::var_os("WANJIE_ARK_BASE_URL"),
2372                wanjie_base_url: env::var_os("WANJIE_BASE_URL"),
2373                wanjie_maas_base_url: env::var_os("WANJIE_MAAS_BASE_URL"),
2374                volcengine_model: env::var_os("VOLCENGINE_MODEL"),
2375                wanjie_ark_model: env::var_os("WANJIE_ARK_MODEL"),
2376                wanjie_model: env::var_os("WANJIE_MODEL"),
2377                wanjie_maas_model: env::var_os("WANJIE_MAAS_MODEL"),
2378                novita_api_key: env::var_os("NOVITA_API_KEY"),
2379                novita_base_url: env::var_os("NOVITA_BASE_URL"),
2380                fireworks_api_key: env::var_os("FIREWORKS_API_KEY"),
2381                fireworks_base_url: env::var_os("FIREWORKS_BASE_URL"),
2382                siliconflow_api_key: env::var_os("SILICONFLOW_API_KEY"),
2383                siliconflow_base_url: env::var_os("SILICONFLOW_BASE_URL"),
2384                siliconflow_model: env::var_os("SILICONFLOW_MODEL"),
2385                moonshot_api_key: env::var_os("MOONSHOT_API_KEY"),
2386                moonshot_base_url: env::var_os("MOONSHOT_BASE_URL"),
2387                moonshot_model: env::var_os("MOONSHOT_MODEL"),
2388                kimi_api_key: env::var_os("KIMI_API_KEY"),
2389                kimi_base_url: env::var_os("KIMI_BASE_URL"),
2390                kimi_model: env::var_os("KIMI_MODEL"),
2391                kimi_model_name: env::var_os("KIMI_MODEL_NAME"),
2392                sglang_api_key: env::var_os("SGLANG_API_KEY"),
2393                sglang_base_url: env::var_os("SGLANG_BASE_URL"),
2394                vllm_api_key: env::var_os("VLLM_API_KEY"),
2395                vllm_base_url: env::var_os("VLLM_BASE_URL"),
2396                ollama_api_key: env::var_os("OLLAMA_API_KEY"),
2397                ollama_base_url: env::var_os("OLLAMA_BASE_URL"),
2398            };
2399            // Safety: test-only environment mutation guarded by a module mutex.
2400            unsafe {
2401                env::remove_var("DEEPSEEK_API_KEY");
2402                env::remove_var("DEEPSEEK_BASE_URL");
2403                env::remove_var("DEEPSEEK_HTTP_HEADERS");
2404                env::remove_var("DEEPSEEK_MODEL");
2405                env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL");
2406                env::remove_var("DEEPSEEK_PROVIDER");
2407                env::remove_var("DEEPSEEK_AUTH_MODE");
2408                env::remove_var("CODEWHALE_PROVIDER");
2409                env::remove_var("CODEWHALE_MODEL");
2410                env::remove_var("CODEWHALE_BASE_URL");
2411                env::remove_var("NVIDIA_API_KEY");
2412                env::remove_var("NVIDIA_NIM_API_KEY");
2413                env::remove_var("NIM_BASE_URL");
2414                env::remove_var("NVIDIA_BASE_URL");
2415                env::remove_var("NVIDIA_NIM_BASE_URL");
2416                env::remove_var("OPENROUTER_API_KEY");
2417                env::remove_var("OPENROUTER_BASE_URL");
2418                env::remove_var("XIAOMI_MIMO_API_KEY");
2419                env::remove_var("XIAOMI_API_KEY");
2420                env::remove_var("MIMO_API_KEY");
2421                env::remove_var("XIAOMI_MIMO_BASE_URL");
2422                env::remove_var("MIMO_BASE_URL");
2423                env::remove_var("XIAOMI_MIMO_MODEL");
2424                env::remove_var("MIMO_MODEL");
2425                env::remove_var("WANJIE_ARK_API_KEY");
2426                env::remove_var("WANJIE_ARK_BASE_URL");
2427                env::remove_var("WANJIE_BASE_URL");
2428                env::remove_var("WANJIE_MAAS_BASE_URL");
2429                env::remove_var("WANJIE_ARK_MODEL");
2430                env::remove_var("WANJIE_MODEL");
2431                env::remove_var("WANJIE_MAAS_MODEL");
2432                env::remove_var("NOVITA_API_KEY");
2433                env::remove_var("NOVITA_BASE_URL");
2434                env::remove_var("FIREWORKS_API_KEY");
2435                env::remove_var("FIREWORKS_BASE_URL");
2436                env::remove_var("SILICONFLOW_API_KEY");
2437                env::remove_var("SILICONFLOW_BASE_URL");
2438                env::remove_var("SILICONFLOW_MODEL");
2439                env::remove_var("MOONSHOT_API_KEY");
2440                env::remove_var("MOONSHOT_BASE_URL");
2441                env::remove_var("MOONSHOT_MODEL");
2442                env::remove_var("KIMI_API_KEY");
2443                env::remove_var("KIMI_BASE_URL");
2444                env::remove_var("KIMI_MODEL");
2445                env::remove_var("KIMI_MODEL_NAME");
2446                env::remove_var("SGLANG_API_KEY");
2447                env::remove_var("SGLANG_BASE_URL");
2448                env::remove_var("VLLM_API_KEY");
2449                env::remove_var("VLLM_BASE_URL");
2450                env::remove_var("OLLAMA_API_KEY");
2451                env::remove_var("OLLAMA_BASE_URL");
2452            }
2453            guard
2454        }
2455
2456        unsafe fn restore_var(key: &str, value: Option<OsString>) {
2457            if let Some(value) = value {
2458                unsafe { env::set_var(key, value) };
2459            } else {
2460                unsafe { env::remove_var(key) };
2461            }
2462        }
2463    }
2464
2465    impl Drop for EnvGuard {
2466        fn drop(&mut self) {
2467            // Safety: test-only environment mutation guarded by a module mutex.
2468            unsafe {
2469                Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take());
2470                Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take());
2471                Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take());
2472                Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take());
2473                Self::restore_var(
2474                    "DEEPSEEK_DEFAULT_TEXT_MODEL",
2475                    self.deepseek_default_text_model.take(),
2476                );
2477                Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take());
2478                Self::restore_var("DEEPSEEK_AUTH_MODE", self.deepseek_auth_mode.take());
2479                Self::restore_var("CODEWHALE_PROVIDER", self.codewhale_provider.take());
2480                Self::restore_var("CODEWHALE_MODEL", self.codewhale_model.take());
2481                Self::restore_var("CODEWHALE_BASE_URL", self.codewhale_base_url.take());
2482                Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take());
2483                Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take());
2484                Self::restore_var("NIM_BASE_URL", self.nim_base_url.take());
2485                Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take());
2486                Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take());
2487                Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take());
2488                Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take());
2489                Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take());
2490                Self::restore_var("XIAOMI_API_KEY", self.xiaomi_api_key.take());
2491                Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take());
2492                Self::restore_var("XIAOMI_MIMO_BASE_URL", self.xiaomi_mimo_base_url.take());
2493                Self::restore_var("MIMO_BASE_URL", self.mimo_base_url.take());
2494                Self::restore_var("XIAOMI_MIMO_MODEL", self.xiaomi_mimo_model.take());
2495                Self::restore_var("MIMO_MODEL", self.mimo_model.take());
2496                Self::restore_var("WANJIE_ARK_API_KEY", self.wanjie_ark_api_key.take());
2497                Self::restore_var("WANJIE_ARK_BASE_URL", self.wanjie_ark_base_url.take());
2498                Self::restore_var("WANJIE_BASE_URL", self.wanjie_base_url.take());
2499                Self::restore_var("WANJIE_MAAS_BASE_URL", self.wanjie_maas_base_url.take());
2500                Self::restore_var("VOLCENGINE_MODEL", self.volcengine_model.take());
2501                Self::restore_var("WANJIE_ARK_MODEL", self.wanjie_ark_model.take());
2502                Self::restore_var("WANJIE_MODEL", self.wanjie_model.take());
2503                Self::restore_var("WANJIE_MAAS_MODEL", self.wanjie_maas_model.take());
2504                Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take());
2505                Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take());
2506                Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take());
2507                Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take());
2508                Self::restore_var("SILICONFLOW_API_KEY", self.siliconflow_api_key.take());
2509                Self::restore_var("SILICONFLOW_BASE_URL", self.siliconflow_base_url.take());
2510                Self::restore_var("SILICONFLOW_MODEL", self.siliconflow_model.take());
2511                Self::restore_var("MOONSHOT_API_KEY", self.moonshot_api_key.take());
2512                Self::restore_var("MOONSHOT_BASE_URL", self.moonshot_base_url.take());
2513                Self::restore_var("MOONSHOT_MODEL", self.moonshot_model.take());
2514                Self::restore_var("KIMI_API_KEY", self.kimi_api_key.take());
2515                Self::restore_var("KIMI_BASE_URL", self.kimi_base_url.take());
2516                Self::restore_var("KIMI_MODEL", self.kimi_model.take());
2517                Self::restore_var("KIMI_MODEL_NAME", self.kimi_model_name.take());
2518                Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take());
2519                Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take());
2520                Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take());
2521                Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take());
2522                Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take());
2523                Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take());
2524            }
2525        }
2526    }
2527
2528    struct RecordingSecretsStore {
2529        gets: Mutex<Vec<String>>,
2530        value: Option<String>,
2531    }
2532
2533    impl RecordingSecretsStore {
2534        fn with_value(value: &str) -> Self {
2535            Self {
2536                gets: Mutex::new(Vec::new()),
2537                value: Some(value.to_string()),
2538            }
2539        }
2540    }
2541
2542    impl codewhale_secrets::KeyringStore for RecordingSecretsStore {
2543        fn get(&self, key: &str) -> Result<Option<String>, codewhale_secrets::SecretsError> {
2544            self.gets.lock().unwrap().push(key.to_string());
2545            Ok(self.value.clone())
2546        }
2547
2548        fn set(&self, _key: &str, _value: &str) -> Result<(), codewhale_secrets::SecretsError> {
2549            Ok(())
2550        }
2551
2552        fn delete(&self, _key: &str) -> Result<(), codewhale_secrets::SecretsError> {
2553            Ok(())
2554        }
2555
2556        fn backend_name(&self) -> &'static str {
2557            "recording"
2558        }
2559    }
2560
2561    #[test]
2562    fn root_deepseek_fields_are_runtime_fallbacks() {
2563        let _lock = env_lock();
2564        let _env = EnvGuard::without_deepseek_runtime_overrides();
2565        let config = ConfigToml {
2566            api_key: Some("root-key".to_string()),
2567            base_url: Some("https://api.deepseek.com".to_string()),
2568            default_text_model: Some("deepseek-v4-pro".to_string()),
2569            ..ConfigToml::default()
2570        };
2571
2572        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2573
2574        assert_eq!(resolved.provider, ProviderKind::Deepseek);
2575        assert_eq!(resolved.api_key.as_deref(), Some("root-key"));
2576        assert_eq!(resolved.base_url, "https://api.deepseek.com");
2577        assert_eq!(resolved.model, "deepseek-v4-pro");
2578    }
2579
2580    #[test]
2581    fn deepseek_runtime_defaults_to_beta_endpoint() {
2582        let _lock = env_lock();
2583        let _env = EnvGuard::without_deepseek_runtime_overrides();
2584        let config = ConfigToml::default();
2585
2586        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2587
2588        assert_eq!(resolved.provider, ProviderKind::Deepseek);
2589        assert_eq!(resolved.base_url, DEFAULT_DEEPSEEK_BASE_URL);
2590        assert_eq!(resolved.model, DEFAULT_DEEPSEEK_MODEL);
2591    }
2592
2593    #[test]
2594    fn provider_specific_deepseek_fields_override_tui_compat_fields() {
2595        let _lock = env_lock();
2596        let _env = EnvGuard::without_deepseek_runtime_overrides();
2597        let mut config = ConfigToml {
2598            api_key: Some("root-key".to_string()),
2599            base_url: Some("https://api.deepseek.com".to_string()),
2600            default_text_model: Some("deepseek-v4-pro".to_string()),
2601            ..ConfigToml::default()
2602        };
2603        config.providers.deepseek.api_key = Some("provider-key".to_string());
2604        config.providers.deepseek.base_url = Some("https://gateway.example/v1".to_string());
2605        config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
2606
2607        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2608
2609        assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
2610        assert_eq!(resolved.base_url, "https://gateway.example/v1");
2611        assert_eq!(resolved.model, "deepseek-v4-flash");
2612    }
2613
2614    #[test]
2615    fn provider_http_headers_override_root_headers() {
2616        let _lock = env_lock();
2617        let _env = EnvGuard::without_deepseek_runtime_overrides();
2618        let mut config = ConfigToml {
2619            api_key: Some("root-key".to_string()),
2620            base_url: Some("https://api.deepseek.com".to_string()),
2621            default_text_model: Some("deepseek-v4-pro".to_string()),
2622            ..ConfigToml::default()
2623        };
2624        config.providers.deepseek.api_key = Some("provider-key".to_string());
2625        config.providers.deepseek.base_url = Some("https://gateway.example/v1".to_string());
2626        config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
2627        config
2628            .http_headers
2629            .insert("X-Shared".to_string(), "root".to_string());
2630        config
2631            .providers
2632            .deepseek
2633            .http_headers
2634            .insert("X-Model-Provider-Id".to_string(), "tongyi".to_string());
2635        config
2636            .providers
2637            .deepseek
2638            .http_headers
2639            .insert("X-Shared".to_string(), "provider".to_string());
2640
2641        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2642
2643        assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
2644        assert_eq!(resolved.base_url, "https://gateway.example/v1");
2645        assert_eq!(resolved.model, "deepseek-v4-flash");
2646        assert_eq!(
2647            resolved
2648                .http_headers
2649                .get("X-Model-Provider-Id")
2650                .map(String::as_str),
2651            Some("tongyi")
2652        );
2653        assert_eq!(
2654            resolved.http_headers.get("X-Shared").map(String::as_str),
2655            Some("provider")
2656        );
2657    }
2658
2659    #[test]
2660    fn http_headers_env_overrides_config() {
2661        let _lock = env_lock();
2662        let _env = EnvGuard::without_deepseek_runtime_overrides();
2663        let mut config = ConfigToml::default();
2664        config
2665            .http_headers
2666            .insert("X-Model-Provider-Id".to_string(), "from-file".to_string());
2667        // Safety: test-only environment mutation guarded by a module mutex.
2668        unsafe {
2669            env::set_var("DEEPSEEK_HTTP_HEADERS", "X-Model-Provider-Id=from-env");
2670        }
2671
2672        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2673
2674        assert_eq!(
2675            resolved
2676                .http_headers
2677                .get("X-Model-Provider-Id")
2678                .map(String::as_str),
2679            Some("from-env")
2680        );
2681    }
2682
2683    #[test]
2684    fn nvidia_nim_provider_defaults_to_catalog_endpoint_and_model() {
2685        let _lock = env_lock();
2686        let _env = EnvGuard::without_deepseek_runtime_overrides();
2687        let config = ConfigToml {
2688            provider: ProviderKind::NvidiaNim,
2689            ..ConfigToml::default()
2690        };
2691
2692        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2693
2694        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
2695        assert_eq!(resolved.base_url, DEFAULT_NVIDIA_NIM_BASE_URL);
2696        assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
2697    }
2698
2699    #[test]
2700    fn nvidia_nim_provider_uses_provider_specific_credentials() {
2701        let _lock = env_lock();
2702        let _env = EnvGuard::without_deepseek_runtime_overrides();
2703        let mut config = ConfigToml {
2704            provider: ProviderKind::NvidiaNim,
2705            ..ConfigToml::default()
2706        };
2707        config.providers.nvidia_nim.api_key = Some("nim-key".to_string());
2708        config.providers.nvidia_nim.base_url = Some("https://nim.example/v1".to_string());
2709        config.providers.nvidia_nim.model = Some("deepseek-ai/deepseek-v4-pro".to_string());
2710
2711        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2712
2713        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
2714        assert_eq!(resolved.api_key.as_deref(), Some("nim-key"));
2715        assert_eq!(resolved.base_url, "https://nim.example/v1");
2716        assert_eq!(resolved.model, "deepseek-ai/deepseek-v4-pro");
2717    }
2718
2719    #[test]
2720    fn nvidia_nim_provider_normalizes_flash_aliases() {
2721        let _lock = env_lock();
2722        let _env = EnvGuard::without_deepseek_runtime_overrides();
2723        let cli = CliRuntimeOverrides {
2724            provider: Some(ProviderKind::NvidiaNim),
2725            model: Some("deepseek-v4-flash".to_string()),
2726            ..CliRuntimeOverrides::default()
2727        };
2728
2729        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
2730
2731        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
2732        assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_FLASH_MODEL);
2733    }
2734
2735    #[test]
2736    fn nvidia_nim_provider_uses_nvidia_env_credentials() {
2737        let _lock = env_lock();
2738        let _env = EnvGuard::without_deepseek_runtime_overrides();
2739        // Safety: test-only environment mutation guarded by a module mutex.
2740        unsafe {
2741            env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
2742            env::set_var("NVIDIA_API_KEY", "nim-env-key");
2743            env::set_var("NVIDIA_NIM_BASE_URL", "https://nim-env.example/v1");
2744        }
2745
2746        let config = ConfigToml::default();
2747        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2748
2749        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
2750        assert_eq!(resolved.api_key.as_deref(), Some("nim-env-key"));
2751        assert_eq!(resolved.base_url, "https://nim-env.example/v1");
2752        assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
2753    }
2754
2755    #[test]
2756    fn nvidia_nim_provider_accepts_short_nim_base_url_alias() {
2757        let _lock = env_lock();
2758        let _env = EnvGuard::without_deepseek_runtime_overrides();
2759        // Safety: test-only environment mutation guarded by a module mutex.
2760        unsafe {
2761            env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
2762            env::set_var("NVIDIA_API_KEY", "nim-env-key");
2763            env::set_var("NIM_BASE_URL", "https://short-nim.example/v1");
2764        }
2765
2766        let config = ConfigToml::default();
2767        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2768
2769        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
2770        assert_eq!(resolved.base_url, "https://short-nim.example/v1");
2771    }
2772
2773    #[test]
2774    fn nvidia_nim_provider_can_fallback_to_deepseek_api_key_env() {
2775        let _lock = env_lock();
2776        let _env = EnvGuard::without_deepseek_runtime_overrides();
2777        // Safety: test-only environment mutation guarded by a module mutex.
2778        unsafe {
2779            env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
2780            env::set_var("DEEPSEEK_API_KEY", "deepseek-compat-key");
2781        }
2782
2783        let config = ConfigToml::default();
2784        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2785
2786        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
2787        assert_eq!(resolved.api_key.as_deref(), Some("deepseek-compat-key"));
2788    }
2789
2790    #[test]
2791    fn list_values_redacts_root_api_key() {
2792        let config = ConfigToml {
2793            api_key: Some("sk-deepseek-secret".to_string()),
2794            ..ConfigToml::default()
2795        };
2796
2797        let values = config.list_values();
2798
2799        assert_eq!(
2800            values.get("api_key").map(String::as_str),
2801            Some("sk-d***cret")
2802        );
2803    }
2804
2805    #[test]
2806    fn list_values_fully_redacts_short_api_key() {
2807        let config = ConfigToml {
2808            api_key: Some("short-key".to_string()),
2809            ..ConfigToml::default()
2810        };
2811
2812        let values = config.list_values();
2813
2814        assert_eq!(values.get("api_key").map(String::as_str), Some("********"));
2815    }
2816
2817    #[test]
2818    fn get_display_value_redacts_sensitive_keys() {
2819        let mut config = ConfigToml {
2820            api_key: Some("sk-deepseek-secret".to_string()),
2821            ..ConfigToml::default()
2822        };
2823        config.providers.openrouter.api_key = Some("openrouter-secret-value".to_string());
2824        config.model = Some("deepseek-v4-pro".to_string());
2825
2826        assert_eq!(
2827            config.get_display_value("api_key").as_deref(),
2828            Some("sk-d***cret")
2829        );
2830        assert_eq!(
2831            config
2832                .get_display_value("providers.openrouter.api_key")
2833                .as_deref(),
2834            Some("open***alue")
2835        );
2836        assert_eq!(
2837            config.get_display_value("model").as_deref(),
2838            Some("deepseek-v4-pro")
2839        );
2840    }
2841
2842    #[test]
2843    fn hook_sinks_config_uses_separate_table_from_lifecycle_hooks() -> Result<()> {
2844        let raw = r#"
2845[hooks]
2846enabled = true
2847default_timeout_secs = 20
2848
2849[[hooks.hooks]]
2850event = "message_submit"
2851command = "echo ok"
2852
2853[hook_sinks]
2854unix_socket_path = "/tmp/cw-hooks.sock"
2855"#;
2856
2857        let config: ConfigToml = toml::from_str(raw)?;
2858
2859        assert_eq!(
2860            config.get_value("hook_sinks.unix_socket_path").as_deref(),
2861            Some("/tmp/cw-hooks.sock")
2862        );
2863        assert!(
2864            config.extras.contains_key("hooks"),
2865            "legacy lifecycle hooks table must remain an opaque extra"
2866        );
2867
2868        let serialized = toml::to_string_pretty(&config)?;
2869        let round_tripped: ConfigToml = toml::from_str(&serialized)?;
2870        let hooks = round_tripped
2871            .extras
2872            .get("hooks")
2873            .and_then(toml::Value::as_table)
2874            .expect("hooks table preserved");
2875
2876        assert_eq!(
2877            hooks.get("enabled").and_then(toml::Value::as_bool),
2878            Some(true)
2879        );
2880        assert_eq!(
2881            hooks
2882                .get("default_timeout_secs")
2883                .and_then(toml::Value::as_integer),
2884            Some(20)
2885        );
2886        assert!(
2887            hooks.get("hooks").and_then(toml::Value::as_array).is_some(),
2888            "nested lifecycle hooks array must survive config rewrites"
2889        );
2890        assert_eq!(
2891            round_tripped
2892                .get_value("hook_sinks.unix_socket_path")
2893                .as_deref(),
2894            Some("/tmp/cw-hooks.sock")
2895        );
2896
2897        Ok(())
2898    }
2899
2900    #[test]
2901    fn hook_sinks_unix_socket_path_round_trips_through_key_value_api() -> Result<()> {
2902        let mut config = ConfigToml::default();
2903
2904        config.set_value("hook_sinks.unix_socket_path", "/tmp/cw-events.sock")?;
2905
2906        assert_eq!(
2907            config.get_value("hook_sinks.unix_socket_path").as_deref(),
2908            Some("/tmp/cw-events.sock")
2909        );
2910        assert_eq!(
2911            config
2912                .list_values()
2913                .get("hook_sinks.unix_socket_path")
2914                .map(String::as_str),
2915            Some("/tmp/cw-events.sock")
2916        );
2917
2918        config.unset_value("hook_sinks.unix_socket_path")?;
2919        assert_eq!(config.get_value("hook_sinks.unix_socket_path"), None);
2920
2921        Ok(())
2922    }
2923
2924    /// End-to-end smoke for the preferred Kimi Code setup path:
2925    ///   1. Start from a fresh root config that uses DeepSeek defaults.
2926    ///   2. Mutate it through the same key-value setters the
2927    ///      `codewhale config set providers.moonshot.*` CLI invokes.
2928    ///   3. Switch the active provider through `CODEWHALE_PROVIDER` —
2929    ///      the public env alias — without ever touching the legacy
2930    ///      `DEEPSEEK_PROVIDER` name.
2931    ///   4. Resolve the runtime and confirm the doctor/runtime values.
2932    ///
2933    /// No real API key is required; the `api_key` here is just a
2934    /// non-empty placeholder.
2935    #[test]
2936    fn moonshot_kimi_code_smoke_config_set_then_resolve() -> Result<()> {
2937        let _lock = env_lock();
2938        let _env = EnvGuard::without_deepseek_runtime_overrides();
2939
2940        let mut config = ConfigToml {
2941            provider: ProviderKind::Deepseek,
2942            default_text_model: Some("deepseek-v4-pro".to_string()),
2943            ..ConfigToml::default()
2944        };
2945
2946        // Same key paths a user would run via `codewhale config set`.
2947        config.set_value("providers.moonshot.api_key", "kimi-code-key-placeholder")?;
2948        config.set_value("providers.moonshot.auth_mode", "api_key")?;
2949        config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?;
2950        config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?;
2951
2952        // Public env alias for the active-provider switch.
2953        // Safety: test-only env mutation guarded by env_lock().
2954        unsafe { env::set_var("CODEWHALE_PROVIDER", "moonshot") };
2955
2956        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2957
2958        assert_eq!(resolved.provider, ProviderKind::Moonshot);
2959        assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
2960        assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
2961        assert_eq!(resolved.auth_mode.as_deref(), Some("api_key"));
2962        assert_eq!(
2963            resolved.api_key.as_deref(),
2964            Some("kimi-code-key-placeholder")
2965        );
2966        assert_eq!(
2967            resolved.api_key_source,
2968            Some(RuntimeApiKeySource::ConfigFile)
2969        );
2970        Ok(())
2971    }
2972
2973    #[test]
2974    fn moonshot_provider_config_values_round_trip() -> Result<()> {
2975        let mut config = ConfigToml::default();
2976
2977        config.set_value("providers.moonshot.api_key", "moonshot-secret-value")?;
2978        config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?;
2979        config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?;
2980        config.set_value("providers.moonshot.auth_mode", "api_key")?;
2981        config.set_value("providers.moonshot.http_headers", "X-Test=ok")?;
2982
2983        assert_eq!(
2984            config
2985                .get_display_value("providers.moonshot.api_key")
2986                .as_deref(),
2987            Some("moon***alue")
2988        );
2989        assert_eq!(
2990            config.get_value("providers.moonshot.base_url").as_deref(),
2991            Some(DEFAULT_KIMI_CODE_BASE_URL)
2992        );
2993        assert_eq!(
2994            config.get_value("providers.moonshot.model").as_deref(),
2995            Some(DEFAULT_KIMI_CODE_MODEL)
2996        );
2997        assert_eq!(
2998            config.get_value("providers.moonshot.auth_mode").as_deref(),
2999            Some("api_key")
3000        );
3001        assert_eq!(
3002            config
3003                .list_values()
3004                .get("providers.moonshot.api_key")
3005                .map(String::as_str),
3006            Some("moon***alue")
3007        );
3008
3009        config.unset_value("providers.moonshot.auth_mode")?;
3010        config.unset_value("providers.moonshot.base_url")?;
3011        config.unset_value("providers.moonshot.model")?;
3012
3013        assert_eq!(config.get_value("providers.moonshot.auth_mode"), None);
3014        assert_eq!(config.get_value("providers.moonshot.base_url"), None);
3015        assert_eq!(config.get_value("providers.moonshot.model"), None);
3016        Ok(())
3017    }
3018
3019    #[test]
3020    fn project_merge_denies_credentials_endpoints_and_provider_selection() {
3021        let mut base = ConfigToml {
3022            provider: ProviderKind::Deepseek,
3023            api_key: Some("user-key".to_string()),
3024            base_url: Some("https://api.deepseek.com".to_string()),
3025            default_text_model: Some("deepseek-v4-flash".to_string()),
3026            ..ConfigToml::default()
3027        };
3028        base.providers.openrouter.api_key = Some("user-openrouter-key".to_string());
3029
3030        let mut project = ConfigToml {
3031            provider: ProviderKind::Openrouter,
3032            api_key: Some("attacker-key".to_string()),
3033            base_url: Some("https://evil.example/v1".to_string()),
3034            default_text_model: Some("deepseek-v4-pro".to_string()),
3035            auth_mode: Some("oauth".to_string()),
3036            telemetry: Some(true),
3037            ..ConfigToml::default()
3038        };
3039        project.providers.openrouter.api_key = Some("attacker-openrouter-key".to_string());
3040        project.providers.openrouter.base_url = Some("https://evil.example/openrouter".to_string());
3041        project.providers.openrouter.model = Some("deepseek/deepseek-v4-pro".to_string());
3042
3043        base.merge_project_overrides(project);
3044
3045        assert_eq!(base.provider, ProviderKind::Deepseek);
3046        assert_eq!(base.api_key.as_deref(), Some("user-key"));
3047        assert_eq!(base.base_url.as_deref(), Some("https://api.deepseek.com"));
3048        assert_eq!(base.auth_mode, None);
3049        assert_eq!(base.telemetry, None);
3050        assert_eq!(
3051            base.providers.openrouter.api_key.as_deref(),
3052            Some("user-openrouter-key")
3053        );
3054        assert_eq!(base.providers.openrouter.base_url, None);
3055        assert_eq!(base.default_text_model.as_deref(), Some("deepseek-v4-pro"));
3056        assert_eq!(
3057            base.providers.openrouter.model.as_deref(),
3058            Some("deepseek/deepseek-v4-pro")
3059        );
3060    }
3061
3062    #[test]
3063    fn project_merge_only_tightens_approval_and_sandbox_policy() {
3064        let mut strict = ConfigToml {
3065            approval_policy: Some("never".to_string()),
3066            sandbox_mode: Some("read-only".to_string()),
3067            ..ConfigToml::default()
3068        };
3069        strict.merge_project_overrides(ConfigToml {
3070            approval_policy: Some("on-request".to_string()),
3071            sandbox_mode: Some("workspace-write".to_string()),
3072            ..ConfigToml::default()
3073        });
3074        assert_eq!(strict.approval_policy.as_deref(), Some("never"));
3075        assert_eq!(strict.sandbox_mode.as_deref(), Some("read-only"));
3076
3077        let mut permissive = ConfigToml {
3078            approval_policy: Some("auto".to_string()),
3079            sandbox_mode: Some("workspace-write".to_string()),
3080            ..ConfigToml::default()
3081        };
3082        permissive.merge_project_overrides(ConfigToml {
3083            approval_policy: Some("never".to_string()),
3084            sandbox_mode: Some("read-only".to_string()),
3085            ..ConfigToml::default()
3086        });
3087        assert_eq!(permissive.approval_policy.as_deref(), Some("never"));
3088        assert_eq!(permissive.sandbox_mode.as_deref(), Some("read-only"));
3089
3090        let mut unset = ConfigToml::default();
3091        unset.merge_project_overrides(ConfigToml {
3092            approval_policy: Some("on-request".to_string()),
3093            sandbox_mode: Some("workspace-write".to_string()),
3094            ..ConfigToml::default()
3095        });
3096        assert_eq!(unset.approval_policy, None);
3097        assert_eq!(unset.sandbox_mode, None);
3098    }
3099
3100    #[test]
3101    fn list_values_redacts_unicode_api_key_without_byte_slicing() {
3102        let config = ConfigToml {
3103            api_key: Some("密钥密钥密钥密钥123456789".to_string()),
3104            ..ConfigToml::default()
3105        };
3106
3107        let values = config.list_values();
3108
3109        assert_eq!(
3110            values.get("api_key").map(String::as_str),
3111            Some("密钥密钥***6789")
3112        );
3113    }
3114
3115    #[test]
3116    fn normalize_config_file_path_rejects_traversal() {
3117        let err = normalize_config_file_path(PathBuf::from("../config.toml"))
3118            .expect_err("traversal path should fail");
3119        assert!(format!("{err:#}").contains("cannot contain '..'"));
3120    }
3121
3122    #[cfg(unix)]
3123    #[test]
3124    fn save_clamps_existing_config_permissions() {
3125        use std::time::{SystemTime, UNIX_EPOCH};
3126
3127        let unique = SystemTime::now()
3128            .duration_since(UNIX_EPOCH)
3129            .expect("clock")
3130            .as_nanos();
3131        let dir = std::env::temp_dir().join(format!(
3132            "deepseek-config-perms-{}-{unique}",
3133            std::process::id()
3134        ));
3135        fs::create_dir_all(&dir).expect("mkdir");
3136        let path = dir.join(CONFIG_FILE_NAME);
3137        fs::write(&path, "api_key = \"old\"\n").expect("seed config");
3138        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod seed");
3139
3140        let store = ConfigStore {
3141            path: path.clone(),
3142            config: ConfigToml {
3143                api_key: Some("new-secret".to_string()),
3144                ..ConfigToml::default()
3145            },
3146        };
3147        store.save().expect("save");
3148
3149        let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
3150        assert_eq!(mode, 0o600);
3151
3152        let _ = fs::remove_dir_all(dir);
3153    }
3154
3155    #[test]
3156    fn provider_kind_parses_openrouter_and_novita_aliases() {
3157        assert_eq!(
3158            ProviderKind::parse("openrouter"),
3159            Some(ProviderKind::Openrouter)
3160        );
3161        assert_eq!(
3162            ProviderKind::parse("OPEN_ROUTER"),
3163            Some(ProviderKind::Openrouter)
3164        );
3165        assert_eq!(
3166            ProviderKind::parse("xiaomi-mimo"),
3167            Some(ProviderKind::XiaomiMimo)
3168        );
3169        assert_eq!(
3170            ProviderKind::parse("xiaomi"),
3171            Some(ProviderKind::XiaomiMimo)
3172        );
3173        assert_eq!(ProviderKind::parse("novita"), Some(ProviderKind::Novita));
3174        assert_eq!(ProviderKind::parse("Novita"), Some(ProviderKind::Novita));
3175        assert_eq!(
3176            ProviderKind::parse("fireworks-ai"),
3177            Some(ProviderKind::Fireworks)
3178        );
3179        assert_eq!(
3180            ProviderKind::parse("silicon-flow"),
3181            Some(ProviderKind::Siliconflow)
3182        );
3183        assert_eq!(
3184            ProviderKind::parse("silicon_flow"),
3185            Some(ProviderKind::Siliconflow)
3186        );
3187        assert_eq!(ProviderKind::parse("kimi"), Some(ProviderKind::Moonshot));
3188        assert_eq!(
3189            ProviderKind::parse("moonshot-ai"),
3190            Some(ProviderKind::Moonshot)
3191        );
3192        assert_eq!(ProviderKind::parse("sg-lang"), Some(ProviderKind::Sglang));
3193        assert_eq!(ProviderKind::parse("v-llm"), Some(ProviderKind::Vllm));
3194        assert_eq!(ProviderKind::parse("vllm"), Some(ProviderKind::Vllm));
3195        assert_eq!(ProviderKind::parse("ollama"), Some(ProviderKind::Ollama));
3196        assert_eq!(
3197            ProviderKind::parse("ollama-local"),
3198            Some(ProviderKind::Ollama)
3199        );
3200        assert_eq!(
3201            ProviderKind::parse("wanjie-ark"),
3202            Some(ProviderKind::WanjieArk)
3203        );
3204        assert_eq!(
3205            ProviderKind::parse("ark_wanjie"),
3206            Some(ProviderKind::WanjieArk)
3207        );
3208
3209        let parsed: ConfigToml =
3210            toml::from_str("provider = \"ark-wanjie\"").expect("wanjie provider alias");
3211        assert_eq!(parsed.provider, ProviderKind::WanjieArk);
3212
3213        let parsed: ConfigToml =
3214            toml::from_str("provider = \"silicon-flow\"").expect("siliconflow provider alias");
3215        assert_eq!(parsed.provider, ProviderKind::Siliconflow);
3216    }
3217
3218    #[test]
3219    fn provider_kind_accepts_legacy_deepseek_cn_aliases() {
3220        for alias in [
3221            "deepseek-cn",
3222            "deepseek_china",
3223            "deepseekcn",
3224            "deepseek-china",
3225        ] {
3226            assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Deepseek));
3227
3228            let parsed: ConfigToml =
3229                toml::from_str(&format!("provider = \"{alias}\"")).expect("legacy provider alias");
3230            assert_eq!(parsed.provider, ProviderKind::Deepseek);
3231        }
3232    }
3233
3234    #[test]
3235    fn openrouter_provider_defaults_to_canonical_endpoint_and_model() {
3236        let _lock = env_lock();
3237        let _env = EnvGuard::without_deepseek_runtime_overrides();
3238        let config = ConfigToml {
3239            provider: ProviderKind::Openrouter,
3240            ..ConfigToml::default()
3241        };
3242
3243        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3244
3245        assert_eq!(resolved.provider, ProviderKind::Openrouter);
3246        assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL);
3247        assert_eq!(resolved.model, DEFAULT_OPENROUTER_MODEL);
3248    }
3249
3250    #[test]
3251    fn xiaomi_mimo_provider_defaults_to_canonical_endpoint_and_model() {
3252        let _lock = env_lock();
3253        let _env = EnvGuard::without_deepseek_runtime_overrides();
3254        let config = ConfigToml {
3255            provider: ProviderKind::XiaomiMimo,
3256            ..ConfigToml::default()
3257        };
3258
3259        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3260
3261        assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
3262        assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
3263        assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL);
3264    }
3265
3266    #[test]
3267    fn novita_provider_defaults_to_canonical_endpoint_and_model() {
3268        let _lock = env_lock();
3269        let _env = EnvGuard::without_deepseek_runtime_overrides();
3270        let config = ConfigToml {
3271            provider: ProviderKind::Novita,
3272            ..ConfigToml::default()
3273        };
3274
3275        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3276
3277        assert_eq!(resolved.provider, ProviderKind::Novita);
3278        assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL);
3279        assert_eq!(resolved.model, DEFAULT_NOVITA_MODEL);
3280    }
3281
3282    #[test]
3283    fn fireworks_provider_defaults_to_canonical_endpoint_and_model() {
3284        let _lock = env_lock();
3285        let _env = EnvGuard::without_deepseek_runtime_overrides();
3286        let config = ConfigToml {
3287            provider: ProviderKind::Fireworks,
3288            ..ConfigToml::default()
3289        };
3290
3291        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3292
3293        assert_eq!(resolved.provider, ProviderKind::Fireworks);
3294        assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL);
3295        assert_eq!(resolved.model, DEFAULT_FIREWORKS_MODEL);
3296    }
3297
3298    #[test]
3299    fn siliconflow_provider_defaults_to_canonical_endpoint_and_model() {
3300        let _lock = env_lock();
3301        let _env = EnvGuard::without_deepseek_runtime_overrides();
3302        let config = ConfigToml {
3303            provider: ProviderKind::Siliconflow,
3304            ..ConfigToml::default()
3305        };
3306
3307        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3308
3309        assert_eq!(resolved.provider, ProviderKind::Siliconflow);
3310        assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_BASE_URL);
3311        assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL);
3312    }
3313
3314    #[test]
3315    fn moonshot_provider_defaults_to_kimi_k2() {
3316        let _lock = env_lock();
3317        let _env = EnvGuard::without_deepseek_runtime_overrides();
3318        let config = ConfigToml {
3319            provider: ProviderKind::Moonshot,
3320            ..ConfigToml::default()
3321        };
3322
3323        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3324
3325        assert_eq!(resolved.provider, ProviderKind::Moonshot);
3326        assert_eq!(resolved.base_url, DEFAULT_MOONSHOT_BASE_URL);
3327        assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL);
3328    }
3329
3330    #[test]
3331    fn moonshot_kimi_oauth_uses_kimi_code_endpoint_and_model() {
3332        let _lock = env_lock();
3333        let _env = EnvGuard::without_deepseek_runtime_overrides();
3334        let mut config = ConfigToml {
3335            provider: ProviderKind::Moonshot,
3336            ..ConfigToml::default()
3337        };
3338        config.providers.moonshot.auth_mode = Some("kimi_oauth".to_string());
3339
3340        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3341
3342        assert_eq!(resolved.provider, ProviderKind::Moonshot);
3343        assert_eq!(resolved.auth_mode.as_deref(), Some("kimi_oauth"));
3344        assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
3345        assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
3346        assert_eq!(resolved.api_key, None);
3347        assert_eq!(resolved.api_key_source, None);
3348    }
3349
3350    #[test]
3351    fn moonshot_kimi_code_api_key_endpoint_defaults_to_kimi_for_coding() {
3352        let _lock = env_lock();
3353        let _env = EnvGuard::without_deepseek_runtime_overrides();
3354        let mut config = ConfigToml {
3355            provider: ProviderKind::Moonshot,
3356            ..ConfigToml::default()
3357        };
3358        config.providers.moonshot.api_key = Some("kimi-code-key".to_string());
3359        config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string());
3360
3361        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3362
3363        assert_eq!(resolved.provider, ProviderKind::Moonshot);
3364        assert_eq!(resolved.auth_mode, None);
3365        assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
3366        assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
3367        assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key"));
3368        assert_eq!(
3369            resolved.api_key_source,
3370            Some(RuntimeApiKeySource::ConfigFile)
3371        );
3372    }
3373
3374    /// `CODEWHALE_PROVIDER` is the user-facing env alias for switching the
3375    /// active provider. It must be honored by the runtime resolver and win
3376    /// over a root `provider = "deepseek"` config entry.
3377    #[test]
3378    fn codewhale_provider_env_switches_active_provider() {
3379        let _lock = env_lock();
3380        let _env = EnvGuard::without_deepseek_runtime_overrides();
3381        // Safety: test-only env mutation guarded by env_lock().
3382        unsafe {
3383            env::set_var("CODEWHALE_PROVIDER", "moonshot");
3384        }
3385        let mut config = ConfigToml {
3386            provider: ProviderKind::Deepseek,
3387            ..ConfigToml::default()
3388        };
3389        config.providers.moonshot.api_key = Some("kimi-code-key".to_string());
3390        config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string());
3391
3392        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3393
3394        assert_eq!(resolved.provider, ProviderKind::Moonshot);
3395        assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
3396        assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
3397        assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key"));
3398    }
3399
3400    /// When both `CODEWHALE_PROVIDER` and the legacy `DEEPSEEK_PROVIDER`
3401    /// are set, the public alias wins — a user adopting `CODEWHALE_*` in a
3402    /// fresh shell config is not tripped up by a stale legacy export still
3403    /// living in their dotfiles.
3404    #[test]
3405    fn codewhale_provider_env_wins_over_deepseek_provider_env() {
3406        let _lock = env_lock();
3407        let _env = EnvGuard::without_deepseek_runtime_overrides();
3408        // Safety: test-only env mutation guarded by env_lock().
3409        unsafe {
3410            env::set_var("CODEWHALE_PROVIDER", "moonshot");
3411            env::set_var("DEEPSEEK_PROVIDER", "openrouter");
3412        }
3413        let config = ConfigToml {
3414            provider: ProviderKind::Deepseek,
3415            ..ConfigToml::default()
3416        };
3417
3418        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3419
3420        assert_eq!(resolved.provider, ProviderKind::Moonshot);
3421    }
3422
3423    /// `CODEWHALE_MODEL` is the user-facing env alias for picking a model
3424    /// against the active provider. It must be honored by the runtime
3425    /// resolver in place of `DEEPSEEK_MODEL`.
3426    #[test]
3427    fn codewhale_model_env_alias_overrides_default_for_active_provider() {
3428        let _lock = env_lock();
3429        let _env = EnvGuard::without_deepseek_runtime_overrides();
3430        // Safety: test-only env mutation guarded by env_lock().
3431        unsafe {
3432            env::set_var("CODEWHALE_PROVIDER", "moonshot");
3433            env::set_var("CODEWHALE_MODEL", "custom-kimi-test-model");
3434        }
3435        let config = ConfigToml::default();
3436
3437        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3438
3439        assert_eq!(resolved.provider, ProviderKind::Moonshot);
3440        assert_eq!(resolved.model, "custom-kimi-test-model");
3441    }
3442
3443    #[test]
3444    fn blank_codewhale_model_env_alias_does_not_override_default_for_active_provider() {
3445        let _lock = env_lock();
3446        let _env = EnvGuard::without_deepseek_runtime_overrides();
3447        // Safety: test-only env mutation guarded by env_lock().
3448        unsafe {
3449            env::set_var("CODEWHALE_PROVIDER", "moonshot");
3450            env::set_var("CODEWHALE_MODEL", "   ");
3451        }
3452        let config = ConfigToml::default();
3453
3454        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3455
3456        assert_eq!(resolved.provider, ProviderKind::Moonshot);
3457        assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL);
3458    }
3459
3460    #[test]
3461    fn deepseek_default_text_model_legacy_alias_still_overrides_active_provider_model() {
3462        let _lock = env_lock();
3463        let _env = EnvGuard::without_deepseek_runtime_overrides();
3464        // Safety: test-only env mutation guarded by env_lock().
3465        unsafe {
3466            env::set_var("CODEWHALE_PROVIDER", "moonshot");
3467            env::set_var("DEEPSEEK_DEFAULT_TEXT_MODEL", "legacy-env-model");
3468        }
3469        let config = ConfigToml::default();
3470
3471        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3472
3473        assert_eq!(resolved.provider, ProviderKind::Moonshot);
3474        assert_eq!(resolved.model, "legacy-env-model");
3475    }
3476
3477    #[test]
3478    fn wanjie_ark_provider_defaults_to_openai_compatible_endpoint_and_model() {
3479        let _lock = env_lock();
3480        let _env = EnvGuard::without_deepseek_runtime_overrides();
3481        let config = ConfigToml {
3482            provider: ProviderKind::WanjieArk,
3483            ..ConfigToml::default()
3484        };
3485
3486        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3487
3488        assert_eq!(resolved.provider, ProviderKind::WanjieArk);
3489        assert_eq!(resolved.base_url, DEFAULT_WANJIE_ARK_BASE_URL);
3490        assert_eq!(resolved.model, DEFAULT_WANJIE_ARK_MODEL);
3491    }
3492
3493    #[test]
3494    fn sglang_provider_defaults_to_local_endpoint_and_model() {
3495        let _lock = env_lock();
3496        let _env = EnvGuard::without_deepseek_runtime_overrides();
3497        let config = ConfigToml {
3498            provider: ProviderKind::Sglang,
3499            ..ConfigToml::default()
3500        };
3501
3502        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3503
3504        assert_eq!(resolved.provider, ProviderKind::Sglang);
3505        assert_eq!(resolved.base_url, DEFAULT_SGLANG_BASE_URL);
3506        assert_eq!(resolved.model, DEFAULT_SGLANG_MODEL);
3507    }
3508
3509    #[test]
3510    fn vllm_provider_defaults_to_local_endpoint_and_model() {
3511        let _lock = env_lock();
3512        let _env = EnvGuard::without_deepseek_runtime_overrides();
3513        let config = ConfigToml {
3514            provider: ProviderKind::Vllm,
3515            ..ConfigToml::default()
3516        };
3517
3518        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3519
3520        assert_eq!(resolved.provider, ProviderKind::Vllm);
3521        assert_eq!(resolved.base_url, DEFAULT_VLLM_BASE_URL);
3522        assert_eq!(resolved.model, DEFAULT_VLLM_MODEL);
3523    }
3524
3525    #[test]
3526    fn ollama_provider_defaults_to_local_endpoint_and_small_model() {
3527        let _lock = env_lock();
3528        let _env = EnvGuard::without_deepseek_runtime_overrides();
3529        let config = ConfigToml {
3530            provider: ProviderKind::Ollama,
3531            ..ConfigToml::default()
3532        };
3533
3534        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3535
3536        assert_eq!(resolved.provider, ProviderKind::Ollama);
3537        assert_eq!(resolved.base_url, DEFAULT_OLLAMA_BASE_URL);
3538        assert_eq!(resolved.model, DEFAULT_OLLAMA_MODEL);
3539        assert_eq!(resolved.api_key, None);
3540    }
3541
3542    #[test]
3543    fn self_hosted_providers_do_not_probe_secret_store_by_default() {
3544        let _lock = env_lock();
3545        let _env = EnvGuard::without_deepseek_runtime_overrides();
3546        let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
3547        let secrets = Secrets::new(store.clone());
3548
3549        for provider in [
3550            ProviderKind::Sglang,
3551            ProviderKind::Vllm,
3552            ProviderKind::Ollama,
3553        ] {
3554            let config = ConfigToml {
3555                provider,
3556                ..ConfigToml::default()
3557            };
3558
3559            let resolved = config
3560                .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
3561
3562            assert_eq!(resolved.provider, provider);
3563            assert_eq!(resolved.api_key, None);
3564        }
3565
3566        assert!(
3567            store.gets.lock().unwrap().is_empty(),
3568            "self-hosted providers should not read the secret store by default"
3569        );
3570    }
3571
3572    #[test]
3573    fn self_hosted_api_key_auth_can_use_secret_store_when_requested() {
3574        let _lock = env_lock();
3575        let _env = EnvGuard::without_deepseek_runtime_overrides();
3576        let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
3577        let secrets = Secrets::new(store.clone());
3578        let config = ConfigToml {
3579            provider: ProviderKind::Ollama,
3580            auth_mode: Some("api_key".to_string()),
3581            ..ConfigToml::default()
3582        };
3583
3584        let resolved =
3585            config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
3586
3587        assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key"));
3588        assert_eq!(store.gets.lock().unwrap().as_slice(), ["ollama"]);
3589    }
3590
3591    #[test]
3592    fn moonshot_api_key_mode_can_use_secret_store_by_default() {
3593        let _lock = env_lock();
3594        let _env = EnvGuard::without_deepseek_runtime_overrides();
3595        let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
3596        let secrets = Secrets::new(store.clone());
3597        let config = ConfigToml {
3598            provider: ProviderKind::Moonshot,
3599            ..ConfigToml::default()
3600        };
3601
3602        let resolved =
3603            config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
3604
3605        assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key"));
3606        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
3607        assert_eq!(store.gets.lock().unwrap().as_slice(), ["moonshot"]);
3608    }
3609
3610    #[test]
3611    fn loopback_custom_deepseek_base_url_does_not_probe_secret_store_by_default() {
3612        let _lock = env_lock();
3613        let _env = EnvGuard::without_deepseek_runtime_overrides();
3614        let store = Arc::new(RecordingSecretsStore::with_value("stale-deepseek-key"));
3615        let secrets = Secrets::new(store.clone());
3616        let config = ConfigToml {
3617            base_url: Some("http://127.0.0.1:8000/v1".to_string()),
3618            ..ConfigToml::default()
3619        };
3620
3621        let resolved =
3622            config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
3623
3624        assert_eq!(resolved.provider, ProviderKind::Deepseek);
3625        assert_eq!(resolved.base_url, "http://127.0.0.1:8000/v1");
3626        assert_eq!(resolved.api_key, None);
3627        assert!(
3628            store.gets.lock().unwrap().is_empty(),
3629            "loopback custom endpoints should not read macOS Keychain or any secret store"
3630        );
3631    }
3632
3633    #[test]
3634    fn ollama_provider_preserves_model_tags() {
3635        let _lock = env_lock();
3636        let _env = EnvGuard::without_deepseek_runtime_overrides();
3637        let cli = CliRuntimeOverrides {
3638            provider: Some(ProviderKind::Ollama),
3639            model: Some("deepseek-coder-v2:16b".to_string()),
3640            ..CliRuntimeOverrides::default()
3641        };
3642
3643        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
3644
3645        assert_eq!(resolved.provider, ProviderKind::Ollama);
3646        assert_eq!(resolved.model, "deepseek-coder-v2:16b");
3647    }
3648
3649    #[test]
3650    fn ollama_env_overrides_provider_base_url_and_optional_key() {
3651        let _lock = env_lock();
3652        let _env = EnvGuard::without_deepseek_runtime_overrides();
3653        // Safety: test-only environment mutation guarded by a module mutex.
3654        unsafe {
3655            env::set_var("DEEPSEEK_PROVIDER", "ollama-local");
3656            env::set_var("OLLAMA_BASE_URL", "http://ollama.example/v1");
3657            env::set_var("OLLAMA_API_KEY", "ollama-env-key");
3658        }
3659
3660        let resolved =
3661            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
3662
3663        assert_eq!(resolved.provider, ProviderKind::Ollama);
3664        assert_eq!(resolved.base_url, "http://ollama.example/v1");
3665        assert_eq!(resolved.api_key.as_deref(), Some("ollama-env-key"));
3666    }
3667
3668    #[test]
3669    fn openrouter_env_api_key_falls_back_when_config_missing() {
3670        let _lock = env_lock();
3671        let _env = EnvGuard::without_deepseek_runtime_overrides();
3672        // Safety: test-only environment mutation guarded by a module mutex.
3673        unsafe {
3674            env::set_var("DEEPSEEK_PROVIDER", "openrouter");
3675            env::set_var("OPENROUTER_API_KEY", "or-env-key");
3676        }
3677
3678        let resolved =
3679            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
3680
3681        assert_eq!(resolved.provider, ProviderKind::Openrouter);
3682        assert_eq!(resolved.api_key.as_deref(), Some("or-env-key"));
3683        assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL);
3684    }
3685
3686    #[test]
3687    fn xiaomi_mimo_env_overrides_provider_key_base_url_and_model() {
3688        let _lock = env_lock();
3689        let _env = EnvGuard::without_deepseek_runtime_overrides();
3690        // Safety: test-only environment mutation guarded by a module mutex.
3691        unsafe {
3692            env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
3693            env::set_var("MIMO_API_KEY", "mimo-env-key");
3694            env::set_var("MIMO_BASE_URL", "https://mimo-gateway.example/v1");
3695            env::set_var("MIMO_MODEL", "mimo-v2.5");
3696        }
3697
3698        let resolved =
3699            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
3700
3701        assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
3702        assert_eq!(resolved.api_key.as_deref(), Some("mimo-env-key"));
3703        assert_eq!(resolved.base_url, "https://mimo-gateway.example/v1");
3704        assert_eq!(resolved.model, "mimo-v2.5");
3705    }
3706
3707    #[test]
3708    fn novita_env_api_key_falls_back_when_config_missing() {
3709        let _lock = env_lock();
3710        let _env = EnvGuard::without_deepseek_runtime_overrides();
3711        // Safety: test-only environment mutation guarded by a module mutex.
3712        unsafe {
3713            env::set_var("DEEPSEEK_PROVIDER", "novita");
3714            env::set_var("NOVITA_API_KEY", "novita-env-key");
3715        }
3716
3717        let resolved =
3718            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
3719
3720        assert_eq!(resolved.provider, ProviderKind::Novita);
3721        assert_eq!(resolved.api_key.as_deref(), Some("novita-env-key"));
3722        assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL);
3723    }
3724
3725    #[test]
3726    fn fireworks_env_api_key_falls_back_when_config_missing() {
3727        let _lock = env_lock();
3728        let _env = EnvGuard::without_deepseek_runtime_overrides();
3729        // Safety: test-only environment mutation guarded by a module mutex.
3730        unsafe {
3731            env::set_var("DEEPSEEK_PROVIDER", "fireworks");
3732            env::set_var("FIREWORKS_API_KEY", "fw-env-key");
3733        }
3734
3735        let resolved =
3736            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
3737
3738        assert_eq!(resolved.provider, ProviderKind::Fireworks);
3739        assert_eq!(resolved.api_key.as_deref(), Some("fw-env-key"));
3740        assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL);
3741    }
3742
3743    #[test]
3744    fn siliconflow_env_overrides_key_base_url_and_model() {
3745        let _lock = env_lock();
3746        let _env = EnvGuard::without_deepseek_runtime_overrides();
3747        // Safety: test-only environment mutation guarded by a module mutex.
3748        unsafe {
3749            env::set_var("CODEWHALE_PROVIDER", "siliconflow");
3750            env::set_var("SILICONFLOW_API_KEY", "sf-env-key");
3751            env::set_var("SILICONFLOW_BASE_URL", "https://sf-mirror.example/v1");
3752            env::set_var("SILICONFLOW_MODEL", "deepseek-v4-flash");
3753        }
3754
3755        let resolved =
3756            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
3757
3758        assert_eq!(resolved.provider, ProviderKind::Siliconflow);
3759        assert_eq!(resolved.api_key.as_deref(), Some("sf-env-key"));
3760        assert_eq!(resolved.base_url, "https://sf-mirror.example/v1");
3761        assert_eq!(resolved.model, "deepseek-v4-flash");
3762    }
3763
3764    #[test]
3765    fn siliconflow_cn_base_url_env_normalizes_model_aliases() {
3766        let _lock = env_lock();
3767        let _env = EnvGuard::without_deepseek_runtime_overrides();
3768        // Safety: test-only environment mutation guarded by a module mutex.
3769        unsafe {
3770            env::set_var("CODEWHALE_PROVIDER", "siliconflow");
3771            env::set_var("SILICONFLOW_API_KEY", "sf-env-key");
3772            env::set_var("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1");
3773        }
3774
3775        for (alias, expected) in [
3776            ("deepseek-v4-flash", DEFAULT_SILICONFLOW_FLASH_MODEL),
3777            ("deepseek-reasoner", DEFAULT_SILICONFLOW_MODEL),
3778        ] {
3779            // Safety: test-only environment mutation guarded by a module mutex.
3780            unsafe {
3781                env::set_var("SILICONFLOW_MODEL", alias);
3782            }
3783
3784            let resolved =
3785                ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
3786
3787            assert_eq!(resolved.provider, ProviderKind::Siliconflow);
3788            assert_eq!(resolved.base_url, "https://api.siliconflow.cn/v1");
3789            assert_eq!(resolved.model, expected);
3790        }
3791    }
3792
3793    #[test]
3794    fn wanjie_ark_env_api_key_and_base_url_fall_back_when_config_missing() {
3795        let _lock = env_lock();
3796        let _env = EnvGuard::without_deepseek_runtime_overrides();
3797        // Safety: test-only environment mutation guarded by a module mutex.
3798        unsafe {
3799            env::set_var("DEEPSEEK_PROVIDER", "wanjie-ark");
3800            env::set_var("WANJIE_ARK_API_KEY", "wanjie-env-key");
3801            env::set_var("WANJIE_ARK_BASE_URL", "https://wanjie.example/api/v1");
3802            env::set_var("WANJIE_ARK_MODEL", "account-model-id");
3803        }
3804
3805        let resolved =
3806            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
3807
3808        assert_eq!(resolved.provider, ProviderKind::WanjieArk);
3809        assert_eq!(resolved.api_key.as_deref(), Some("wanjie-env-key"));
3810        assert_eq!(resolved.base_url, "https://wanjie.example/api/v1");
3811        assert_eq!(resolved.model, "account-model-id");
3812    }
3813
3814    #[test]
3815    fn openrouter_provider_normalizes_flash_aliases() {
3816        let _lock = env_lock();
3817        let _env = EnvGuard::without_deepseek_runtime_overrides();
3818        let cli = CliRuntimeOverrides {
3819            provider: Some(ProviderKind::Openrouter),
3820            model: Some("deepseek-v4-flash".to_string()),
3821            ..CliRuntimeOverrides::default()
3822        };
3823
3824        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
3825
3826        assert_eq!(resolved.provider, ProviderKind::Openrouter);
3827        assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL);
3828    }
3829
3830    #[test]
3831    fn openrouter_provider_normalizes_recent_large_model_aliases() {
3832        let _lock = env_lock();
3833        let _env = EnvGuard::without_deepseek_runtime_overrides();
3834
3835        for (alias, expected) in [
3836            (
3837                "trinity-large-thinking",
3838                OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL,
3839            ),
3840            ("qwen3.6-35b-a3b", OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
3841            ("mimo-v2.5-pro", OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
3842            ("kimi-k2.6", OPENROUTER_KIMI_K2_6_MODEL),
3843            ("gemma-4-31b-it", OPENROUTER_GEMMA_4_31B_MODEL),
3844            ("glm-5.1", OPENROUTER_GLM_5_1_MODEL),
3845        ] {
3846            let cli = CliRuntimeOverrides {
3847                provider: Some(ProviderKind::Openrouter),
3848                model: Some(alias.to_string()),
3849                ..CliRuntimeOverrides::default()
3850            };
3851
3852            let resolved = ConfigToml::default().resolve_runtime_options(&cli);
3853
3854            assert_eq!(resolved.provider, ProviderKind::Openrouter);
3855            assert_eq!(resolved.model, expected);
3856        }
3857    }
3858
3859    #[test]
3860    fn novita_provider_normalizes_flash_aliases() {
3861        let _lock = env_lock();
3862        let _env = EnvGuard::without_deepseek_runtime_overrides();
3863        let cli = CliRuntimeOverrides {
3864            provider: Some(ProviderKind::Novita),
3865            model: Some("deepseek-v4-flash".to_string()),
3866            ..CliRuntimeOverrides::default()
3867        };
3868
3869        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
3870
3871        assert_eq!(resolved.provider, ProviderKind::Novita);
3872        assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL);
3873    }
3874
3875    #[test]
3876    fn siliconflow_provider_normalizes_flash_aliases() {
3877        let _lock = env_lock();
3878        let _env = EnvGuard::without_deepseek_runtime_overrides();
3879        let cli = CliRuntimeOverrides {
3880            provider: Some(ProviderKind::Siliconflow),
3881            model: Some("deepseek-v4-flash".to_string()),
3882            ..CliRuntimeOverrides::default()
3883        };
3884
3885        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
3886
3887        assert_eq!(resolved.provider, ProviderKind::Siliconflow);
3888        assert_eq!(resolved.model, DEFAULT_SILICONFLOW_FLASH_MODEL);
3889    }
3890
3891    #[test]
3892    fn siliconflow_provider_normalizes_reasoning_aliases_to_pro() {
3893        let _lock = env_lock();
3894        let _env = EnvGuard::without_deepseek_runtime_overrides();
3895
3896        for alias in ["deepseek-reasoner", "deepseek-r1"] {
3897            let cli = CliRuntimeOverrides {
3898                provider: Some(ProviderKind::Siliconflow),
3899                model: Some(alias.to_string()),
3900                ..CliRuntimeOverrides::default()
3901            };
3902
3903            let resolved = ConfigToml::default().resolve_runtime_options(&cli);
3904
3905            assert_eq!(resolved.provider, ProviderKind::Siliconflow);
3906            assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL);
3907        }
3908    }
3909
3910    #[test]
3911    fn siliconflow_provider_preserves_deepseek_v3_2_alias() {
3912        let _lock = env_lock();
3913        let _env = EnvGuard::without_deepseek_runtime_overrides();
3914        let cli = CliRuntimeOverrides {
3915            provider: Some(ProviderKind::Siliconflow),
3916            model: Some("deepseek-v3.2".to_string()),
3917            ..CliRuntimeOverrides::default()
3918        };
3919
3920        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
3921
3922        assert_eq!(resolved.provider, ProviderKind::Siliconflow);
3923        assert_eq!(resolved.model, "deepseek-v3.2");
3924    }
3925
3926    #[test]
3927    fn sglang_provider_normalizes_flash_aliases() {
3928        let _lock = env_lock();
3929        let _env = EnvGuard::without_deepseek_runtime_overrides();
3930        let cli = CliRuntimeOverrides {
3931            provider: Some(ProviderKind::Sglang),
3932            model: Some("deepseek-v4-flash".to_string()),
3933            ..CliRuntimeOverrides::default()
3934        };
3935
3936        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
3937
3938        assert_eq!(resolved.provider, ProviderKind::Sglang);
3939        assert_eq!(resolved.model, DEFAULT_SGLANG_FLASH_MODEL);
3940    }
3941
3942    #[test]
3943    fn vllm_provider_normalizes_flash_aliases() {
3944        let _lock = env_lock();
3945        let _env = EnvGuard::without_deepseek_runtime_overrides();
3946        let cli = CliRuntimeOverrides {
3947            provider: Some(ProviderKind::Vllm),
3948            model: Some("deepseek-v4-flash".to_string()),
3949            ..CliRuntimeOverrides::default()
3950        };
3951
3952        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
3953
3954        assert_eq!(resolved.provider, ProviderKind::Vllm);
3955        assert_eq!(resolved.model, DEFAULT_VLLM_FLASH_MODEL);
3956    }
3957
3958    #[test]
3959    fn openrouter_provider_specific_config_overrides_env() {
3960        let _lock = env_lock();
3961        let _env = EnvGuard::without_deepseek_runtime_overrides();
3962        let mut config = ConfigToml {
3963            provider: ProviderKind::Openrouter,
3964            ..ConfigToml::default()
3965        };
3966        config.providers.openrouter.api_key = Some("file-key".to_string());
3967        config.providers.openrouter.base_url = Some("https://or-mirror.example/v1".to_string());
3968
3969        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3970
3971        assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
3972        assert_eq!(resolved.base_url, "https://or-mirror.example/v1");
3973    }
3974
3975    #[test]
3976    fn openrouter_custom_base_url_preserves_provider_model() {
3977        let _lock = env_lock();
3978        let _env = EnvGuard::without_deepseek_runtime_overrides();
3979        let mut config = ConfigToml {
3980            provider: ProviderKind::Openrouter,
3981            ..ConfigToml::default()
3982        };
3983        config.providers.openrouter.base_url = Some("https://gateway.example.com/v1".to_string());
3984        config.providers.openrouter.model = Some("DeepSeek-V4-Pro".to_string());
3985
3986        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3987
3988        assert_eq!(resolved.provider, ProviderKind::Openrouter);
3989        assert_eq!(resolved.base_url, "https://gateway.example.com/v1");
3990        assert_eq!(resolved.model, "DeepSeek-V4-Pro");
3991    }
3992
3993    #[test]
3994    fn fireworks_custom_base_url_preserves_provider_model() {
3995        let _lock = env_lock();
3996        let _env = EnvGuard::without_deepseek_runtime_overrides();
3997        let mut config = ConfigToml {
3998            provider: ProviderKind::Fireworks,
3999            ..ConfigToml::default()
4000        };
4001        config.providers.fireworks.base_url = Some("https://my-gateway.example/v1".to_string());
4002        config.providers.fireworks.model = Some("DeepSeek-V4-Pro".to_string());
4003
4004        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4005
4006        assert_eq!(resolved.provider, ProviderKind::Fireworks);
4007        assert_eq!(resolved.base_url, "https://my-gateway.example/v1");
4008        // Custom base URL skips provider-specific model prefixing.
4009        assert_eq!(resolved.model, "DeepSeek-V4-Pro");
4010    }
4011
4012    #[test]
4013    fn siliconflow_custom_base_url_preserves_provider_model() {
4014        let _lock = env_lock();
4015        let _env = EnvGuard::without_deepseek_runtime_overrides();
4016        let mut config = ConfigToml {
4017            provider: ProviderKind::Siliconflow,
4018            ..ConfigToml::default()
4019        };
4020        config.providers.siliconflow.base_url = Some("https://my-gateway.example/v1".to_string());
4021        config.providers.siliconflow.model = Some("DeepSeek-V4-Pro".to_string());
4022
4023        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4024
4025        assert_eq!(resolved.provider, ProviderKind::Siliconflow);
4026        assert_eq!(resolved.base_url, "https://my-gateway.example/v1");
4027        assert_eq!(resolved.model, "DeepSeek-V4-Pro");
4028    }
4029
4030    #[test]
4031    fn config_file_resolves_above_env_and_keyring() {
4032        use codewhale_secrets::KeyringStore;
4033        let _lock = env_lock();
4034        let _env = EnvGuard::without_deepseek_runtime_overrides();
4035        // Safety: env mutation guarded by env_lock().
4036        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
4037
4038        let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
4039        store.set("deepseek", "ring-key").unwrap();
4040        let secrets = Secrets::new(store);
4041
4042        let mut config = ConfigToml::default();
4043        config.providers.deepseek.api_key = Some("file-key".to_string());
4044
4045        let resolved =
4046            config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
4047        assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
4048        assert_eq!(
4049            resolved.api_key_source,
4050            Some(RuntimeApiKeySource::ConfigFile)
4051        );
4052
4053        // Safety: env mutation guarded by env_lock().
4054        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
4055    }
4056
4057    #[test]
4058    fn env_resolves_when_config_file_and_keyring_empty() {
4059        let _lock = env_lock();
4060        let _env = EnvGuard::without_deepseek_runtime_overrides();
4061        // Safety: env mutation guarded by env_lock().
4062        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
4063
4064        let secrets = Secrets::new(std::sync::Arc::new(
4065            codewhale_secrets::InMemoryKeyringStore::new(),
4066        ));
4067        let config = ConfigToml::default();
4068
4069        let resolved =
4070            config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
4071        assert_eq!(resolved.api_key.as_deref(), Some("env-key"));
4072        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
4073
4074        // Safety: env mutation guarded by env_lock().
4075        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
4076    }
4077
4078    #[test]
4079    fn config_file_resolves_when_keyring_and_env_empty() {
4080        let _lock = env_lock();
4081        let _env = EnvGuard::without_deepseek_runtime_overrides();
4082
4083        let secrets = Secrets::new(std::sync::Arc::new(
4084            codewhale_secrets::InMemoryKeyringStore::new(),
4085        ));
4086        let mut config = ConfigToml::default();
4087        config.providers.deepseek.api_key = Some("file-key".to_string());
4088
4089        let resolved =
4090            config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
4091        assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
4092        assert_eq!(
4093            resolved.api_key_source,
4094            Some(RuntimeApiKeySource::ConfigFile)
4095        );
4096    }
4097
4098    #[test]
4099    fn keyring_resolves_when_config_file_empty_even_if_env_is_set() {
4100        use codewhale_secrets::KeyringStore;
4101        let _lock = env_lock();
4102        let _env = EnvGuard::without_deepseek_runtime_overrides();
4103        // Safety: env mutation guarded by env_lock().
4104        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key") };
4105
4106        let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
4107        store.set("deepseek", "ring-key").unwrap();
4108        let secrets = Secrets::new(store);
4109
4110        let resolved = ConfigToml::default()
4111            .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
4112        assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
4113        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
4114
4115        // Safety: env mutation guarded by env_lock().
4116        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
4117    }
4118
4119    #[test]
4120    fn cli_flag_still_overrides_keyring() {
4121        use codewhale_secrets::KeyringStore;
4122        let _lock = env_lock();
4123        let _env = EnvGuard::without_deepseek_runtime_overrides();
4124
4125        let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
4126        store.set("deepseek", "ring-key").unwrap();
4127        let secrets = Secrets::new(store);
4128
4129        let cli = CliRuntimeOverrides {
4130            api_key: Some("cli-key".to_string()),
4131            ..CliRuntimeOverrides::default()
4132        };
4133        let resolved = ConfigToml::default().resolve_runtime_options_with_secrets(&cli, &secrets);
4134        assert_eq!(resolved.api_key.as_deref(), Some("cli-key"));
4135        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Cli));
4136    }
4137}