Skip to main content

codewhale_config/
lib.rs

1pub mod provider;
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5use std::fs;
6use std::io::Write;
7use std::path::{Component, Path, PathBuf};
8use std::sync::OnceLock;
9
10use anyhow::{Context, Result, bail};
11pub use codewhale_execpolicy::ToolAskRule;
12use codewhale_execpolicy::{ExecPolicyEngine, Ruleset};
13use codewhale_secrets::SecretSource;
14pub use codewhale_secrets::Secrets;
15use serde::{Deserialize, Serialize};
16
17#[cfg(unix)]
18use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
19
20pub const CONFIG_FILE_NAME: &str = "config.toml";
21pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml";
22const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro";
23const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
24const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
25const DEFAULT_OPENAI_MODEL: &str = "deepseek-v4-pro";
26const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta";
27const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
28const DEFAULT_OPENAI_CODEX_MODEL: &str = "gpt-5.5";
29const DEFAULT_ANTHROPIC_MODEL: &str = "claude-sonnet-4-6";
30const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com";
31const DEFAULT_OPENAI_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api";
32const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
33const DEFAULT_ATLASCLOUD_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
34const DEFAULT_ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1";
35const DEFAULT_WANJIE_ARK_MODEL: &str = "deepseek-reasoner";
36const DEFAULT_WANJIE_ARK_BASE_URL: &str = "https://maas-openapi.wanjiedata.com/api/v1";
37const DEFAULT_VOLCENGINE_MODEL: &str = "DeepSeek-V4-Pro";
38const DEFAULT_VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/coding/v3";
39const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro";
40const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
41const OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL: &str = "arcee-ai/trinity-large-thinking";
42const OPENROUTER_GEMMA_4_31B_MODEL: &str = "google/gemma-4-31b-it";
43const OPENROUTER_GEMMA_4_26B_A4B_MODEL: &str = "google/gemma-4-26b-a4b-it";
44const OPENROUTER_GLM_5_1_MODEL: &str = "z-ai/glm-5.1";
45const OPENROUTER_GLM_5_2_MODEL: &str = "z-ai/glm-5.2";
46const OPENROUTER_KIMI_K2_7_CODE_MODEL: &str = "moonshotai/kimi-k2.7-code";
47const OPENROUTER_KIMI_K2_6_MODEL: &str = "moonshotai/kimi-k2.6";
48const OPENROUTER_MINIMAX_M3_MODEL: &str = "minimax/minimax-m3";
49const OPENROUTER_MINIMAX_2_7_MODEL: &str = "minimax/minimax-2.7";
50const OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL: &str =
51    "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free";
52const OPENROUTER_QWEN_3_6_FLASH_MODEL: &str = "qwen/qwen3.6-flash";
53const OPENROUTER_QWEN_3_6_35B_A3B_MODEL: &str = "qwen/qwen3.6-35b-a3b";
54const OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL: &str = "qwen/qwen3.6-max-preview";
55const OPENROUTER_QWEN_3_6_27B_MODEL: &str = "qwen/qwen3.6-27b";
56const OPENROUTER_QWEN_3_6_PLUS_MODEL: &str = "qwen/qwen3.6-plus";
57const OPENROUTER_QWEN_3_7_MAX_MODEL: &str = "qwen/qwen3.7-max";
58const OPENROUTER_TENCENT_HY3_PREVIEW_MODEL: &str = "tencent/hy3-preview";
59const OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL: &str = "xiaomi/mimo-v2.5-pro";
60const OPENROUTER_XIAOMI_MIMO_V2_5_MODEL: &str = "xiaomi/mimo-v2.5";
61const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro";
62const XIAOMI_MIMO_V2_5_OMNI_MODEL: &str = "mimo-v2.5";
63const XIAOMI_MIMO_ASR_MODEL: &str = "mimo-v2.5-asr";
64const XIAOMI_MIMO_TTS_MODEL: &str = "mimo-v2.5-tts";
65const XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL: &str = "mimo-v2.5-tts-voicedesign";
66const XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL: &str = "mimo-v2.5-tts-voiceclone";
67const XIAOMI_MIMO_V2_TTS_MODEL: &str = "mimo-v2-tts";
68const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro";
69const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
70const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro";
71const DEFAULT_SILICONFLOW_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
72const DEFAULT_SILICONFLOW_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
73const DEFAULT_ARCEE_MODEL: &str = "trinity-large-thinking";
74const ARCEE_TRINITY_LARGE_PREVIEW_MODEL: &str = "trinity-large-preview";
75const ARCEE_TRINITY_MINI_MODEL: &str = "trinity-mini";
76const DEFAULT_MOONSHOT_MODEL: &str = "kimi-k2.7-code";
77const MOONSHOT_KIMI_K2_6_MODEL: &str = "kimi-k2.6";
78const DEFAULT_MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1";
79const DEFAULT_KIMI_CODE_MODEL: &str = "kimi-for-coding";
80const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding/v1";
81const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
82const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
83const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
84const XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL: &str = "https://api.xiaomimimo.com/v1";
85const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://token-plan-sgp.xiaomimimo.com/v1";
86const XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL: &str = "https://token-plan-cn.xiaomimimo.com/v1";
87const XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL: &str = DEFAULT_XIAOMI_MIMO_BASE_URL;
88const XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL: &str = "https://token-plan-ams.xiaomimimo.com/v1";
89const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/openai/v1";
90const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
91const DEFAULT_SILICONFLOW_BASE_URL: &str = "https://api.siliconflow.com/v1";
92const DEFAULT_SILICONFLOW_CN_BASE_URL: &str = "https://api.siliconflow.cn/v1";
93const DEFAULT_ARCEE_BASE_URL: &str = "https://api.arcee.ai/api/v1";
94const DEFAULT_HUGGINGFACE_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
95const DEFAULT_HUGGINGFACE_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
96const DEFAULT_HUGGINGFACE_BASE_URL: &str = "https://router.huggingface.co/v1";
97const DEFAULT_TOGETHER_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
98const DEFAULT_TOGETHER_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
99const DEFAULT_TOGETHER_BASE_URL: &str = "https://api.together.xyz/v1";
100const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1";
101const DEFAULT_VLLM_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
102const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
103const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1";
104const DEFAULT_OLLAMA_MODEL: &str = "deepseek-coder:1.3b";
105const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
106
107// Z.ai (GLM Coding Plan) defaults
108const DEFAULT_ZAI_MODEL: &str = "GLM-5.1";
109const ZAI_GLM_5_2_MODEL: &str = "GLM-5.2";
110const DEFAULT_ZAI_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
111// StepFun / StepFlash defaults
112const DEFAULT_STEPFUN_MODEL: &str = "step-3.7-flash";
113const DEFAULT_STEPFUN_BASE_URL: &str = "https://api.stepfun.ai/v1";
114// MiniMax defaults
115const DEFAULT_MINIMAX_MODEL: &str = "MiniMax-M3";
116const MINIMAX_M2_7_MODEL: &str = "MiniMax-M2.7";
117const MINIMAX_M2_7_HIGHSPEED_MODEL: &str = "MiniMax-M2.7-highspeed";
118const MINIMAX_M2_5_MODEL: &str = "MiniMax-M2.5";
119const MINIMAX_M2_5_HIGHSPEED_MODEL: &str = "MiniMax-M2.5-highspeed";
120const MINIMAX_M2_1_MODEL: &str = "MiniMax-M2.1";
121const MINIMAX_M2_1_HIGHSPEED_MODEL: &str = "MiniMax-M2.1-highspeed";
122const MINIMAX_M2_MODEL: &str = "MiniMax-M2";
123const DEFAULT_MINIMAX_BASE_URL: &str = "https://api.minimax.io/v1";
124const DEFAULT_DEEPINFRA_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
125const DEFAULT_DEEPINFRA_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
126const DEFAULT_DEEPINFRA_BASE_URL: &str = "https://api.deepinfra.com/v1/openai";
127
128#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
129#[serde(rename_all = "kebab-case")]
130pub enum ProviderKind {
131    #[default]
132    #[serde(
133        alias = "deepseek-cn",
134        alias = "deepseek_china",
135        alias = "deepseekcn",
136        alias = "deepseek-china"
137    )]
138    Deepseek,
139    NvidiaNim,
140    #[serde(alias = "open-ai")]
141    Openai,
142    Atlascloud,
143    #[serde(
144        alias = "wanjie",
145        alias = "wanjie_ark",
146        alias = "ark-wanjie",
147        alias = "ark_wanjie",
148        alias = "wanjie-maas",
149        alias = "wanjie_maas"
150    )]
151    WanjieArk,
152    #[serde(alias = "volcengine-ark", alias = "volcengine_ark", alias = "ark")]
153    Volcengine,
154    Openrouter,
155    #[serde(alias = "mimo", alias = "xiaomi", alias = "xiaomi_mimo")]
156    XiaomiMimo,
157    Novita,
158    Fireworks,
159    #[serde(alias = "silicon-flow", alias = "silicon_flow")]
160    Siliconflow,
161    #[serde(alias = "arcee-ai", alias = "arcee_ai")]
162    Arcee,
163    #[serde(alias = "siliconflow-cn", alias = "siliconflow-CN")]
164    SiliconflowCN,
165    Moonshot,
166    Sglang,
167    Vllm,
168    Ollama,
169    #[serde(alias = "hugging-face", alias = "hugging_face", alias = "hf")]
170    Huggingface,
171    #[serde(alias = "together-ai", alias = "together_ai")]
172    Together,
173    #[serde(
174        alias = "openai-codex",
175        alias = "openai_codex",
176        alias = "codex",
177        alias = "chatgpt",
178        alias = "chatgpt-codex",
179        alias = "chatgpt_codex"
180    )]
181    OpenaiCodex,
182    #[serde(alias = "claude")]
183    Anthropic,
184    #[serde(alias = "z-ai", alias = "z_ai", alias = "z.ai")]
185    Zai,
186    #[serde(
187        alias = "step-fun",
188        alias = "step_fun",
189        alias = "stepfun",
190        alias = "stepflash",
191        alias = "step-flash",
192        alias = "step_flash"
193    )]
194    Stepfun,
195    #[serde(alias = "mini-max", alias = "mini_max", alias = "minimax")]
196    Minimax,
197    #[serde(alias = "deep-infra", alias = "deep_infra")]
198    Deepinfra,
199}
200
201impl ProviderKind {
202    pub const ALL: [Self; 25] = [
203        Self::Deepseek,
204        Self::NvidiaNim,
205        Self::Openai,
206        Self::Atlascloud,
207        Self::WanjieArk,
208        Self::Volcengine,
209        Self::Openrouter,
210        Self::XiaomiMimo,
211        Self::Novita,
212        Self::Fireworks,
213        Self::Siliconflow,
214        Self::Arcee,
215        Self::SiliconflowCN,
216        Self::Moonshot,
217        Self::Sglang,
218        Self::Vllm,
219        Self::Ollama,
220        Self::Huggingface,
221        Self::Together,
222        Self::OpenaiCodex,
223        Self::Anthropic,
224        Self::Zai,
225        Self::Stepfun,
226        Self::Minimax,
227        Self::Deepinfra,
228    ];
229
230    #[must_use]
231    pub fn all() -> &'static [Self] {
232        &[
233            Self::Deepseek,
234            Self::NvidiaNim,
235            Self::Openai,
236            Self::Atlascloud,
237            Self::WanjieArk,
238            Self::Volcengine,
239            Self::Openrouter,
240            Self::XiaomiMimo,
241            Self::Novita,
242            Self::Fireworks,
243            Self::Siliconflow,
244            Self::SiliconflowCN,
245            Self::Arcee,
246            Self::Moonshot,
247            Self::Sglang,
248            Self::Vllm,
249            Self::Ollama,
250            Self::Huggingface,
251            Self::Together,
252            Self::OpenaiCodex,
253            Self::Anthropic,
254            Self::Zai,
255            Self::Stepfun,
256            Self::Minimax,
257        ]
258    }
259
260    #[must_use]
261    pub fn names_hint() -> String {
262        Self::all()
263            .iter()
264            .map(|provider| provider.as_str())
265            .collect::<Vec<_>>()
266            .join(", ")
267    }
268
269    #[must_use]
270    pub fn as_str(self) -> &'static str {
271        self.provider().id()
272    }
273
274    #[must_use]
275    pub fn parse(value: &str) -> Option<Self> {
276        let trimmed = value.trim();
277        provider::all_providers()
278            .iter()
279            .find(|p| {
280                trimmed.eq_ignore_ascii_case(p.id())
281                    || p.aliases().iter().any(|a| trimmed.eq_ignore_ascii_case(a))
282            })
283            .map(|p| p.kind())
284    }
285
286    #[must_use]
287    pub fn is_siliconflow(self) -> bool {
288        matches!(self, Self::Siliconflow | Self::SiliconflowCN)
289    }
290
291    /// Return the built-in metadata entry for this provider.
292    ///
293    /// This is a metadata foundation only; runtime routing still resolves
294    /// through [`ConfigToml::resolve_runtime_options`].
295    #[must_use]
296    pub fn provider(self) -> &'static dyn provider::Provider {
297        provider::provider_for_kind(self)
298    }
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize, Default)]
302pub struct ProviderConfigToml {
303    pub api_key: Option<String>,
304    pub base_url: Option<String>,
305    pub model: Option<String>,
306    pub mode: Option<String>,
307    pub auth_mode: Option<String>,
308    pub insecure_skip_tls_verify: Option<bool>,
309    #[serde(default)]
310    pub http_headers: BTreeMap<String, String>,
311    pub path_suffix: Option<String>,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize, Default)]
315pub struct ProvidersToml {
316    #[serde(default)]
317    pub deepseek: ProviderConfigToml,
318    #[serde(default)]
319    pub nvidia_nim: ProviderConfigToml,
320    #[serde(default)]
321    pub openai: ProviderConfigToml,
322    #[serde(default)]
323    pub atlascloud: ProviderConfigToml,
324    #[serde(default)]
325    pub wanjie_ark: ProviderConfigToml,
326    #[serde(default)]
327    pub volcengine: ProviderConfigToml,
328    #[serde(default)]
329    pub openrouter: ProviderConfigToml,
330    #[serde(default, alias = "xiaomi", alias = "mimo", alias = "xiaomimimo")]
331    pub xiaomi_mimo: ProviderConfigToml,
332    #[serde(default)]
333    pub novita: ProviderConfigToml,
334    #[serde(default)]
335    pub fireworks: ProviderConfigToml,
336    #[serde(default)]
337    pub siliconflow: ProviderConfigToml,
338    #[serde(default, alias = "siliconflow-CN", alias = "siliconflow-cn")]
339    pub siliconflow_cn: ProviderConfigToml,
340    #[serde(default)]
341    pub arcee: ProviderConfigToml,
342    #[serde(default)]
343    pub moonshot: ProviderConfigToml,
344    #[serde(default)]
345    pub sglang: ProviderConfigToml,
346    #[serde(default)]
347    pub vllm: ProviderConfigToml,
348    #[serde(default)]
349    pub ollama: ProviderConfigToml,
350    #[serde(default)]
351    pub huggingface: ProviderConfigToml,
352    #[serde(default)]
353    pub together: ProviderConfigToml,
354    #[serde(
355        default,
356        alias = "openai-codex",
357        alias = "openai_codex",
358        alias = "codex",
359        alias = "chatgpt",
360        alias = "chatgpt-codex"
361    )]
362    pub openai_codex: ProviderConfigToml,
363    #[serde(default)]
364    pub anthropic: ProviderConfigToml,
365    #[serde(default, alias = "z-ai", alias = "z_ai", alias = "z.ai")]
366    pub zai: ProviderConfigToml,
367    #[serde(
368        default,
369        alias = "step-fun",
370        alias = "step_fun",
371        alias = "stepfun",
372        alias = "stepflash",
373        alias = "step-flash",
374        alias = "step_flash"
375    )]
376    pub stepfun: ProviderConfigToml,
377    #[serde(default, alias = "mini-max", alias = "mini_max", alias = "minimax")]
378    pub minimax: ProviderConfigToml,
379    #[serde(default, alias = "deep-infra", alias = "deep_infra")]
380    pub deepinfra: ProviderConfigToml,
381}
382
383/// Sibling `permissions.toml` schema.
384///
385/// This slice is intentionally ask-only: each rule is a typed condition that
386/// means "ask before this tool invocation." Typed allow/deny records and UI
387/// actions are expected to land in follow-up PRs.
388#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
389#[serde(deny_unknown_fields)]
390pub struct PermissionsToml {
391    #[serde(default, skip_serializing_if = "Vec::is_empty")]
392    pub rules: Vec<ToolAskRule>,
393}
394
395impl PermissionsToml {
396    #[must_use]
397    pub fn is_empty(&self) -> bool {
398        self.rules.is_empty()
399    }
400
401    #[must_use]
402    pub fn ruleset(&self) -> Ruleset {
403        Ruleset::user(Vec::new(), Vec::new()).with_ask_rules(self.rules.clone())
404    }
405}
406
407impl ProvidersToml {
408    #[must_use]
409    pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
410        match provider {
411            ProviderKind::Deepseek => &self.deepseek,
412            ProviderKind::NvidiaNim => &self.nvidia_nim,
413            ProviderKind::Openai => &self.openai,
414            ProviderKind::Atlascloud => &self.atlascloud,
415            ProviderKind::WanjieArk => &self.wanjie_ark,
416            ProviderKind::Volcengine => &self.volcengine,
417            ProviderKind::Openrouter => &self.openrouter,
418            ProviderKind::XiaomiMimo => &self.xiaomi_mimo,
419            ProviderKind::Novita => &self.novita,
420            ProviderKind::Fireworks => &self.fireworks,
421            ProviderKind::Siliconflow => &self.siliconflow,
422            ProviderKind::SiliconflowCN => &self.siliconflow_cn,
423            ProviderKind::Arcee => &self.arcee,
424            ProviderKind::Moonshot => &self.moonshot,
425            ProviderKind::Sglang => &self.sglang,
426            ProviderKind::Vllm => &self.vllm,
427            ProviderKind::Ollama => &self.ollama,
428            ProviderKind::Huggingface => &self.huggingface,
429            ProviderKind::Together => &self.together,
430            ProviderKind::OpenaiCodex => &self.openai_codex,
431            ProviderKind::Anthropic => &self.anthropic,
432            ProviderKind::Zai => &self.zai,
433            ProviderKind::Stepfun => &self.stepfun,
434            ProviderKind::Minimax => &self.minimax,
435            ProviderKind::Deepinfra => &self.deepinfra,
436        }
437    }
438
439    pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
440        match provider {
441            ProviderKind::Deepseek => &mut self.deepseek,
442            ProviderKind::NvidiaNim => &mut self.nvidia_nim,
443            ProviderKind::Openai => &mut self.openai,
444            ProviderKind::Atlascloud => &mut self.atlascloud,
445            ProviderKind::WanjieArk => &mut self.wanjie_ark,
446            ProviderKind::Volcengine => &mut self.volcengine,
447            ProviderKind::Openrouter => &mut self.openrouter,
448            ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo,
449            ProviderKind::Novita => &mut self.novita,
450            ProviderKind::Fireworks => &mut self.fireworks,
451            ProviderKind::Siliconflow => &mut self.siliconflow,
452            ProviderKind::SiliconflowCN => &mut self.siliconflow_cn,
453            ProviderKind::Arcee => &mut self.arcee,
454            ProviderKind::Moonshot => &mut self.moonshot,
455            ProviderKind::Sglang => &mut self.sglang,
456            ProviderKind::Vllm => &mut self.vllm,
457            ProviderKind::Ollama => &mut self.ollama,
458            ProviderKind::Huggingface => &mut self.huggingface,
459            ProviderKind::Together => &mut self.together,
460            ProviderKind::OpenaiCodex => &mut self.openai_codex,
461            ProviderKind::Anthropic => &mut self.anthropic,
462            ProviderKind::Zai => &mut self.zai,
463            ProviderKind::Stepfun => &mut self.stepfun,
464            ProviderKind::Minimax => &mut self.minimax,
465            ProviderKind::Deepinfra => &mut self.deepinfra,
466        }
467    }
468}
469
470/// Kinds of built-in harness postures.
471///
472/// A posture names the runtime strategy CodeWhale should use for a
473/// provider/model route: how much context to preload, how aggressively to lean
474/// on sub-agents, and how to balance prompt-cache stability against quick
475/// exploration. Runtime selection is wired in later v0.9 slices; this config
476/// model intentionally keeps the policy data explicit first.
477#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
478#[serde(rename_all = "kebab-case")]
479pub enum HarnessPostureKind {
480    /// Full-featured default: rich constitution, broad tool catalog, and normal
481    /// sub-agent posture.
482    #[default]
483    Standard,
484    /// Cache-heavy: deeper prompt layering and prefix-cache-oriented context.
485    CacheHeavy,
486    /// Lean: smaller starting context, faster compaction, and stronger
487    /// exploration/delegation bias.
488    Lean,
489    /// User-defined posture assembled from explicit knobs below.
490    Custom,
491}
492
493/// How this posture should approach compaction and prompt-cache stability.
494#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
495#[serde(rename_all = "kebab-case")]
496pub enum HarnessCompactionStrategy {
497    #[default]
498    Default,
499    PrefixCache,
500    Aggressive,
501}
502
503/// Which tool catalog shape this posture prefers.
504#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
505#[serde(rename_all = "kebab-case")]
506pub enum HarnessToolSurface {
507    #[default]
508    Full,
509    ReadOnly,
510    Auto,
511}
512
513/// Safety posture applied when the runtime consumes a harness profile.
514#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
515#[serde(rename_all = "kebab-case")]
516pub enum HarnessSafetyPosture {
517    #[default]
518    Standard,
519    Strict,
520    Permissive,
521}
522
523/// A concrete harness posture with policy knobs.
524#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
525#[serde(deny_unknown_fields)]
526pub struct HarnessPosture {
527    /// Named posture kind.
528    #[serde(default)]
529    pub kind: HarnessPostureKind,
530    /// Maximum number of concurrent sub-agents (0 = runtime default).
531    #[serde(default)]
532    pub max_subagents: usize,
533    /// Prefer search-based/on-demand context over always-on documentation.
534    #[serde(default)]
535    pub prefer_codebase_search: bool,
536    /// Compaction and prompt-cache strategy.
537    #[serde(default)]
538    pub compaction_strategy: HarnessCompactionStrategy,
539    /// Preferred tool catalog shape.
540    #[serde(default)]
541    pub tool_surface: HarnessToolSurface,
542    /// Safety posture for runtime consumers.
543    #[serde(default)]
544    pub safety_posture: HarnessSafetyPosture,
545}
546
547impl Default for HarnessPosture {
548    fn default() -> Self {
549        Self {
550            kind: HarnessPostureKind::Standard,
551            max_subagents: 0,
552            prefer_codebase_search: false,
553            compaction_strategy: HarnessCompactionStrategy::default(),
554            tool_surface: HarnessToolSurface::default(),
555            safety_posture: HarnessSafetyPosture::default(),
556        }
557    }
558}
559
560impl HarnessPosture {
561    /// A cache-heavy posture tuned for DeepSeek V4 / MiMo-style models.
562    #[must_use]
563    pub fn cache_heavy() -> Self {
564        Self {
565            kind: HarnessPostureKind::CacheHeavy,
566            max_subagents: 10,
567            prefer_codebase_search: false,
568            compaction_strategy: HarnessCompactionStrategy::PrefixCache,
569            tool_surface: HarnessToolSurface::Full,
570            safety_posture: HarnessSafetyPosture::Standard,
571        }
572    }
573
574    /// A lean posture for smaller-context or weaker tool-use models.
575    #[must_use]
576    pub fn lean() -> Self {
577        Self {
578            kind: HarnessPostureKind::Lean,
579            max_subagents: 20,
580            prefer_codebase_search: true,
581            compaction_strategy: HarnessCompactionStrategy::Aggressive,
582            tool_surface: HarnessToolSurface::Full,
583            safety_posture: HarnessSafetyPosture::Standard,
584        }
585    }
586}
587
588/// A harness profile binds a posture to a provider route and model pattern.
589#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
590#[serde(deny_unknown_fields)]
591pub struct HarnessProfile {
592    /// Provider route this profile applies to, e.g. "deepseek" or
593    /// "xiaomi-mimo".
594    pub provider_route: String,
595    /// Regex or glob pattern for model names, e.g. "deepseek-v4.*".
596    pub model_pattern: String,
597    /// The posture to apply.
598    #[serde(default)]
599    pub posture: HarnessPosture,
600}
601
602impl HarnessProfile {
603    /// Return true when this profile applies to the provider/model route.
604    ///
605    /// This is a pure config helper: matching a profile must not mutate runtime
606    /// provider selection, prompts, auth, tools, context, or persisted config.
607    #[must_use]
608    pub fn matches_route(&self, provider_route: &str, model: &str) -> bool {
609        provider_routes_equal(&self.provider_route, provider_route)
610            && wildcard_pattern_matches(&self.model_pattern, model)
611    }
612}
613
614#[derive(Debug, Clone, Serialize, Deserialize, Default)]
615pub struct ConfigToml {
616    /// TUI-compatible DeepSeek API key. Kept at the root so both `deepseek`
617    /// and `codewhale-tui` can share a single config file.
618    pub api_key: Option<String>,
619    /// TUI-compatible DeepSeek base URL.
620    pub base_url: Option<String>,
621    /// Optional extra HTTP headers forwarded to model API requests.
622    #[serde(default)]
623    pub http_headers: BTreeMap<String, String>,
624    /// TUI-compatible default DeepSeek model.
625    pub default_text_model: Option<String>,
626    #[serde(default)]
627    pub provider: ProviderKind,
628    pub model: Option<String>,
629    pub auth_mode: Option<String>,
630    pub output_mode: Option<String>,
631    pub verbosity: Option<String>,
632    pub log_level: Option<String>,
633    pub telemetry: Option<bool>,
634    pub approval_policy: Option<String>,
635    pub sandbox_mode: Option<String>,
636    /// Native tool catalog controls shared with `codewhale-tui`.
637    #[serde(default)]
638    pub tools: Option<ToolsToml>,
639    #[serde(default)]
640    pub providers: ProvidersToml,
641    /// Provider fallback chain (#2574). TUI runtime code may advance through
642    /// these providers after recoverable provider errors; config resolution
643    /// itself still reports the selected primary provider.
644    #[serde(default, skip_serializing_if = "Vec::is_empty")]
645    pub fallback_providers: Vec<ProviderKind>,
646    /// Per-domain network policy (#135). When absent, network tools fall back
647    /// to a permissive default that mirrors pre-v0.7.0 behavior.
648    #[serde(default)]
649    pub network: Option<NetworkPolicyToml>,
650    /// Community skill installer settings (#140). Mirrors
651    /// [`SkillsToml`] from the TUI side; the dispatcher consults
652    /// `registry_url` when running `deepseek skill install`.
653    #[serde(default)]
654    pub skills: Option<SkillsToml>,
655    /// Workspace side-git snapshots (#137). The live TUI defaults this to
656    /// enabled with 7-day retention when absent.
657    #[serde(default)]
658    pub snapshots: Option<SnapshotsToml>,
659    /// Post-edit LSP diagnostics injection (#136). When absent, the engine
660    /// applies the defaults documented in [`LspConfigToml`].
661    #[serde(default)]
662    pub lsp: Option<LspConfigToml>,
663    /// Per-model harness profiles (#2693). Runtime wiring lands in follow-up
664    /// v0.9 slices; this is the durable config data model.
665    #[serde(default)]
666    pub harness_profiles: Vec<HarnessProfile>,
667    /// Optional 1-8 hotbar slot bindings (#2064). When absent, the TUI falls
668    /// back to the built-in default slots.
669    #[serde(default, skip_serializing_if = "Option::is_none")]
670    pub hotbar: Option<Vec<HotbarBindingToml>>,
671    /// App-server hook sink configuration. Kept separate from the TUI
672    /// lifecycle `[hooks]` table so config rewrites preserve existing hooks.
673    #[serde(default)]
674    pub hook_sinks: Option<HookSinksToml>,
675    /// Agent Fleet trust and security policy (#3165). When absent, fleet
676    /// workers inherit conservative Sandbox defaults.
677    #[serde(default)]
678    pub fleet: Option<FleetConfigToml>,
679    #[serde(flatten)]
680    pub extras: BTreeMap<String, toml::Value>,
681}
682
683impl ConfigToml {
684    /// Resolve the first configured harness profile for a provider/model route.
685    ///
686    /// This helper is deliberately dormant for v0.9: callers may display or
687    /// test the resolved profile, but runtime provider/model routing and prompt
688    /// shaping remain unchanged until a later, explicit integration slice.
689    #[must_use]
690    pub fn resolve_harness_profile(
691        &self,
692        provider_route: &str,
693        model: &str,
694    ) -> Option<&HarnessProfile> {
695        self.harness_profiles
696            .iter()
697            .chain(built_in_harness_profiles().iter())
698            .find(|profile| profile.matches_route(provider_route, model))
699    }
700
701    /// Resolve durable hotbar config into normalized 1-8 slot bindings.
702    ///
703    /// `known_action_ids` is supplied by the TUI action registry in later
704    /// slices. Unknown actions are preserved so the UI can render a disabled
705    /// `?` cell instead of silently deleting user config.
706    #[must_use]
707    pub fn resolve_hotbar_bindings(&self, known_action_ids: &[&str]) -> HotbarConfigResolution {
708        resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids)
709    }
710}
711
712/// Built-in profile seeds for common provider/model families.
713///
714/// User-configured profiles are always checked first; these seeds only provide
715/// a stable resolver result when config has no narrower match.
716#[must_use]
717pub fn built_in_harness_profiles() -> &'static [HarnessProfile] {
718    static PROFILES: OnceLock<Vec<HarnessProfile>> = OnceLock::new();
719    PROFILES.get_or_init(|| {
720        vec![
721            HarnessProfile {
722                provider_route: "deepseek".to_string(),
723                model_pattern: "deepseek-v4*".to_string(),
724                posture: HarnessPosture::cache_heavy(),
725            },
726            HarnessProfile {
727                provider_route: "xiaomi-mimo".to_string(),
728                model_pattern: "mimo-v2.5*".to_string(),
729                posture: HarnessPosture::cache_heavy(),
730            },
731            HarnessProfile {
732                provider_route: "arcee".to_string(),
733                model_pattern: "trinity-large-thinking".to_string(),
734                posture: HarnessPosture::cache_heavy(),
735            },
736            HarnessProfile {
737                provider_route: "huggingface".to_string(),
738                model_pattern: "*".to_string(),
739                posture: HarnessPosture::lean(),
740            },
741            HarnessProfile {
742                provider_route: "sglang".to_string(),
743                model_pattern: "*".to_string(),
744                posture: HarnessPosture::lean(),
745            },
746            HarnessProfile {
747                provider_route: "vllm".to_string(),
748                model_pattern: "*".to_string(),
749                posture: HarnessPosture::lean(),
750            },
751            HarnessProfile {
752                provider_route: "ollama".to_string(),
753                model_pattern: "*".to_string(),
754                posture: HarnessPosture::lean(),
755            },
756        ]
757    })
758}
759
760fn provider_routes_equal(expected: &str, actual: &str) -> bool {
761    match (ProviderKind::parse(expected), ProviderKind::parse(actual)) {
762        (Some(expected), Some(actual)) => expected == actual,
763        _ => expected.trim().eq_ignore_ascii_case(actual.trim()),
764    }
765}
766
767fn wildcard_pattern_matches(pattern: &str, value: &str) -> bool {
768    wildcard_chars_match(
769        &pattern.chars().collect::<Vec<_>>(),
770        &value.chars().collect::<Vec<_>>(),
771    )
772}
773
774fn wildcard_chars_match(pattern: &[char], value: &[char]) -> bool {
775    let (mut pattern_idx, mut value_idx) = (0, 0);
776    let mut star_idx: Option<usize> = None;
777    let mut star_value_idx = 0;
778
779    while value_idx < value.len() {
780        if pattern_idx < pattern.len()
781            && (pattern[pattern_idx] == '?' || pattern[pattern_idx] == value[value_idx])
782        {
783            pattern_idx += 1;
784            value_idx += 1;
785        } else if pattern_idx < pattern.len() && pattern[pattern_idx] == '*' {
786            star_idx = Some(pattern_idx);
787            pattern_idx += 1;
788            star_value_idx = value_idx;
789        } else if let Some(star) = star_idx {
790            pattern_idx = star + 1;
791            star_value_idx += 1;
792            value_idx = star_value_idx;
793        } else {
794            return false;
795        }
796    }
797
798    pattern[pattern_idx..].iter().all(|ch| *ch == '*')
799}
800
801/// Ordered primary-plus-fallback provider list for future provider routing.
802///
803/// The helper is intentionally dormant: constructing or parsing a chain does
804/// not change [`ConfigToml::resolve_runtime_options`].
805#[derive(Debug, Clone, PartialEq, Eq)]
806pub struct ProviderChain {
807    providers: Vec<ProviderKind>,
808    position: usize,
809}
810
811pub const HOTBAR_SLOT_COUNT: u8 = 8;
812
813pub const DEFAULT_HOTBAR_ACTIONS: [&str; HOTBAR_SLOT_COUNT as usize] = [
814    "voice.toggle",
815    "session.compact",
816    "mode.plan",
817    "mode.agent",
818    "mode.yolo",
819    "palette.open",
820    "sidebar.toggle",
821    "trust.toggle",
822];
823
824/// On-disk schema for one `[[hotbar]]` table.
825#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
826#[serde(deny_unknown_fields)]
827pub struct HotbarBindingToml {
828    pub slot: u8,
829    pub action: String,
830    #[serde(default)]
831    pub label: Option<String>,
832}
833
834/// Validated hotbar binding used by future render/dispatch layers.
835#[derive(Debug, Clone, PartialEq, Eq)]
836pub struct HotbarBinding {
837    pub slot: u8,
838    pub action: String,
839    pub label: Option<String>,
840}
841
842/// Non-fatal hotbar config issue. Invalid slots are skipped; duplicate slots
843/// use the last binding; unknown actions are kept for UI feedback.
844#[derive(Debug, Clone, PartialEq, Eq)]
845pub enum HotbarConfigWarning {
846    SlotOutOfRange {
847        slot: u8,
848        action: String,
849    },
850    DuplicateSlot {
851        slot: u8,
852        previous_action: String,
853        replacement_action: String,
854    },
855    UnknownAction {
856        slot: u8,
857        action: String,
858    },
859}
860
861impl fmt::Display for HotbarConfigWarning {
862    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
863        match self {
864            Self::SlotOutOfRange { slot, action } => write!(
865                f,
866                "hotbar slot {slot} for action '{action}' is outside 1-{HOTBAR_SLOT_COUNT}; skipped"
867            ),
868            Self::DuplicateSlot {
869                slot,
870                previous_action,
871                replacement_action,
872            } => write!(
873                f,
874                "hotbar slot {slot} was bound to '{previous_action}' more than once; using '{replacement_action}'"
875            ),
876            Self::UnknownAction { slot, action } => write!(
877                f,
878                "hotbar slot {slot} references unknown action '{action}'; keeping binding"
879            ),
880        }
881    }
882}
883
884#[derive(Debug, Clone, PartialEq, Eq)]
885pub struct HotbarConfigResolution {
886    pub bindings: Vec<HotbarBinding>,
887    pub warnings: Vec<HotbarConfigWarning>,
888}
889
890#[must_use]
891pub fn default_hotbar_bindings() -> Vec<HotbarBinding> {
892    DEFAULT_HOTBAR_ACTIONS
893        .iter()
894        .enumerate()
895        .map(|(idx, action)| HotbarBinding {
896            slot: u8::try_from(idx + 1).expect("default hotbar slot fits in u8"),
897            action: (*action).to_string(),
898            label: None,
899        })
900        .collect()
901}
902
903#[must_use]
904pub fn resolve_hotbar_bindings(
905    configured: Option<&[HotbarBindingToml]>,
906    known_action_ids: &[&str],
907) -> HotbarConfigResolution {
908    let known = known_action_ids.iter().copied().collect::<BTreeSet<&str>>();
909    let mut warnings = Vec::new();
910
911    let source = match configured {
912        Some(bindings) => bindings
913            .iter()
914            .map(|binding| HotbarBinding {
915                slot: binding.slot,
916                action: binding.action.clone(),
917                label: binding.label.clone(),
918            })
919            .collect::<Vec<_>>(),
920        None => default_hotbar_bindings(),
921    };
922
923    let mut by_slot: BTreeMap<u8, HotbarBinding> = BTreeMap::new();
924    for binding in source {
925        if !(1..=HOTBAR_SLOT_COUNT).contains(&binding.slot) {
926            warnings.push(HotbarConfigWarning::SlotOutOfRange {
927                slot: binding.slot,
928                action: binding.action,
929            });
930            continue;
931        }
932        if !known.is_empty() && !known.contains(binding.action.as_str()) {
933            warnings.push(HotbarConfigWarning::UnknownAction {
934                slot: binding.slot,
935                action: binding.action.clone(),
936            });
937        }
938        if let Some(previous) = by_slot.insert(binding.slot, binding.clone()) {
939            warnings.push(HotbarConfigWarning::DuplicateSlot {
940                slot: binding.slot,
941                previous_action: previous.action,
942                replacement_action: binding.action,
943            });
944        }
945    }
946
947    HotbarConfigResolution {
948        bindings: by_slot.into_values().collect(),
949        warnings,
950    }
951}
952
953impl ProviderChain {
954    #[must_use]
955    pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self {
956        let mut providers = vec![active];
957        for fallback in fallbacks {
958            if *fallback != active && !providers.contains(fallback) {
959                providers.push(*fallback);
960            }
961        }
962        Self {
963            providers,
964            position: 0,
965        }
966    }
967
968    #[must_use]
969    pub fn providers(&self) -> &[ProviderKind] {
970        &self.providers
971    }
972
973    #[must_use]
974    pub fn position(&self) -> usize {
975        self.position
976    }
977
978    #[must_use]
979    pub fn current(&self) -> ProviderKind {
980        self.providers
981            .get(self.position)
982            .copied()
983            .unwrap_or(self.providers[0])
984    }
985
986    #[must_use]
987    pub fn has_next(&self) -> bool {
988        self.position + 1 < self.providers.len()
989    }
990
991    pub fn advance(&mut self) -> Option<ProviderKind> {
992        if !self.has_next() {
993            return None;
994        }
995        self.position += 1;
996        Some(self.current())
997    }
998
999    pub fn reset(&mut self) {
1000        self.position = 0;
1001    }
1002
1003    #[must_use]
1004    pub fn is_fallback_active(&self) -> bool {
1005        self.position > 0
1006    }
1007
1008    /// Count the current provider plus untried chain entries.
1009    #[must_use]
1010    pub fn remaining(&self) -> usize {
1011        self.providers.len() - self.position
1012    }
1013}
1014
1015/// On-disk schema for the `[hook_sinks]` table.
1016#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1017pub struct HookSinksToml {
1018    /// Unix domain socket path used by the app-server event sink.
1019    ///
1020    /// When unset, no Unix socket sink is registered. There is deliberately no
1021    /// shared `/tmp` default because socket ownership should be explicit.
1022    #[serde(default)]
1023    pub unix_socket_path: Option<PathBuf>,
1024}
1025
1026/// On-disk schema for the `[skills]` table (#140). See `config.example.toml`
1027/// for documentation.
1028#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1029pub struct SkillsToml {
1030    /// Curated registry index URL. When unset, the TUI falls back to the
1031    /// bundled default (community-curated GitHub raw).
1032    #[serde(default)]
1033    pub registry_url: Option<String>,
1034    /// Per-skill maximum *uncompressed* size in bytes. When unset, the TUI
1035    /// uses 5 MiB.
1036    #[serde(default)]
1037    pub max_install_size_bytes: Option<u64>,
1038}
1039
1040/// On-disk schema for the `[tools]` table (#2076).
1041#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1042pub struct ToolsToml {
1043    /// Native tool names to keep loaded outside the default core catalog.
1044    #[serde(default)]
1045    pub always_load: Vec<String>,
1046}
1047
1048/// On-disk schema for the `[snapshots]` table (#137). See
1049/// `config.example.toml` for documentation.
1050#[derive(Debug, Clone, Serialize, Deserialize)]
1051pub struct SnapshotsToml {
1052    #[serde(default = "default_snapshots_enabled")]
1053    pub enabled: bool,
1054    #[serde(default = "default_snapshot_max_age_days")]
1055    pub max_age_days: u64,
1056}
1057
1058fn default_snapshots_enabled() -> bool {
1059    true
1060}
1061
1062fn default_snapshot_max_age_days() -> u64 {
1063    7
1064}
1065
1066impl Default for SnapshotsToml {
1067    fn default() -> Self {
1068        Self {
1069            enabled: default_snapshots_enabled(),
1070            max_age_days: default_snapshot_max_age_days(),
1071        }
1072    }
1073}
1074
1075/// On-disk schema for the `[fleet]` table (#3165). See `config.example.toml`
1076/// and `docs/FLEET.md` for documentation.
1077#[derive(Debug, Clone, Serialize, Deserialize)]
1078pub struct FleetConfigToml {
1079    /// Default trust level for fleet workers. One of `"sandbox"`, `"local"`,
1080    /// `"remote-verified"`, or `"operator"`. Defaults to `"sandbox"`.
1081    #[serde(default = "default_fleet_trust_level_str")]
1082    pub default_trust_level: String,
1083    /// Require identity verification for remote (SSH) workers before
1084    /// granting them `remote-verified` trust. Defaults to true.
1085    #[serde(default = "default_fleet_require_identity")]
1086    pub require_identity_verification: bool,
1087    /// Maximum trust level any worker may have (`"sandbox"`, `"local"`,
1088    /// `"remote-verified"`, or `"operator"`). Defaults to `"operator"`.
1089    #[serde(default = "default_fleet_max_trust_level_str")]
1090    pub max_trust_level: String,
1091    /// User-defined and built-in role presets.
1092    ///
1093    /// Each role defines default tool profiles, capabilities, budgets, and
1094    /// trust settings that task specs can reference by name. Built-in roles
1095    /// (`smoke-runner`, `reviewer`, `builder`, `read-only`) are always
1096    /// available; user-defined roles in config override or extend them.
1097    #[serde(default)]
1098    pub roles: BTreeMap<String, FleetRolePreset>,
1099    /// Headless worker execution hardening (#3027).
1100    #[serde(default)]
1101    pub exec: FleetExecConfig,
1102}
1103
1104/// Canonical recursion-depth policy for the headless worker runtime.
1105///
1106/// Single source of truth shared by BOTH standalone sub-agents and fleet
1107/// workers so the two cannot drift into "two moving targets":
1108/// - [`DEFAULT_SPAWN_DEPTH`] is the default recursion budget (the sub-agent
1109///   runtime's `DEFAULT_MAX_SPAWN_DEPTH` is defined as this value).
1110/// - [`MAX_SPAWN_DEPTH_CEILING`] is the hard safety cap; every configured
1111///   value (fleet `max_spawn_depth`, the `agent` tool's `max_depth`) clamps to it.
1112///
1113/// A worker runs at `spawn_depth = 0` and may spawn while
1114/// `spawn_depth + 1 <= max_spawn_depth`, so a depth of N affords N nested
1115/// delegation levels below the root worker. The default of 3 affords at least
1116/// three recursion levels out of the box; the root worker still runs at
1117/// depth 0 even when the budget is 0.
1118pub const DEFAULT_SPAWN_DEPTH: u32 = 3;
1119
1120/// Hard ceiling on recursion depth for any worker/sub-agent. See
1121/// [`DEFAULT_SPAWN_DEPTH`]. Raising this single constant lifts the limit
1122/// everywhere (the fleet clamp and `agent` validation both read it).
1123pub const MAX_SPAWN_DEPTH_CEILING: u32 = 3;
1124
1125/// Headless worker execution constraints (#3027).
1126///
1127/// These limits apply to all fleet workers and sub-agents spawned through
1128/// the headless worker runtime. Task specs can tighten but not loosen them.
1129#[derive(Debug, Clone, Serialize, Deserialize)]
1130pub struct FleetExecConfig {
1131    /// Tools that are always allowed regardless of role or task spec.
1132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1133    pub allowed_tools: Vec<String>,
1134    /// Tools that are always disallowed, overriding role and task spec.
1135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1136    pub disallowed_tools: Vec<String>,
1137    /// Hard ceiling on sub-agent steps (tool calls + model turns).
1138    /// Workers that exceed this are terminated. Default: unbounded (u32::MAX).
1139    #[serde(default = "default_fleet_max_turns")]
1140    pub max_turns: u32,
1141    /// Recursive child-agent budget for headless fleet workers.
1142    /// Defaults to [`DEFAULT_SPAWN_DEPTH`] (3) so a fleet worker has the SAME
1143    /// recursion budget as a standalone sub-agent — fleet and sub-agents are one
1144    /// substrate, not two. Set 0 to block child `agent` calls (the root worker
1145    /// still runs); the value is clamped to [`MAX_SPAWN_DEPTH_CEILING`].
1146    #[serde(default = "default_fleet_max_spawn_depth")]
1147    pub max_spawn_depth: u32,
1148    /// Extra system prompt text appended to every headless worker.
1149    /// Useful for injecting org-wide policy or behavior constraints.
1150    #[serde(default, skip_serializing_if = "String::is_empty")]
1151    pub append_system_prompt: String,
1152    /// Output format for fleet worker results.
1153    /// `"text"` (default) or `"stream-json"` for newline-delimited JSON events.
1154    #[serde(default = "default_fleet_output_format")]
1155    pub output_format: String,
1156}
1157
1158fn default_fleet_max_turns() -> u32 {
1159    u32::MAX
1160}
1161
1162fn default_fleet_max_spawn_depth() -> u32 {
1163    DEFAULT_SPAWN_DEPTH
1164}
1165
1166fn default_fleet_output_format() -> String {
1167    "text".to_string()
1168}
1169
1170impl Default for FleetExecConfig {
1171    fn default() -> Self {
1172        Self {
1173            allowed_tools: Vec::new(),
1174            disallowed_tools: Vec::new(),
1175            max_turns: default_fleet_max_turns(),
1176            max_spawn_depth: default_fleet_max_spawn_depth(),
1177            append_system_prompt: String::new(),
1178            output_format: default_fleet_output_format(),
1179        }
1180    }
1181}
1182
1183/// A named role preset that bundles common worker settings.
1184///
1185/// Task specs reference a role name (e.g. `"role": "reviewer"`), and the
1186/// fleet manager fills in any missing fields from the preset. User-defined
1187/// roles in `[fleet.roles]` override built-in defaults with the same name.
1188///
1189/// Token budgets and tool-call limits are task-level decisions — they don't
1190/// belong on role presets. Use `timeout_seconds` as the safety bound.
1191#[derive(Debug, Clone, Serialize, Deserialize)]
1192pub struct FleetRolePreset {
1193    /// Short description of what this role is for.
1194    #[serde(skip_serializing_if = "Option::is_none")]
1195    pub description: Option<String>,
1196    /// Default tool profile (`"read-only"`, `"read-write"`, or `"custom"`).
1197    #[serde(skip_serializing_if = "Option::is_none")]
1198    pub tool_profile: Option<String>,
1199    /// Default set of tool names available to this role.
1200    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1201    pub tools: Vec<String>,
1202    /// Default capability tags (e.g. `"rust"`, `"git"`, `"gh"`).
1203    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1204    pub capabilities: Vec<String>,
1205    /// Default timeout in seconds for tasks using this role.
1206    #[serde(skip_serializing_if = "Option::is_none")]
1207    pub timeout_seconds: Option<u64>,
1208    /// Default trust level override for this role.
1209    #[serde(skip_serializing_if = "Option::is_none")]
1210    pub trust_level: Option<String>,
1211}
1212
1213fn default_fleet_trust_level_str() -> String {
1214    "sandbox".to_string()
1215}
1216
1217fn default_fleet_require_identity() -> bool {
1218    true
1219}
1220
1221fn default_fleet_max_trust_level_str() -> String {
1222    "operator".to_string()
1223}
1224
1225impl Default for FleetConfigToml {
1226    fn default() -> Self {
1227        Self {
1228            default_trust_level: default_fleet_trust_level_str(),
1229            require_identity_verification: default_fleet_require_identity(),
1230            max_trust_level: default_fleet_max_trust_level_str(),
1231            roles: BTreeMap::new(),
1232            exec: FleetExecConfig::default(),
1233        }
1234    }
1235}
1236
1237impl FleetConfigToml {
1238    /// Resolve a role preset by name. Checks user-defined roles first,
1239    /// then falls back to built-in role defaults.
1240    #[must_use]
1241    pub fn resolve_role(&self, name: &str) -> Option<FleetRolePreset> {
1242        self.roles
1243            .get(name)
1244            .cloned()
1245            .or_else(|| built_in_role_presets().get(name).cloned())
1246    }
1247}
1248
1249/// Built-in role presets that are always available without config.
1250#[must_use]
1251pub fn built_in_role_presets() -> BTreeMap<String, FleetRolePreset> {
1252    [
1253        (
1254            "smoke-runner".to_string(),
1255            FleetRolePreset {
1256                description: Some("Lightweight read-only smoke check worker".to_string()),
1257                tool_profile: Some("read-only".to_string()),
1258                tools: vec![],
1259                capabilities: vec![],
1260                timeout_seconds: Some(300),
1261                trust_level: Some("local".to_string()),
1262            },
1263        ),
1264        (
1265            "reviewer".to_string(),
1266            FleetRolePreset {
1267                description: Some("Read-only code and documentation review".to_string()),
1268                tool_profile: Some("read-only".to_string()),
1269                tools: vec![],
1270                capabilities: vec![],
1271                timeout_seconds: Some(600),
1272                trust_level: None,
1273            },
1274        ),
1275        (
1276            "builder".to_string(),
1277            FleetRolePreset {
1278                description: Some(
1279                    "Read-write builder with compilation and test access".to_string(),
1280                ),
1281                tool_profile: Some("read-write".to_string()),
1282                tools: vec![],
1283                capabilities: vec![],
1284                timeout_seconds: Some(1800),
1285                trust_level: Some("local".to_string()),
1286            },
1287        ),
1288        (
1289            "read-only".to_string(),
1290            FleetRolePreset {
1291                description: Some(
1292                    "Minimal read-only observer with no writes or secrets".to_string(),
1293                ),
1294                tool_profile: Some("read-only".to_string()),
1295                tools: vec![],
1296                capabilities: vec![],
1297                timeout_seconds: Some(300),
1298                trust_level: Some("sandbox".to_string()),
1299            },
1300        ),
1301    ]
1302    .into()
1303}
1304
1305/// On-disk schema for the `[network]` table (#135). See `config.example.toml`
1306/// for documentation.
1307#[derive(Debug, Clone, Serialize, Deserialize)]
1308pub struct NetworkPolicyToml {
1309    /// Decision for hosts that are not in `allow` or `deny`. One of
1310    /// `"allow" | "deny" | "prompt"`. Defaults to `"prompt"`.
1311    #[serde(default = "default_network_decision")]
1312    pub default: String,
1313    /// Hosts that are always allowed. Subdomain rules: a leading dot
1314    /// (`.example.com`) matches subdomains but not the apex.
1315    #[serde(default)]
1316    pub allow: Vec<String>,
1317    /// Hosts that are always denied. Deny entries win over allow entries.
1318    #[serde(default)]
1319    pub deny: Vec<String>,
1320    /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an
1321    /// explicitly trusted proxy setup. Literal IP URLs remain blocked.
1322    #[serde(default)]
1323    pub proxy: Vec<String>,
1324    /// Whether to record one audit-log line per outbound network call.
1325    #[serde(default = "default_network_audit")]
1326    pub audit: bool,
1327}
1328
1329fn default_network_decision() -> String {
1330    "prompt".to_string()
1331}
1332
1333fn default_network_audit() -> bool {
1334    true
1335}
1336
1337impl Default for NetworkPolicyToml {
1338    fn default() -> Self {
1339        Self {
1340            default: default_network_decision(),
1341            allow: Vec::new(),
1342            deny: Vec::new(),
1343            proxy: Vec::new(),
1344            audit: default_network_audit(),
1345        }
1346    }
1347}
1348
1349/// On-disk schema for the `[lsp]` table (#136). See `config.example.toml`
1350/// for documentation. All fields are optional so the TUI runtime can fall
1351/// back to its own defaults when keys are absent.
1352#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1353pub struct LspConfigToml {
1354    /// Master switch.
1355    pub enabled: Option<bool>,
1356    /// Maximum time to wait for diagnostics after an edit, in milliseconds.
1357    pub poll_after_edit_ms: Option<u64>,
1358    /// Cap on diagnostics surfaced per file.
1359    pub max_diagnostics_per_file: Option<usize>,
1360    /// When `true`, warnings (severity 2) are surfaced in addition to errors.
1361    pub include_warnings: Option<bool>,
1362    /// Optional override for the `language -> [cmd, ...args]` table.
1363    pub servers: Option<BTreeMap<String, Vec<String>>>,
1364}
1365
1366impl ConfigToml {
1367    /// Merge safe project-level overrides from `$WORKSPACE/.codewhale/config.toml`
1368    /// or legacy `$WORKSPACE/.deepseek/config.toml`.
1369    ///
1370    /// Repo-local config is untrusted input. This helper intentionally ignores
1371    /// credentials, endpoints, provider selection, auth/session values, telemetry,
1372    /// network policy, skill registry, LSP command tables, and unknown extras.
1373    /// Approval and sandbox values may only tighten the existing user/global
1374    /// posture.
1375    pub fn merge_project_overrides(&mut self, project: ConfigToml) {
1376        if project.default_text_model.is_some() {
1377            self.default_text_model = project.default_text_model;
1378        }
1379        if project.model.is_some() {
1380            self.model = project.model;
1381        }
1382        if project.output_mode.is_some() {
1383            self.output_mode = project.output_mode;
1384        }
1385        if project.verbosity.is_some() {
1386            self.verbosity = project.verbosity;
1387        }
1388        if project.log_level.is_some() {
1389            self.log_level = project.log_level;
1390        }
1391        if let Some(policy) = project.approval_policy
1392            && project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy)
1393        {
1394            self.approval_policy = Some(policy);
1395        }
1396        if let Some(mode) = project.sandbox_mode
1397            && project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode)
1398        {
1399            self.sandbox_mode = Some(mode);
1400        }
1401        if project.tools.is_some() {
1402            self.tools = project.tools;
1403        }
1404        merge_project_provider_config(&mut self.providers.deepseek, &project.providers.deepseek);
1405        merge_project_provider_config(
1406            &mut self.providers.nvidia_nim,
1407            &project.providers.nvidia_nim,
1408        );
1409        merge_project_provider_config(&mut self.providers.openai, &project.providers.openai);
1410        merge_project_provider_config(
1411            &mut self.providers.atlascloud,
1412            &project.providers.atlascloud,
1413        );
1414        merge_project_provider_config(
1415            &mut self.providers.wanjie_ark,
1416            &project.providers.wanjie_ark,
1417        );
1418        merge_project_provider_config(
1419            &mut self.providers.volcengine,
1420            &project.providers.volcengine,
1421        );
1422        merge_project_provider_config(
1423            &mut self.providers.openrouter,
1424            &project.providers.openrouter,
1425        );
1426        merge_project_provider_config(
1427            &mut self.providers.xiaomi_mimo,
1428            &project.providers.xiaomi_mimo,
1429        );
1430        merge_project_provider_config(&mut self.providers.novita, &project.providers.novita);
1431        merge_project_provider_config(&mut self.providers.fireworks, &project.providers.fireworks);
1432        merge_project_provider_config(
1433            &mut self.providers.siliconflow,
1434            &project.providers.siliconflow,
1435        );
1436        merge_project_provider_config(
1437            &mut self.providers.siliconflow_cn,
1438            &project.providers.siliconflow_cn,
1439        );
1440        merge_project_provider_config(&mut self.providers.arcee, &project.providers.arcee);
1441        merge_project_provider_config(&mut self.providers.moonshot, &project.providers.moonshot);
1442        merge_project_provider_config(&mut self.providers.sglang, &project.providers.sglang);
1443        merge_project_provider_config(&mut self.providers.vllm, &project.providers.vllm);
1444        merge_project_provider_config(&mut self.providers.ollama, &project.providers.ollama);
1445        merge_project_provider_config(
1446            &mut self.providers.huggingface,
1447            &project.providers.huggingface,
1448        );
1449    }
1450
1451    #[must_use]
1452    pub fn get_value(&self, key: &str) -> Option<String> {
1453        match key {
1454            "provider" => Some(self.provider.as_str().to_string()),
1455            "api_key" => self.api_key.clone(),
1456            "base_url" => self.base_url.clone(),
1457            "http_headers" => serialize_http_headers(&self.http_headers),
1458            "default_text_model" => self.default_text_model.clone(),
1459            "model" => self.model.clone(),
1460            "auth.mode" => self.auth_mode.clone(),
1461            "output_mode" => self.output_mode.clone(),
1462            "verbosity" => self.verbosity.clone(),
1463            "log_level" => self.log_level.clone(),
1464            "telemetry" => self.telemetry.map(|v| v.to_string()),
1465            "approval_policy" => self.approval_policy.clone(),
1466            "sandbox_mode" => self.sandbox_mode.clone(),
1467            "tools.always_load" => self.tools.as_ref().map(|tools| tools.always_load.join(",")),
1468            "hook_sinks.unix_socket_path" => self
1469                .hook_sinks
1470                .as_ref()
1471                .and_then(|sinks| sinks.unix_socket_path.as_ref())
1472                .map(|path| path.display().to_string()),
1473            "providers.deepseek.api_key" => self.providers.deepseek.api_key.clone(),
1474            "providers.deepseek.base_url" => self.providers.deepseek.base_url.clone(),
1475            "providers.deepseek.model" => self.providers.deepseek.model.clone(),
1476            "providers.deepseek.http_headers" => {
1477                serialize_http_headers(&self.providers.deepseek.http_headers)
1478            }
1479            "providers.nvidia_nim.api_key" => self.providers.nvidia_nim.api_key.clone(),
1480            "providers.nvidia_nim.base_url" => self.providers.nvidia_nim.base_url.clone(),
1481            "providers.nvidia_nim.model" => self.providers.nvidia_nim.model.clone(),
1482            "providers.nvidia_nim.http_headers" => {
1483                serialize_http_headers(&self.providers.nvidia_nim.http_headers)
1484            }
1485            "providers.openai.api_key" => self.providers.openai.api_key.clone(),
1486            "providers.openai.base_url" => self.providers.openai.base_url.clone(),
1487            "providers.openai.model" => self.providers.openai.model.clone(),
1488            "providers.openai.http_headers" => {
1489                serialize_http_headers(&self.providers.openai.http_headers)
1490            }
1491            "providers.atlascloud.api_key" => self.providers.atlascloud.api_key.clone(),
1492            "providers.atlascloud.base_url" => self.providers.atlascloud.base_url.clone(),
1493            "providers.atlascloud.model" => self.providers.atlascloud.model.clone(),
1494            "providers.atlascloud.http_headers" => {
1495                serialize_http_headers(&self.providers.atlascloud.http_headers)
1496            }
1497            "providers.wanjie_ark.api_key" => self.providers.wanjie_ark.api_key.clone(),
1498            "providers.wanjie_ark.base_url" => self.providers.wanjie_ark.base_url.clone(),
1499            "providers.wanjie_ark.model" => self.providers.wanjie_ark.model.clone(),
1500            "providers.volcengine.api_key" => self.providers.volcengine.api_key.clone(),
1501            "providers.volcengine.base_url" => self.providers.volcengine.base_url.clone(),
1502            "providers.volcengine.model" => self.providers.volcengine.model.clone(),
1503            "providers.volcengine.http_headers" => {
1504                serialize_http_headers(&self.providers.volcengine.http_headers)
1505            }
1506            "providers.wanjie_ark.http_headers" => {
1507                serialize_http_headers(&self.providers.wanjie_ark.http_headers)
1508            }
1509            "providers.openrouter.api_key" => self.providers.openrouter.api_key.clone(),
1510            "providers.openrouter.base_url" => self.providers.openrouter.base_url.clone(),
1511            "providers.openrouter.model" => self.providers.openrouter.model.clone(),
1512            "providers.openrouter.http_headers" => {
1513                serialize_http_headers(&self.providers.openrouter.http_headers)
1514            }
1515            "providers.xiaomi_mimo.api_key" => self.providers.xiaomi_mimo.api_key.clone(),
1516            "providers.xiaomi_mimo.base_url" => self.providers.xiaomi_mimo.base_url.clone(),
1517            "providers.xiaomi_mimo.model" => self.providers.xiaomi_mimo.model.clone(),
1518            "providers.xiaomi_mimo.mode" => self.providers.xiaomi_mimo.mode.clone(),
1519            "providers.xiaomi_mimo.http_headers" => {
1520                serialize_http_headers(&self.providers.xiaomi_mimo.http_headers)
1521            }
1522            "providers.novita.api_key" => self.providers.novita.api_key.clone(),
1523            "providers.novita.base_url" => self.providers.novita.base_url.clone(),
1524            "providers.novita.model" => self.providers.novita.model.clone(),
1525            "providers.novita.http_headers" => {
1526                serialize_http_headers(&self.providers.novita.http_headers)
1527            }
1528            "providers.fireworks.api_key" => self.providers.fireworks.api_key.clone(),
1529            "providers.fireworks.base_url" => self.providers.fireworks.base_url.clone(),
1530            "providers.fireworks.model" => self.providers.fireworks.model.clone(),
1531            "providers.fireworks.http_headers" => {
1532                serialize_http_headers(&self.providers.fireworks.http_headers)
1533            }
1534            "providers.siliconflow.api_key" => self.providers.siliconflow.api_key.clone(),
1535            "providers.siliconflow.base_url" => self.providers.siliconflow.base_url.clone(),
1536            "providers.siliconflow.model" => self.providers.siliconflow.model.clone(),
1537            "providers.siliconflow.http_headers" => {
1538                serialize_http_headers(&self.providers.siliconflow.http_headers)
1539            }
1540            "providers.siliconflow_cn.api_key" => self.providers.siliconflow_cn.api_key.clone(),
1541            "providers.siliconflow_cn.base_url" => self.providers.siliconflow_cn.base_url.clone(),
1542            "providers.siliconflow_cn.model" => self.providers.siliconflow_cn.model.clone(),
1543            "providers.siliconflow_cn.http_headers" => {
1544                serialize_http_headers(&self.providers.siliconflow_cn.http_headers)
1545            }
1546            "providers.arcee.api_key" => self.providers.arcee.api_key.clone(),
1547            "providers.arcee.base_url" => self.providers.arcee.base_url.clone(),
1548            "providers.arcee.model" => self.providers.arcee.model.clone(),
1549            "providers.arcee.http_headers" => {
1550                serialize_http_headers(&self.providers.arcee.http_headers)
1551            }
1552            "providers.moonshot.api_key" => self.providers.moonshot.api_key.clone(),
1553            "providers.moonshot.base_url" => self.providers.moonshot.base_url.clone(),
1554            "providers.moonshot.model" => self.providers.moonshot.model.clone(),
1555            "providers.moonshot.auth_mode" => self.providers.moonshot.auth_mode.clone(),
1556            "providers.moonshot.http_headers" => {
1557                serialize_http_headers(&self.providers.moonshot.http_headers)
1558            }
1559            "providers.sglang.api_key" => self.providers.sglang.api_key.clone(),
1560            "providers.sglang.base_url" => self.providers.sglang.base_url.clone(),
1561            "providers.sglang.model" => self.providers.sglang.model.clone(),
1562            "providers.sglang.http_headers" => {
1563                serialize_http_headers(&self.providers.sglang.http_headers)
1564            }
1565            "providers.vllm.api_key" => self.providers.vllm.api_key.clone(),
1566            "providers.vllm.base_url" => self.providers.vllm.base_url.clone(),
1567            "providers.vllm.model" => self.providers.vllm.model.clone(),
1568            "providers.vllm.http_headers" => {
1569                serialize_http_headers(&self.providers.vllm.http_headers)
1570            }
1571            "providers.ollama.api_key" => self.providers.ollama.api_key.clone(),
1572            "providers.ollama.base_url" => self.providers.ollama.base_url.clone(),
1573            "providers.ollama.model" => self.providers.ollama.model.clone(),
1574            "providers.ollama.http_headers" => {
1575                serialize_http_headers(&self.providers.ollama.http_headers)
1576            }
1577            "providers.huggingface.api_key" => self.providers.huggingface.api_key.clone(),
1578            "providers.huggingface.base_url" => self.providers.huggingface.base_url.clone(),
1579            "providers.huggingface.model" => self.providers.huggingface.model.clone(),
1580            "providers.huggingface.http_headers" => {
1581                serialize_http_headers(&self.providers.huggingface.http_headers)
1582            }
1583            "providers.together.api_key" => self.providers.together.api_key.clone(),
1584            "providers.together.base_url" => self.providers.together.base_url.clone(),
1585            "providers.together.model" => self.providers.together.model.clone(),
1586            "providers.together.http_headers" => {
1587                serialize_http_headers(&self.providers.together.http_headers)
1588            }
1589            _ => self.extras.get(key).map(toml::Value::to_string),
1590        }
1591    }
1592
1593    #[must_use]
1594    pub fn get_display_value(&self, key: &str) -> Option<String> {
1595        self.get_value(key).map(|value| {
1596            if is_sensitive_config_key(key) {
1597                redact_secret(&value)
1598            } else {
1599                value
1600            }
1601        })
1602    }
1603
1604    pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
1605        match key {
1606            "provider" => {
1607                self.provider = ProviderKind::parse(value).with_context(|| {
1608                    format!(
1609                        "unknown provider '{value}': expected {}",
1610                        ProviderKind::names_hint()
1611                    )
1612                })?;
1613            }
1614            "api_key" => self.api_key = Some(value.to_string()),
1615            "base_url" => self.base_url = Some(value.to_string()),
1616            "http_headers" => self.http_headers = parse_http_headers(value)?,
1617            "default_text_model" => self.default_text_model = Some(value.to_string()),
1618            "model" => self.model = Some(value.to_string()),
1619            "auth.mode" => self.auth_mode = Some(value.to_string()),
1620            "output_mode" => self.output_mode = Some(value.to_string()),
1621            "verbosity" => self.verbosity = Some(value.to_string()),
1622            "log_level" => self.log_level = Some(value.to_string()),
1623            "telemetry" => {
1624                self.telemetry = Some(parse_bool(value)?);
1625            }
1626            "approval_policy" => self.approval_policy = Some(value.to_string()),
1627            "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
1628            "hook_sinks.unix_socket_path" => {
1629                self.hook_sinks
1630                    .get_or_insert_with(HookSinksToml::default)
1631                    .unix_socket_path = Some(PathBuf::from(value));
1632            }
1633            "providers.deepseek.api_key" => {
1634                let value = value.to_string();
1635                self.providers.deepseek.api_key = Some(value.clone());
1636                self.api_key = Some(value);
1637            }
1638            "providers.deepseek.base_url" => {
1639                let value = value.to_string();
1640                self.providers.deepseek.base_url = Some(value.clone());
1641                self.base_url = Some(value);
1642            }
1643            "providers.deepseek.model" => {
1644                let value = value.to_string();
1645                self.providers.deepseek.model = Some(value.clone());
1646                self.default_text_model = Some(value);
1647            }
1648            "providers.deepseek.http_headers" => {
1649                let headers = parse_http_headers(value)?;
1650                self.providers.deepseek.http_headers = headers.clone();
1651                self.http_headers = headers;
1652            }
1653            "providers.openai.api_key" => self.providers.openai.api_key = Some(value.to_string()),
1654            "providers.openai.base_url" => self.providers.openai.base_url = Some(value.to_string()),
1655            "providers.openai.model" => self.providers.openai.model = Some(value.to_string()),
1656            "providers.openai.http_headers" => {
1657                self.providers.openai.http_headers = parse_http_headers(value)?;
1658            }
1659            "providers.atlascloud.api_key" => {
1660                self.providers.atlascloud.api_key = Some(value.to_string());
1661            }
1662            "providers.atlascloud.base_url" => {
1663                self.providers.atlascloud.base_url = Some(value.to_string());
1664            }
1665            "providers.atlascloud.model" => {
1666                self.providers.atlascloud.model = Some(value.to_string());
1667            }
1668            "providers.atlascloud.http_headers" => {
1669                self.providers.atlascloud.http_headers = parse_http_headers(value)?;
1670            }
1671            "providers.wanjie_ark.api_key" => {
1672                self.providers.wanjie_ark.api_key = Some(value.to_string());
1673            }
1674            "providers.wanjie_ark.base_url" => {
1675                self.providers.wanjie_ark.base_url = Some(value.to_string());
1676            }
1677            "providers.wanjie_ark.model" => {
1678                self.providers.wanjie_ark.model = Some(value.to_string());
1679            }
1680            "providers.volcengine.api_key" => {
1681                self.providers.volcengine.api_key = Some(value.to_string());
1682            }
1683            "providers.volcengine.base_url" => {
1684                self.providers.volcengine.base_url = Some(value.to_string());
1685            }
1686            "providers.volcengine.model" => {
1687                self.providers.volcengine.model = Some(value.to_string());
1688            }
1689            "providers.volcengine.http_headers" => {
1690                self.providers.volcengine.http_headers = parse_http_headers(value)?;
1691            }
1692            "providers.wanjie_ark.http_headers" => {
1693                self.providers.wanjie_ark.http_headers = parse_http_headers(value)?;
1694            }
1695            "providers.nvidia_nim.api_key" => {
1696                self.providers.nvidia_nim.api_key = Some(value.to_string());
1697            }
1698            "providers.nvidia_nim.base_url" => {
1699                self.providers.nvidia_nim.base_url = Some(value.to_string());
1700            }
1701            "providers.nvidia_nim.model" => {
1702                self.providers.nvidia_nim.model = Some(value.to_string());
1703            }
1704            "providers.nvidia_nim.http_headers" => {
1705                self.providers.nvidia_nim.http_headers = parse_http_headers(value)?;
1706            }
1707            "providers.openrouter.api_key" => {
1708                self.providers.openrouter.api_key = Some(value.to_string());
1709            }
1710            "providers.openrouter.base_url" => {
1711                self.providers.openrouter.base_url = Some(value.to_string());
1712            }
1713            "providers.openrouter.model" => {
1714                self.providers.openrouter.model = Some(value.to_string());
1715            }
1716            "providers.openrouter.http_headers" => {
1717                self.providers.openrouter.http_headers = parse_http_headers(value)?;
1718            }
1719            "providers.xiaomi_mimo.api_key" => {
1720                self.providers.xiaomi_mimo.api_key = Some(value.to_string());
1721            }
1722            "providers.xiaomi_mimo.base_url" => {
1723                self.providers.xiaomi_mimo.base_url = Some(value.to_string());
1724            }
1725            "providers.xiaomi_mimo.model" => {
1726                self.providers.xiaomi_mimo.model = Some(value.to_string());
1727            }
1728            "providers.xiaomi_mimo.mode" => {
1729                self.providers.xiaomi_mimo.mode = Some(value.to_string());
1730            }
1731            "providers.xiaomi_mimo.http_headers" => {
1732                self.providers.xiaomi_mimo.http_headers = parse_http_headers(value)?;
1733            }
1734            "providers.novita.api_key" => {
1735                self.providers.novita.api_key = Some(value.to_string());
1736            }
1737            "providers.novita.base_url" => {
1738                self.providers.novita.base_url = Some(value.to_string());
1739            }
1740            "providers.novita.model" => {
1741                self.providers.novita.model = Some(value.to_string());
1742            }
1743            "providers.novita.http_headers" => {
1744                self.providers.novita.http_headers = parse_http_headers(value)?;
1745            }
1746            "providers.fireworks.api_key" => {
1747                self.providers.fireworks.api_key = Some(value.to_string());
1748            }
1749            "providers.fireworks.base_url" => {
1750                self.providers.fireworks.base_url = Some(value.to_string());
1751            }
1752            "providers.fireworks.model" => {
1753                self.providers.fireworks.model = Some(value.to_string());
1754            }
1755            "providers.fireworks.http_headers" => {
1756                self.providers.fireworks.http_headers = parse_http_headers(value)?;
1757            }
1758            "providers.siliconflow.api_key" => {
1759                self.providers.siliconflow.api_key = Some(value.to_string());
1760            }
1761            "providers.siliconflow.base_url" => {
1762                self.providers.siliconflow.base_url = Some(value.to_string());
1763            }
1764            "providers.siliconflow.model" => {
1765                self.providers.siliconflow.model = Some(value.to_string());
1766            }
1767            "providers.siliconflow.http_headers" => {
1768                self.providers.siliconflow.http_headers = parse_http_headers(value)?;
1769            }
1770            "providers.siliconflow_cn.api_key" => {
1771                self.providers.siliconflow_cn.api_key = Some(value.to_string());
1772            }
1773            "providers.siliconflow_cn.base_url" => {
1774                self.providers.siliconflow_cn.base_url = Some(value.to_string());
1775            }
1776            "providers.siliconflow_cn.model" => {
1777                self.providers.siliconflow_cn.model = Some(value.to_string());
1778            }
1779            "providers.siliconflow_cn.http_headers" => {
1780                self.providers.siliconflow_cn.http_headers = parse_http_headers(value)?;
1781            }
1782            "providers.arcee.api_key" => {
1783                self.providers.arcee.api_key = Some(value.to_string());
1784            }
1785            "providers.arcee.base_url" => {
1786                self.providers.arcee.base_url = Some(value.to_string());
1787            }
1788            "providers.arcee.model" => {
1789                self.providers.arcee.model = Some(value.to_string());
1790            }
1791            "providers.arcee.http_headers" => {
1792                self.providers.arcee.http_headers = parse_http_headers(value)?;
1793            }
1794            "providers.moonshot.api_key" => {
1795                self.providers.moonshot.api_key = Some(value.to_string());
1796            }
1797            "providers.moonshot.base_url" => {
1798                self.providers.moonshot.base_url = Some(value.to_string());
1799            }
1800            "providers.moonshot.model" => {
1801                self.providers.moonshot.model = Some(value.to_string());
1802            }
1803            "providers.moonshot.auth_mode" => {
1804                self.providers.moonshot.auth_mode = Some(value.to_string());
1805            }
1806            "providers.moonshot.http_headers" => {
1807                self.providers.moonshot.http_headers = parse_http_headers(value)?;
1808            }
1809            "providers.sglang.api_key" => {
1810                self.providers.sglang.api_key = Some(value.to_string());
1811            }
1812            "providers.sglang.base_url" => {
1813                self.providers.sglang.base_url = Some(value.to_string());
1814            }
1815            "providers.sglang.model" => {
1816                self.providers.sglang.model = Some(value.to_string());
1817            }
1818            "providers.sglang.http_headers" => {
1819                self.providers.sglang.http_headers = parse_http_headers(value)?;
1820            }
1821            "providers.vllm.api_key" => {
1822                self.providers.vllm.api_key = Some(value.to_string());
1823            }
1824            "providers.vllm.base_url" => {
1825                self.providers.vllm.base_url = Some(value.to_string());
1826            }
1827            "providers.vllm.model" => {
1828                self.providers.vllm.model = Some(value.to_string());
1829            }
1830            "providers.vllm.http_headers" => {
1831                self.providers.vllm.http_headers = parse_http_headers(value)?;
1832            }
1833            "providers.ollama.api_key" => {
1834                self.providers.ollama.api_key = Some(value.to_string());
1835            }
1836            "providers.ollama.base_url" => {
1837                self.providers.ollama.base_url = Some(value.to_string());
1838            }
1839            "providers.ollama.model" => {
1840                self.providers.ollama.model = Some(value.to_string());
1841            }
1842            "providers.ollama.http_headers" => {
1843                self.providers.ollama.http_headers = parse_http_headers(value)?;
1844            }
1845            "providers.huggingface.api_key" => {
1846                self.providers.huggingface.api_key = Some(value.to_string());
1847            }
1848            "providers.huggingface.base_url" => {
1849                self.providers.huggingface.base_url = Some(value.to_string());
1850            }
1851            "providers.huggingface.model" => {
1852                self.providers.huggingface.model = Some(value.to_string());
1853            }
1854            "providers.huggingface.http_headers" => {
1855                self.providers.huggingface.http_headers = parse_http_headers(value)?;
1856            }
1857            "providers.together.api_key" => {
1858                self.providers.together.api_key = Some(value.to_string());
1859            }
1860            "providers.together.base_url" => {
1861                self.providers.together.base_url = Some(value.to_string());
1862            }
1863            "providers.together.model" => {
1864                self.providers.together.model = Some(value.to_string());
1865            }
1866            "providers.together.http_headers" => {
1867                self.providers.together.http_headers = parse_http_headers(value)?;
1868            }
1869            _ => {
1870                self.extras
1871                    .insert(key.to_string(), toml::Value::String(value.to_string()));
1872            }
1873        }
1874        Ok(())
1875    }
1876
1877    pub fn unset_value(&mut self, key: &str) -> Result<()> {
1878        match key {
1879            "provider" => self.provider = ProviderKind::Deepseek,
1880            "api_key" => self.api_key = None,
1881            "base_url" => self.base_url = None,
1882            "http_headers" => self.http_headers.clear(),
1883            "default_text_model" => self.default_text_model = None,
1884            "model" => self.model = None,
1885            "auth.mode" => self.auth_mode = None,
1886            "output_mode" => self.output_mode = None,
1887            "verbosity" => self.verbosity = None,
1888            "log_level" => self.log_level = None,
1889            "telemetry" => self.telemetry = None,
1890            "approval_policy" => self.approval_policy = None,
1891            "sandbox_mode" => self.sandbox_mode = None,
1892            "hook_sinks.unix_socket_path" => {
1893                if let Some(sinks) = self.hook_sinks.as_mut() {
1894                    sinks.unix_socket_path = None;
1895                }
1896            }
1897            "providers.deepseek.api_key" => {
1898                self.providers.deepseek.api_key = None;
1899                self.api_key = None;
1900            }
1901            "providers.deepseek.base_url" => {
1902                self.providers.deepseek.base_url = None;
1903                self.base_url = None;
1904            }
1905            "providers.deepseek.model" => {
1906                self.providers.deepseek.model = None;
1907                self.default_text_model = None;
1908            }
1909            "providers.deepseek.http_headers" => {
1910                self.providers.deepseek.http_headers.clear();
1911                self.http_headers.clear();
1912            }
1913            "providers.openai.api_key" => self.providers.openai.api_key = None,
1914            "providers.openai.base_url" => self.providers.openai.base_url = None,
1915            "providers.openai.model" => self.providers.openai.model = None,
1916            "providers.openai.http_headers" => self.providers.openai.http_headers.clear(),
1917            "providers.atlascloud.api_key" => self.providers.atlascloud.api_key = None,
1918            "providers.atlascloud.base_url" => self.providers.atlascloud.base_url = None,
1919            "providers.atlascloud.model" => self.providers.atlascloud.model = None,
1920            "providers.atlascloud.http_headers" => self.providers.atlascloud.http_headers.clear(),
1921            "providers.wanjie_ark.api_key" => self.providers.wanjie_ark.api_key = None,
1922            "providers.wanjie_ark.base_url" => self.providers.wanjie_ark.base_url = None,
1923            "providers.wanjie_ark.model" => self.providers.wanjie_ark.model = None,
1924            "providers.volcengine.api_key" => self.providers.volcengine.api_key = None,
1925            "providers.volcengine.base_url" => self.providers.volcengine.base_url = None,
1926            "providers.volcengine.model" => self.providers.volcengine.model = None,
1927            "providers.volcengine.http_headers" => {
1928                self.providers.volcengine.http_headers.clear();
1929            }
1930            "providers.wanjie_ark.http_headers" => {
1931                self.providers.wanjie_ark.http_headers.clear();
1932            }
1933            "providers.nvidia_nim.api_key" => self.providers.nvidia_nim.api_key = None,
1934            "providers.nvidia_nim.base_url" => self.providers.nvidia_nim.base_url = None,
1935            "providers.nvidia_nim.model" => self.providers.nvidia_nim.model = None,
1936            "providers.nvidia_nim.http_headers" => self.providers.nvidia_nim.http_headers.clear(),
1937            "providers.openrouter.api_key" => self.providers.openrouter.api_key = None,
1938            "providers.openrouter.base_url" => self.providers.openrouter.base_url = None,
1939            "providers.openrouter.model" => self.providers.openrouter.model = None,
1940            "providers.openrouter.http_headers" => self.providers.openrouter.http_headers.clear(),
1941            "providers.xiaomi_mimo.api_key" => self.providers.xiaomi_mimo.api_key = None,
1942            "providers.xiaomi_mimo.base_url" => self.providers.xiaomi_mimo.base_url = None,
1943            "providers.xiaomi_mimo.model" => self.providers.xiaomi_mimo.model = None,
1944            "providers.xiaomi_mimo.mode" => self.providers.xiaomi_mimo.mode = None,
1945            "providers.xiaomi_mimo.http_headers" => {
1946                self.providers.xiaomi_mimo.http_headers.clear();
1947            }
1948            "providers.novita.api_key" => self.providers.novita.api_key = None,
1949            "providers.novita.base_url" => self.providers.novita.base_url = None,
1950            "providers.novita.model" => self.providers.novita.model = None,
1951            "providers.novita.http_headers" => self.providers.novita.http_headers.clear(),
1952            "providers.fireworks.api_key" => self.providers.fireworks.api_key = None,
1953            "providers.fireworks.base_url" => self.providers.fireworks.base_url = None,
1954            "providers.fireworks.model" => self.providers.fireworks.model = None,
1955            "providers.fireworks.http_headers" => self.providers.fireworks.http_headers.clear(),
1956            "providers.siliconflow.api_key" => self.providers.siliconflow.api_key = None,
1957            "providers.siliconflow.base_url" => self.providers.siliconflow.base_url = None,
1958            "providers.siliconflow.model" => self.providers.siliconflow.model = None,
1959            "providers.siliconflow.http_headers" => {
1960                self.providers.siliconflow.http_headers.clear();
1961            }
1962            "providers.siliconflow_cn.api_key" => self.providers.siliconflow_cn.api_key = None,
1963            "providers.siliconflow_cn.base_url" => self.providers.siliconflow_cn.base_url = None,
1964            "providers.siliconflow_cn.model" => self.providers.siliconflow_cn.model = None,
1965            "providers.siliconflow_cn.http_headers" => {
1966                self.providers.siliconflow_cn.http_headers.clear();
1967            }
1968            "providers.arcee.api_key" => self.providers.arcee.api_key = None,
1969            "providers.arcee.base_url" => self.providers.arcee.base_url = None,
1970            "providers.arcee.model" => self.providers.arcee.model = None,
1971            "providers.arcee.http_headers" => {
1972                self.providers.arcee.http_headers.clear();
1973            }
1974            "providers.moonshot.api_key" => self.providers.moonshot.api_key = None,
1975            "providers.moonshot.base_url" => self.providers.moonshot.base_url = None,
1976            "providers.moonshot.model" => self.providers.moonshot.model = None,
1977            "providers.moonshot.auth_mode" => self.providers.moonshot.auth_mode = None,
1978            "providers.moonshot.http_headers" => self.providers.moonshot.http_headers.clear(),
1979            "providers.sglang.api_key" => self.providers.sglang.api_key = None,
1980            "providers.sglang.base_url" => self.providers.sglang.base_url = None,
1981            "providers.sglang.model" => self.providers.sglang.model = None,
1982            "providers.sglang.http_headers" => self.providers.sglang.http_headers.clear(),
1983            "providers.vllm.api_key" => self.providers.vllm.api_key = None,
1984            "providers.vllm.base_url" => self.providers.vllm.base_url = None,
1985            "providers.vllm.model" => self.providers.vllm.model = None,
1986            "providers.vllm.http_headers" => self.providers.vllm.http_headers.clear(),
1987            "providers.ollama.api_key" => self.providers.ollama.api_key = None,
1988            "providers.ollama.base_url" => self.providers.ollama.base_url = None,
1989            "providers.ollama.model" => self.providers.ollama.model = None,
1990            "providers.ollama.http_headers" => self.providers.ollama.http_headers.clear(),
1991            "providers.huggingface.api_key" => self.providers.huggingface.api_key = None,
1992            "providers.huggingface.base_url" => self.providers.huggingface.base_url = None,
1993            "providers.huggingface.model" => self.providers.huggingface.model = None,
1994            "providers.huggingface.http_headers" => self.providers.huggingface.http_headers.clear(),
1995            "providers.together.api_key" => self.providers.together.api_key = None,
1996            "providers.together.base_url" => self.providers.together.base_url = None,
1997            "providers.together.model" => self.providers.together.model = None,
1998            "providers.together.http_headers" => self.providers.together.http_headers.clear(),
1999            _ => {
2000                self.extras.remove(key);
2001            }
2002        }
2003        Ok(())
2004    }
2005
2006    #[must_use]
2007    pub fn list_values(&self) -> BTreeMap<String, String> {
2008        let mut out = BTreeMap::new();
2009        out.insert("provider".to_string(), self.provider.as_str().to_string());
2010
2011        if let Some(v) = self.api_key.as_ref() {
2012            out.insert("api_key".to_string(), redact_secret(v));
2013        }
2014        if let Some(v) = self.base_url.as_ref() {
2015            out.insert("base_url".to_string(), v.clone());
2016        }
2017        if let Some(v) = serialize_http_headers(&self.http_headers) {
2018            out.insert("http_headers".to_string(), v);
2019        }
2020        if let Some(v) = self.default_text_model.as_ref() {
2021            out.insert("default_text_model".to_string(), v.clone());
2022        }
2023        if let Some(v) = self.model.as_ref() {
2024            out.insert("model".to_string(), v.clone());
2025        }
2026        if let Some(v) = self.auth_mode.as_ref() {
2027            out.insert("auth.mode".to_string(), v.clone());
2028        }
2029        if let Some(v) = self.output_mode.as_ref() {
2030            out.insert("output_mode".to_string(), v.clone());
2031        }
2032        if let Some(v) = self.verbosity.as_ref() {
2033            out.insert("verbosity".to_string(), v.clone());
2034        }
2035        if let Some(v) = self.log_level.as_ref() {
2036            out.insert("log_level".to_string(), v.clone());
2037        }
2038        if let Some(v) = self.telemetry {
2039            out.insert("telemetry".to_string(), v.to_string());
2040        }
2041        if let Some(v) = self.approval_policy.as_ref() {
2042            out.insert("approval_policy".to_string(), v.clone());
2043        }
2044        if let Some(v) = self.sandbox_mode.as_ref() {
2045            out.insert("sandbox_mode".to_string(), v.clone());
2046        }
2047        if let Some(v) = self
2048            .hook_sinks
2049            .as_ref()
2050            .and_then(|sinks| sinks.unix_socket_path.as_ref())
2051        {
2052            out.insert(
2053                "hook_sinks.unix_socket_path".to_string(),
2054                v.display().to_string(),
2055            );
2056        }
2057        if let Some(v) = self.providers.deepseek.api_key.as_ref() {
2058            out.insert("providers.deepseek.api_key".to_string(), redact_secret(v));
2059        }
2060        if let Some(v) = self.providers.deepseek.base_url.as_ref() {
2061            out.insert("providers.deepseek.base_url".to_string(), v.clone());
2062        }
2063        if let Some(v) = self.providers.deepseek.model.as_ref() {
2064            out.insert("providers.deepseek.model".to_string(), v.clone());
2065        }
2066        if let Some(v) = serialize_http_headers(&self.providers.deepseek.http_headers) {
2067            out.insert("providers.deepseek.http_headers".to_string(), v);
2068        }
2069        if let Some(v) = self.providers.openai.api_key.as_ref() {
2070            out.insert("providers.openai.api_key".to_string(), redact_secret(v));
2071        }
2072        if let Some(v) = self.providers.openai.base_url.as_ref() {
2073            out.insert("providers.openai.base_url".to_string(), v.clone());
2074        }
2075        if let Some(v) = self.providers.openai.model.as_ref() {
2076            out.insert("providers.openai.model".to_string(), v.clone());
2077        }
2078        if let Some(v) = serialize_http_headers(&self.providers.openai.http_headers) {
2079            out.insert("providers.openai.http_headers".to_string(), v);
2080        }
2081        if let Some(v) = self.providers.atlascloud.api_key.as_ref() {
2082            out.insert("providers.atlascloud.api_key".to_string(), redact_secret(v));
2083        }
2084        if let Some(v) = self.providers.atlascloud.base_url.as_ref() {
2085            out.insert("providers.atlascloud.base_url".to_string(), v.clone());
2086        }
2087        if let Some(v) = self.providers.atlascloud.model.as_ref() {
2088            out.insert("providers.atlascloud.model".to_string(), v.clone());
2089        }
2090        if let Some(v) = serialize_http_headers(&self.providers.atlascloud.http_headers) {
2091            out.insert("providers.atlascloud.http_headers".to_string(), v);
2092        }
2093        if let Some(v) = self.providers.volcengine.api_key.as_ref() {
2094            out.insert("providers.volcengine.api_key".to_string(), redact_secret(v));
2095        }
2096        if let Some(v) = self.providers.volcengine.base_url.as_ref() {
2097            out.insert("providers.volcengine.base_url".to_string(), v.clone());
2098        }
2099        if let Some(v) = self.providers.volcengine.model.as_ref() {
2100            out.insert("providers.volcengine.model".to_string(), v.clone());
2101        }
2102        if let Some(v) = self.providers.wanjie_ark.api_key.as_ref() {
2103            out.insert("providers.wanjie_ark.api_key".to_string(), redact_secret(v));
2104        }
2105        if let Some(v) = self.providers.wanjie_ark.base_url.as_ref() {
2106            out.insert("providers.wanjie_ark.base_url".to_string(), v.clone());
2107        }
2108        if let Some(v) = self.providers.wanjie_ark.model.as_ref() {
2109            out.insert("providers.wanjie_ark.model".to_string(), v.clone());
2110        }
2111        if let Some(v) = serialize_http_headers(&self.providers.volcengine.http_headers) {
2112            out.insert("providers.volcengine.http_headers".to_string(), v);
2113        }
2114        if let Some(v) = serialize_http_headers(&self.providers.wanjie_ark.http_headers) {
2115            out.insert("providers.wanjie_ark.http_headers".to_string(), v);
2116        }
2117        if let Some(v) = self.providers.nvidia_nim.api_key.as_ref() {
2118            out.insert("providers.nvidia_nim.api_key".to_string(), redact_secret(v));
2119        }
2120        if let Some(v) = self.providers.nvidia_nim.base_url.as_ref() {
2121            out.insert("providers.nvidia_nim.base_url".to_string(), v.clone());
2122        }
2123        if let Some(v) = self.providers.nvidia_nim.model.as_ref() {
2124            out.insert("providers.nvidia_nim.model".to_string(), v.clone());
2125        }
2126        if let Some(v) = serialize_http_headers(&self.providers.nvidia_nim.http_headers) {
2127            out.insert("providers.nvidia_nim.http_headers".to_string(), v);
2128        }
2129        if let Some(v) = self.providers.openrouter.api_key.as_ref() {
2130            out.insert("providers.openrouter.api_key".to_string(), redact_secret(v));
2131        }
2132        if let Some(v) = self.providers.openrouter.base_url.as_ref() {
2133            out.insert("providers.openrouter.base_url".to_string(), v.clone());
2134        }
2135        if let Some(v) = self.providers.openrouter.model.as_ref() {
2136            out.insert("providers.openrouter.model".to_string(), v.clone());
2137        }
2138        if let Some(v) = serialize_http_headers(&self.providers.openrouter.http_headers) {
2139            out.insert("providers.openrouter.http_headers".to_string(), v);
2140        }
2141        if let Some(v) = self.providers.xiaomi_mimo.api_key.as_ref() {
2142            out.insert(
2143                "providers.xiaomi_mimo.api_key".to_string(),
2144                redact_secret(v),
2145            );
2146        }
2147        if let Some(v) = self.providers.xiaomi_mimo.base_url.as_ref() {
2148            out.insert("providers.xiaomi_mimo.base_url".to_string(), v.clone());
2149        }
2150        if let Some(v) = self.providers.xiaomi_mimo.model.as_ref() {
2151            out.insert("providers.xiaomi_mimo.model".to_string(), v.clone());
2152        }
2153        if let Some(v) = self.providers.xiaomi_mimo.mode.as_ref() {
2154            out.insert("providers.xiaomi_mimo.mode".to_string(), v.clone());
2155        }
2156        if let Some(v) = serialize_http_headers(&self.providers.xiaomi_mimo.http_headers) {
2157            out.insert("providers.xiaomi_mimo.http_headers".to_string(), v);
2158        }
2159        if let Some(v) = self.providers.novita.api_key.as_ref() {
2160            out.insert("providers.novita.api_key".to_string(), redact_secret(v));
2161        }
2162        if let Some(v) = self.providers.novita.base_url.as_ref() {
2163            out.insert("providers.novita.base_url".to_string(), v.clone());
2164        }
2165        if let Some(v) = self.providers.novita.model.as_ref() {
2166            out.insert("providers.novita.model".to_string(), v.clone());
2167        }
2168        if let Some(v) = serialize_http_headers(&self.providers.novita.http_headers) {
2169            out.insert("providers.novita.http_headers".to_string(), v);
2170        }
2171        if let Some(v) = self.providers.fireworks.api_key.as_ref() {
2172            out.insert("providers.fireworks.api_key".to_string(), redact_secret(v));
2173        }
2174        if let Some(v) = self.providers.fireworks.base_url.as_ref() {
2175            out.insert("providers.fireworks.base_url".to_string(), v.clone());
2176        }
2177        if let Some(v) = self.providers.fireworks.model.as_ref() {
2178            out.insert("providers.fireworks.model".to_string(), v.clone());
2179        }
2180        if let Some(v) = serialize_http_headers(&self.providers.fireworks.http_headers) {
2181            out.insert("providers.fireworks.http_headers".to_string(), v);
2182        }
2183        if let Some(v) = self.providers.siliconflow.api_key.as_ref() {
2184            out.insert(
2185                "providers.siliconflow.api_key".to_string(),
2186                redact_secret(v),
2187            );
2188        }
2189        if let Some(v) = self.providers.siliconflow.base_url.as_ref() {
2190            out.insert("providers.siliconflow.base_url".to_string(), v.clone());
2191        }
2192        if let Some(v) = self.providers.siliconflow.model.as_ref() {
2193            out.insert("providers.siliconflow.model".to_string(), v.clone());
2194        }
2195        if let Some(v) = serialize_http_headers(&self.providers.siliconflow.http_headers) {
2196            out.insert("providers.siliconflow.http_headers".to_string(), v);
2197        }
2198        if let Some(v) = self.providers.siliconflow_cn.api_key.as_ref() {
2199            out.insert(
2200                "providers.siliconflow_cn.api_key".to_string(),
2201                redact_secret(v),
2202            );
2203        }
2204        if let Some(v) = self.providers.siliconflow_cn.base_url.as_ref() {
2205            out.insert("providers.siliconflow_cn.base_url".to_string(), v.clone());
2206        }
2207        if let Some(v) = self.providers.siliconflow_cn.model.as_ref() {
2208            out.insert("providers.siliconflow_cn.model".to_string(), v.clone());
2209        }
2210        if let Some(v) = serialize_http_headers(&self.providers.siliconflow_cn.http_headers) {
2211            out.insert("providers.siliconflow_cn.http_headers".to_string(), v);
2212        }
2213        if let Some(v) = self.providers.arcee.api_key.as_ref() {
2214            out.insert("providers.arcee.api_key".to_string(), redact_secret(v));
2215        }
2216        if let Some(v) = self.providers.arcee.base_url.as_ref() {
2217            out.insert("providers.arcee.base_url".to_string(), v.clone());
2218        }
2219        if let Some(v) = self.providers.arcee.model.as_ref() {
2220            out.insert("providers.arcee.model".to_string(), v.clone());
2221        }
2222        if let Some(v) = serialize_http_headers(&self.providers.arcee.http_headers) {
2223            out.insert("providers.arcee.http_headers".to_string(), v);
2224        }
2225        if let Some(v) = self.providers.moonshot.api_key.as_ref() {
2226            out.insert("providers.moonshot.api_key".to_string(), redact_secret(v));
2227        }
2228        if let Some(v) = self.providers.moonshot.base_url.as_ref() {
2229            out.insert("providers.moonshot.base_url".to_string(), v.clone());
2230        }
2231        if let Some(v) = self.providers.moonshot.model.as_ref() {
2232            out.insert("providers.moonshot.model".to_string(), v.clone());
2233        }
2234        if let Some(v) = self.providers.moonshot.auth_mode.as_ref() {
2235            out.insert("providers.moonshot.auth_mode".to_string(), v.clone());
2236        }
2237        if let Some(v) = serialize_http_headers(&self.providers.moonshot.http_headers) {
2238            out.insert("providers.moonshot.http_headers".to_string(), v);
2239        }
2240        if let Some(v) = self.providers.sglang.api_key.as_ref() {
2241            out.insert("providers.sglang.api_key".to_string(), redact_secret(v));
2242        }
2243        if let Some(v) = self.providers.sglang.base_url.as_ref() {
2244            out.insert("providers.sglang.base_url".to_string(), v.clone());
2245        }
2246        if let Some(v) = self.providers.sglang.model.as_ref() {
2247            out.insert("providers.sglang.model".to_string(), v.clone());
2248        }
2249        if let Some(v) = serialize_http_headers(&self.providers.sglang.http_headers) {
2250            out.insert("providers.sglang.http_headers".to_string(), v);
2251        }
2252        if let Some(v) = self.providers.vllm.api_key.as_ref() {
2253            out.insert("providers.vllm.api_key".to_string(), redact_secret(v));
2254        }
2255        if let Some(v) = self.providers.vllm.base_url.as_ref() {
2256            out.insert("providers.vllm.base_url".to_string(), v.clone());
2257        }
2258        if let Some(v) = self.providers.vllm.model.as_ref() {
2259            out.insert("providers.vllm.model".to_string(), v.clone());
2260        }
2261        if let Some(v) = serialize_http_headers(&self.providers.vllm.http_headers) {
2262            out.insert("providers.vllm.http_headers".to_string(), v);
2263        }
2264        if let Some(v) = self.providers.ollama.api_key.as_ref() {
2265            out.insert("providers.ollama.api_key".to_string(), redact_secret(v));
2266        }
2267        if let Some(v) = self.providers.ollama.base_url.as_ref() {
2268            out.insert("providers.ollama.base_url".to_string(), v.clone());
2269        }
2270        if let Some(v) = self.providers.ollama.model.as_ref() {
2271            out.insert("providers.ollama.model".to_string(), v.clone());
2272        }
2273        if let Some(v) = serialize_http_headers(&self.providers.ollama.http_headers) {
2274            out.insert("providers.ollama.http_headers".to_string(), v);
2275        }
2276        if let Some(v) = self.providers.huggingface.api_key.as_ref() {
2277            out.insert(
2278                "providers.huggingface.api_key".to_string(),
2279                redact_secret(v),
2280            );
2281        }
2282        if let Some(v) = self.providers.huggingface.base_url.as_ref() {
2283            out.insert("providers.huggingface.base_url".to_string(), v.clone());
2284        }
2285        if let Some(v) = self.providers.huggingface.model.as_ref() {
2286            out.insert("providers.huggingface.model".to_string(), v.clone());
2287        }
2288        if let Some(v) = serialize_http_headers(&self.providers.huggingface.http_headers) {
2289            out.insert("providers.huggingface.http_headers".to_string(), v);
2290        }
2291
2292        for (k, v) in &self.extras {
2293            out.insert(k.clone(), v.to_string());
2294        }
2295        out
2296    }
2297
2298    /// Resolve runtime options without touching platform credential stores.
2299    ///
2300    /// This method keeps library callers prompt-free: CLI flag → config file
2301    /// → environment. Call `resolve_runtime_options_with_secrets` when a
2302    /// user-facing dispatcher should recover credentials from the configured
2303    /// secret store.
2304    #[must_use]
2305    pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
2306        let no_keyring = Secrets::new(std::sync::Arc::new(
2307            codewhale_secrets::InMemoryKeyringStore::new(),
2308        ));
2309        self.resolve_runtime_options_with_secrets(cli, &no_keyring)
2310    }
2311
2312    /// Resolve runtime options using an explicit secrets façade.
2313    ///
2314    /// API-key precedence is **CLI flag → config-file → secret store → environment**.
2315    #[must_use]
2316    pub fn resolve_runtime_options_with_secrets(
2317        &self,
2318        cli: &CliRuntimeOverrides,
2319        secrets: &Secrets,
2320    ) -> ResolvedRuntimeOptions {
2321        let env = EnvRuntimeOverrides::load();
2322        let (provider, provider_source) = if let Some(provider) = cli.provider {
2323            (provider, ProviderSource::Cli)
2324        } else if let Some(provider) = env.provider {
2325            (
2326                provider,
2327                ProviderSource::Env(env.provider_source.unwrap_or("CODEWHALE_PROVIDER")),
2328            )
2329        } else {
2330            (self.provider, ProviderSource::Config)
2331        };
2332
2333        let mut provider_cfg = self.providers.for_provider(provider).clone();
2334        if provider == ProviderKind::SiliconflowCN {
2335            let fb = &self.providers.siliconflow;
2336            if provider_cfg.api_key.is_none() {
2337                provider_cfg.api_key = fb.api_key.clone();
2338            }
2339            if provider_cfg.base_url.is_none() {
2340                provider_cfg.base_url = fb.base_url.clone();
2341            }
2342            if provider_cfg.model.is_none() {
2343                provider_cfg.model = fb.model.clone();
2344            }
2345        }
2346        let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
2347            .then(|| self.api_key.clone())
2348            .flatten();
2349        let root_deepseek_base_url = (provider == ProviderKind::Deepseek)
2350            .then(|| self.base_url.clone())
2351            .flatten();
2352        let root_deepseek_model = (provider == ProviderKind::Deepseek)
2353            .then(|| self.default_text_model.clone())
2354            .flatten();
2355        let auth_mode = cli
2356            .auth_mode
2357            .clone()
2358            .or_else(|| env.auth_mode.clone())
2359            .or_else(|| provider_cfg.auth_mode.clone())
2360            .or_else(|| self.auth_mode.clone());
2361        let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
2362        let configured_base_url = cli
2363            .base_url
2364            .clone()
2365            .or_else(|| env.base_url_for(provider))
2366            .or_else(|| provider_cfg.base_url.clone())
2367            .or(root_deepseek_base_url);
2368        let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo {
2369            env.xiaomi_mimo_mode
2370                .clone()
2371                .or_else(|| provider_cfg.mode.clone())
2372        } else {
2373            None
2374        };
2375        let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo {
2376            xiaomi_mimo_env_api_key_for_runtime(
2377                xiaomi_mimo_mode.as_deref(),
2378                configured_base_url.as_deref(),
2379            )
2380        } else {
2381            None
2382        };
2383        let explicit_api_key_for_endpoint = cli
2384            .api_key
2385            .as_deref()
2386            .or(from_file.as_deref())
2387            .or(xiaomi_mimo_env_api_key.as_deref());
2388        let base_url = if provider == ProviderKind::XiaomiMimo {
2389            resolve_xiaomi_mimo_base_url(
2390                configured_base_url,
2391                explicit_api_key_for_endpoint,
2392                xiaomi_mimo_mode.as_deref(),
2393            )
2394        } else {
2395            configured_base_url.unwrap_or_else(|| match provider {
2396                ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
2397                ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
2398                ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
2399                ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
2400                ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
2401                ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
2402                ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
2403                ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
2404                ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
2405                ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
2406                ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
2407                ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL.to_string(),
2408                ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL.to_string(),
2409                ProviderKind::Moonshot => {
2410                    if auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) {
2411                        DEFAULT_KIMI_CODE_BASE_URL.to_string()
2412                    } else {
2413                        DEFAULT_MOONSHOT_BASE_URL.to_string()
2414                    }
2415                }
2416                ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
2417                ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
2418                ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
2419                ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(),
2420                ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL.to_string(),
2421                ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
2422                ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(),
2423                ProviderKind::Zai => DEFAULT_ZAI_BASE_URL.to_string(),
2424                ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL.to_string(),
2425                ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL.to_string(),
2426                ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL.to_string(),
2427            })
2428        };
2429        // CLI flag wins outright. Otherwise: config-file → injected secrets/env.
2430        // This makes `deepseek auth set` a reliable fix even when the user's
2431        // shell still exports an old key. When the file is empty, the injected
2432        // secrets façade recovers configured secret-store credentials before
2433        // falling back to ambient env.
2434        let uses_kimi_oauth = provider == ProviderKind::Moonshot
2435            && auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth);
2436        let (api_key, api_key_source) = if let Some(value) = cli.api_key.clone() {
2437            (Some(value), Some(RuntimeApiKeySource::Cli))
2438        } else if uses_kimi_oauth {
2439            (None, None)
2440        } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) {
2441            (Some(value), Some(RuntimeApiKeySource::ConfigFile))
2442        } else if let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty()) {
2443            (Some(value), Some(RuntimeApiKeySource::Env))
2444        } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
2445            match env_api_key_for_provider(provider) {
2446                Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
2447                None => (None, None),
2448            }
2449        } else {
2450            match secrets.resolve_with_source(provider.as_str()) {
2451                Some((value, source)) => {
2452                    let source = match source {
2453                        SecretSource::Keyring => RuntimeApiKeySource::Keyring,
2454                        SecretSource::Env => RuntimeApiKeySource::Env,
2455                    };
2456                    (Some(value), Some(source))
2457                }
2458                None => match env_api_key_for_provider(provider) {
2459                    Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
2460                    None => (None, None),
2461                },
2462            }
2463        };
2464
2465        let env_provider_model = env.model_for(provider, &base_url);
2466        let explicit_model = cli.model.is_some()
2467            || env.model.is_some()
2468            || env_provider_model.is_some()
2469            || provider_cfg.model.is_some()
2470            || root_deepseek_model.is_some()
2471            || self.model.is_some();
2472        let model = cli
2473            .model
2474            .clone()
2475            .or_else(|| env.model.clone())
2476            .or(env_provider_model)
2477            .or_else(|| provider_cfg.model.clone())
2478            .or(root_deepseek_model)
2479            .or_else(|| self.model.clone())
2480            .unwrap_or_else(|| {
2481                if provider == ProviderKind::Moonshot
2482                    && (auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth)
2483                        || moonshot_base_url_uses_kimi_code(&base_url))
2484                {
2485                    DEFAULT_KIMI_CODE_MODEL.to_string()
2486                } else {
2487                    default_model_for_provider(provider).to_string()
2488                }
2489            });
2490        let model =
2491            if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
2492                model.trim().to_string()
2493            } else {
2494                normalize_model_for_provider(provider, &model)
2495            };
2496
2497        let mut http_headers = self.http_headers.clone();
2498        http_headers.extend(provider_cfg.http_headers.clone());
2499        if let Some(env_headers) = env.http_headers {
2500            http_headers.extend(env_headers);
2501        }
2502        http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
2503
2504        let output_mode = cli
2505            .output_mode
2506            .clone()
2507            .or_else(|| env.output_mode.clone())
2508            .or_else(|| self.output_mode.clone());
2509        let log_level = cli
2510            .log_level
2511            .clone()
2512            .or_else(|| env.log_level.clone())
2513            .or_else(|| self.log_level.clone());
2514        let telemetry = cli
2515            .telemetry
2516            .or(env.telemetry)
2517            .or(self.telemetry)
2518            .unwrap_or(false);
2519        let approval_policy = cli
2520            .approval_policy
2521            .clone()
2522            .or_else(|| env.approval_policy.clone())
2523            .or_else(|| self.approval_policy.clone());
2524        let sandbox_mode = cli
2525            .sandbox_mode
2526            .clone()
2527            .or_else(|| env.sandbox_mode.clone())
2528            .or_else(|| self.sandbox_mode.clone());
2529        let yolo = cli.yolo.or(env.yolo);
2530        let verbosity = cli
2531            .verbosity
2532            .clone()
2533            .or_else(|| env.verbosity.clone())
2534            .or_else(|| self.verbosity.clone());
2535
2536        ResolvedRuntimeOptions {
2537            provider,
2538            provider_source,
2539            model,
2540            api_key,
2541            api_key_source,
2542            base_url,
2543            auth_mode,
2544            insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false),
2545            output_mode,
2546            log_level,
2547            telemetry,
2548            approval_policy,
2549            sandbox_mode,
2550            yolo,
2551            verbosity,
2552            http_headers,
2553        }
2554    }
2555}
2556
2557fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
2558    if source.model.is_some() {
2559        target.model = source.model.clone();
2560    }
2561}
2562
2563#[must_use]
2564pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
2565    let Some(project_rank) = approval_policy_rank(project) else {
2566        return false;
2567    };
2568    match current.and_then(approval_policy_rank) {
2569        Some(current_rank) => project_rank >= current_rank,
2570        None => project_rank >= 2,
2571    }
2572}
2573
2574#[must_use]
2575pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
2576    let normalized_project = project.trim().to_ascii_lowercase();
2577    if normalized_project == "external-sandbox" {
2578        return current
2579            .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
2580            .unwrap_or(false);
2581    }
2582
2583    let Some(project_rank) = sandbox_mode_rank(project) else {
2584        return false;
2585    };
2586    match current.and_then(sandbox_mode_rank) {
2587        Some(current_rank) => project_rank >= current_rank,
2588        None => project_rank >= 2,
2589    }
2590}
2591
2592fn approval_policy_rank(value: &str) -> Option<u8> {
2593    match value.trim().to_ascii_lowercase().as_str() {
2594        "auto" => Some(0),
2595        "suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
2596        "never" | "deny" | "denied" => Some(2),
2597        _ => None,
2598    }
2599}
2600
2601fn sandbox_mode_rank(value: &str) -> Option<u8> {
2602    match value.trim().to_ascii_lowercase().as_str() {
2603        "danger-full-access" => Some(0),
2604        "external-sandbox" => Some(0),
2605        "workspace-write" => Some(1),
2606        "read-only" => Some(2),
2607        _ => None,
2608    }
2609}
2610
2611/// Load a project-level config from the workspace.
2612///
2613/// Checks `$WORKSPACE/.codewhale/config.toml` first, falling back to
2614/// `$WORKSPACE/.deepseek/config.toml` for backward compatibility.
2615/// Returns `None` if neither file exists or can't be parsed.
2616pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
2617    for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
2618        let path = workspace.join(dir).join(CONFIG_FILE_NAME);
2619        if path.exists()
2620            && let Ok(raw) = fs::read_to_string(&path)
2621        {
2622            match toml::from_str(&raw) {
2623                Ok(config) => return Some(config),
2624                Err(e) => {
2625                    tracing::warn!("Failed to parse project config {}: {e}", path.display());
2626                    return None;
2627                }
2628            }
2629        }
2630    }
2631    None
2632}
2633
2634fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
2635    if matches!(provider, ProviderKind::XiaomiMimo)
2636        && let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
2637    {
2638        return canonical.to_string();
2639    }
2640    if matches!(provider, ProviderKind::Minimax)
2641        && let Some(canonical) = canonical_minimax_model_id(model)
2642    {
2643        return canonical.to_string();
2644    }
2645    if matches!(provider, ProviderKind::Zai)
2646        && let Some(canonical) = canonical_zai_model_id(model)
2647    {
2648        return canonical.to_string();
2649    }
2650
2651    if matches!(
2652        provider,
2653        ProviderKind::Atlascloud
2654            | ProviderKind::WanjieArk
2655            | ProviderKind::Volcengine
2656            | ProviderKind::XiaomiMimo
2657            | ProviderKind::Zai
2658            | ProviderKind::Stepfun
2659            | ProviderKind::Minimax
2660            | ProviderKind::Ollama
2661    ) {
2662        return model.to_string();
2663    }
2664
2665    let normalized = model.trim().to_ascii_lowercase();
2666    if provider == ProviderKind::Openrouter
2667        && let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
2668    {
2669        return canonical.to_string();
2670    }
2671    match (provider, normalized.as_str()) {
2672        (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
2673            DEFAULT_NVIDIA_NIM_MODEL.to_string()
2674        }
2675        (
2676            ProviderKind::NvidiaNim,
2677            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2678            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2679        ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
2680        (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
2681            DEFAULT_OPENROUTER_MODEL.to_string()
2682        }
2683        (
2684            ProviderKind::Openrouter,
2685            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2686            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2687        ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
2688        (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
2689            DEFAULT_NOVITA_MODEL.to_string()
2690        }
2691        (
2692            ProviderKind::Novita,
2693            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2694            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2695        ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
2696        (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
2697            DEFAULT_FIREWORKS_MODEL.to_string()
2698        }
2699        (
2700            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2701            "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
2702        ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
2703        (
2704            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2705            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
2706        ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
2707        (
2708            ProviderKind::Arcee,
2709            "trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking",
2710        ) => DEFAULT_ARCEE_MODEL.to_string(),
2711        (ProviderKind::Arcee, "trinity-mini" | "arcee-trinity-mini") => {
2712            ARCEE_TRINITY_MINI_MODEL.to_string()
2713        }
2714        (ProviderKind::Arcee, "arcee-trinity-large-preview") => {
2715            ARCEE_TRINITY_LARGE_PREVIEW_MODEL.to_string()
2716        }
2717        (
2718            ProviderKind::Moonshot,
2719            "kimi"
2720            | "kimi-k2"
2721            | "kimi-k2.7"
2722            | "kimi-k2-7"
2723            | "kimi-k2.7-code"
2724            | "kimi-k2-7-code"
2725            | "kimi-code"
2726            | "moonshot-kimi-k2.7-code",
2727        ) => DEFAULT_MOONSHOT_MODEL.to_string(),
2728        (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
2729            MOONSHOT_KIMI_K2_6_MODEL.to_string()
2730        }
2731        (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
2732            DEFAULT_SGLANG_MODEL.to_string()
2733        }
2734        (
2735            ProviderKind::Sglang,
2736            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2737            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2738        ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
2739        (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
2740            DEFAULT_VLLM_MODEL.to_string()
2741        }
2742        (
2743            ProviderKind::Vllm,
2744            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2745            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2746        ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
2747        (ProviderKind::Huggingface, "deepseek-v4-pro" | "deepseek-v4pro") => {
2748            DEFAULT_HUGGINGFACE_MODEL.to_string()
2749        }
2750        (
2751            ProviderKind::Huggingface,
2752            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2753            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2754        ) => DEFAULT_HUGGINGFACE_FLASH_MODEL.to_string(),
2755        (ProviderKind::Together, "deepseek-v4-pro" | "deepseek-v4pro") => {
2756            DEFAULT_TOGETHER_MODEL.to_string()
2757        }
2758        (
2759            ProviderKind::Together,
2760            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2761            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2762        ) => DEFAULT_TOGETHER_FLASH_MODEL.to_string(),
2763        (ProviderKind::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
2764            DEFAULT_DEEPINFRA_MODEL.to_string()
2765        }
2766        (
2767            ProviderKind::Deepinfra,
2768            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2769            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2770        ) => DEFAULT_DEEPINFRA_FLASH_MODEL.to_string(),
2771        _ => model.to_string(),
2772    }
2773}
2774
2775fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> {
2776    let normalized = model.trim().to_ascii_lowercase();
2777    let normalized = normalized.replace(['_', ' '], "-");
2778    match normalized.as_str() {
2779        "mimo"
2780        | DEFAULT_XIAOMI_MIMO_MODEL
2781        | "mimo-v2-5-pro"
2782        | "xiaomi-mimo-v2.5-pro"
2783        | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL),
2784        "omni"
2785        | "mimo-omni"
2786        | "v2.5-omni"
2787        | "v25-omni"
2788        | "mimo-v2.5"
2789        | "mimo-v25"
2790        | "mimo-v2-5"
2791        | "mimo-v2.5-omni"
2792        | "mimo-v25-omni"
2793        | "mimo-v2-5-omni"
2794        | "xiaomi-mimo-v2.5"
2795        | "xiaomi-mimo-v2-5"
2796        | "xiaomi-mimo-v2.5-omni"
2797        | "xiaomi-mimo-v2-5-omni" => Some(XIAOMI_MIMO_V2_5_OMNI_MODEL),
2798        "asr" | "mimo-asr" | "mimo-v2.5-asr" | "speech-to-text" | "transcribe" => {
2799            Some(XIAOMI_MIMO_ASR_MODEL)
2800        }
2801        "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => {
2802            Some(XIAOMI_MIMO_TTS_MODEL)
2803        }
2804        "mimo-tts-voicedesign"
2805        | "mimo-voice-design"
2806        | "mimo-v25-tts-voicedesign"
2807        | "mimo-v2.5-tts-voicedesign"
2808        | "voicedesign"
2809        | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL),
2810        "mimo-tts-voiceclone"
2811        | "mimo-voice-clone"
2812        | "mimo-v25-tts-voiceclone"
2813        | "mimo-v2.5-tts-voiceclone"
2814        | "voiceclone"
2815        | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL),
2816        "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL),
2817        _ => None,
2818    }
2819}
2820
2821fn canonical_minimax_model_id(model: &str) -> Option<&'static str> {
2822    let normalized = model.trim().to_ascii_lowercase();
2823    let normalized = normalized.replace(['_', ' '], "-");
2824    match normalized.as_str() {
2825        "minimax" | "minimax-m3" | "minimax-m-3" | "minimax-m-3-thinking" => {
2826            Some(DEFAULT_MINIMAX_MODEL)
2827        }
2828        "minimax-m2.7" | "minimax-m2-7" | "minimax-m-2.7" | "minimax-m-2-7" => {
2829            Some(MINIMAX_M2_7_MODEL)
2830        }
2831        "minimax-m2.7-highspeed"
2832        | "minimax-m2-7-highspeed"
2833        | "minimax-m-2.7-highspeed"
2834        | "minimax-m-2-7-highspeed" => Some(MINIMAX_M2_7_HIGHSPEED_MODEL),
2835        "minimax-m2.5" | "minimax-m2-5" | "minimax-m-2.5" | "minimax-m-2-5" => {
2836            Some(MINIMAX_M2_5_MODEL)
2837        }
2838        "minimax-m2.5-highspeed"
2839        | "minimax-m2-5-highspeed"
2840        | "minimax-m-2.5-highspeed"
2841        | "minimax-m-2-5-highspeed" => Some(MINIMAX_M2_5_HIGHSPEED_MODEL),
2842        "minimax-m2.1" | "minimax-m2-1" | "minimax-m-2.1" | "minimax-m-2-1" => {
2843            Some(MINIMAX_M2_1_MODEL)
2844        }
2845        "minimax-m2.1-highspeed"
2846        | "minimax-m2-1-highspeed"
2847        | "minimax-m-2.1-highspeed"
2848        | "minimax-m-2-1-highspeed" => Some(MINIMAX_M2_1_HIGHSPEED_MODEL),
2849        "minimax-m2" | "minimax-m-2" => Some(MINIMAX_M2_MODEL),
2850        _ => None,
2851    }
2852}
2853
2854fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
2855    let normalized = model.trim().to_ascii_lowercase();
2856    let normalized = normalized.replace(['_', ' '], "-");
2857    match normalized.as_str() {
2858        "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(DEFAULT_ZAI_MODEL),
2859        "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(ZAI_GLM_5_2_MODEL),
2860        _ => None,
2861    }
2862}
2863
2864fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
2865    let normalized = model.trim().to_ascii_lowercase();
2866    let normalized = normalized.replace(['_', ' '], "-");
2867    match normalized.as_str() {
2868        OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
2869        | "trinity"
2870        | "trinity-large-thinking"
2871        | "arcee-trinity"
2872        | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
2873        OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
2874            Some(OPENROUTER_GEMMA_4_31B_MODEL)
2875        }
2876        OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
2877            Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
2878        }
2879        OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
2880            Some(OPENROUTER_GLM_5_1_MODEL)
2881        }
2882        OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
2883            Some(OPENROUTER_GLM_5_2_MODEL)
2884        }
2885        OPENROUTER_KIMI_K2_7_CODE_MODEL
2886        | "kimi"
2887        | "kimi-k2"
2888        | "kimi-k2.7"
2889        | "kimi-k2-7"
2890        | "kimi-k2.7-code"
2891        | "kimi-k2-7-code"
2892        | "kimi-code"
2893        | "moonshot-kimi-k2.7-code"
2894        | "openrouter-kimi-k2.7-code" => Some(OPENROUTER_KIMI_K2_7_CODE_MODEL),
2895        OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
2896            Some(OPENROUTER_KIMI_K2_6_MODEL)
2897        }
2898        OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => {
2899            Some(OPENROUTER_MINIMAX_M3_MODEL)
2900        }
2901        OPENROUTER_MINIMAX_2_7_MODEL
2902        | "minimax-2.7"
2903        | "minimax-2-7"
2904        | "minimax-m2.7"
2905        | "minimax-m2-7"
2906        | "minimax-m-2.7"
2907        | "minimax-m-2-7" => Some(OPENROUTER_MINIMAX_2_7_MODEL),
2908        OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
2909        | "nemotron-3-nano-omni"
2910        | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
2911        OPENROUTER_QWEN_3_6_35B_A3B_MODEL
2912        | "qwen3.6-35b-a3b"
2913        | "qwen-3.6-35b-a3b"
2914        | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
2915        OPENROUTER_QWEN_3_6_FLASH_MODEL | "qwen3.6-flash" | "qwen-3.6-flash" => {
2916            Some(OPENROUTER_QWEN_3_6_FLASH_MODEL)
2917        }
2918        OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL
2919        | "qwen3.6-max-preview"
2920        | "qwen-3.6-max-preview"
2921        | "qwen-max-preview" => Some(OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
2922        OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
2923            Some(OPENROUTER_QWEN_3_6_27B_MODEL)
2924        }
2925        OPENROUTER_QWEN_3_6_PLUS_MODEL | "qwen3.6-plus" | "qwen-3.6-plus" => {
2926            Some(OPENROUTER_QWEN_3_6_PLUS_MODEL)
2927        }
2928        OPENROUTER_QWEN_3_7_MAX_MODEL | "qwen3.7-max" | "qwen-3.7-max" => {
2929            Some(OPENROUTER_QWEN_3_7_MAX_MODEL)
2930        }
2931        OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
2932            Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
2933        }
2934        OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
2935        | "mimo-v2.5-pro"
2936        | "mimo-v2-5-pro"
2937        | "xiaomi-mimo-v2.5-pro"
2938        | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
2939        OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
2940        | "mimo-v2.5"
2941        | "mimo-v2-5"
2942        | "xiaomi-mimo-v2.5"
2943        | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
2944        _ => None,
2945    }
2946}
2947
2948fn default_model_for_provider(provider: ProviderKind) -> &'static str {
2949    match provider {
2950        ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
2951        ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
2952        ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
2953        ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
2954        ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
2955        ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
2956        ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
2957        ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
2958        ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
2959        ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
2960        ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_MODEL,
2961        ProviderKind::Arcee => DEFAULT_ARCEE_MODEL,
2962        ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
2963        ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
2964        ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
2965        ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
2966        ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
2967        ProviderKind::Together => DEFAULT_TOGETHER_MODEL,
2968        ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL,
2969        ProviderKind::Anthropic => DEFAULT_ANTHROPIC_MODEL,
2970        ProviderKind::Zai => DEFAULT_ZAI_MODEL,
2971        ProviderKind::Stepfun => DEFAULT_STEPFUN_MODEL,
2972        ProviderKind::Minimax => DEFAULT_MINIMAX_MODEL,
2973        ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
2974    }
2975}
2976
2977fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
2978    match provider {
2979        ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
2980        ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
2981        ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
2982        ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
2983        ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
2984        ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
2985        ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
2986        ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
2987        ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
2988        ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
2989        ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
2990        ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL,
2991        ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL,
2992        ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
2993        ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
2994        ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
2995        ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
2996        ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
2997        ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL,
2998        ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
2999        ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
3000        ProviderKind::Zai => DEFAULT_ZAI_BASE_URL,
3001        ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL,
3002        ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL,
3003        ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
3004    }
3005}
3006
3007fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
3008    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
3009    normalized == DEFAULT_KIMI_CODE_BASE_URL
3010        || normalized == "https://api.kimi.com/coding"
3011        || normalized.starts_with("https://api.kimi.com/coding/")
3012}
3013
3014fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
3015    let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
3016    if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
3017        return None;
3018    }
3019    Some(match normalized.as_str() {
3020        "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => {
3021            DEFAULT_XIAOMI_MIMO_BASE_URL
3022        }
3023        "token-plan-cn"
3024        | "token-plan-china"
3025        | "token-plan-mainland"
3026        | "token-plan-mainland-china"
3027        | "cn"
3028        | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
3029        "token-plan-sgp"
3030        | "token-plan-sg"
3031        | "token-plan-singapore"
3032        | "sgp"
3033        | "sg"
3034        | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
3035        "token-plan-ams"
3036        | "token-plan-eu"
3037        | "token-plan-europe"
3038        | "token-plan-amsterdam"
3039        | "ams"
3040        | "eu"
3041        | "europe"
3042        | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
3043        _ => DEFAULT_XIAOMI_MIMO_BASE_URL,
3044    })
3045}
3046
3047fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
3048    matches!(
3049        normalized_mode,
3050        "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
3051    )
3052}
3053
3054fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
3055    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
3056    normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
3057        || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL
3058        || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
3059}
3060
3061fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option<String> {
3062    candidates.iter().find_map(|name| {
3063        std::env::var(name)
3064            .ok()
3065            .filter(|value| !value.trim().is_empty())
3066    })
3067}
3068
3069fn xiaomi_mimo_env_api_key_for_runtime(
3070    mode: Option<&str>,
3071    base_url: Option<&str>,
3072) -> Option<String> {
3073    const TOKEN_PLAN_ENV_VARS: &[&str] =
3074        &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"];
3075    const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"];
3076
3077    let normalized_mode =
3078        mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
3079    let standard_selected = normalized_mode
3080        .as_deref()
3081        .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint)
3082        || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go);
3083    if standard_selected {
3084        return xiaomi_mimo_env_var(STANDARD_ENV_VARS);
3085    }
3086
3087    let token_plan_selected = normalized_mode
3088        .as_deref()
3089        .and_then(xiaomi_mimo_base_url_for_mode)
3090        .is_some()
3091        || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan);
3092    if token_plan_selected {
3093        return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS);
3094    }
3095
3096    xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS))
3097}
3098
3099fn resolve_xiaomi_mimo_base_url(
3100    configured: Option<String>,
3101    api_key: Option<&str>,
3102    mode: Option<&str>,
3103) -> String {
3104    let normalized_mode =
3105        mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
3106    let uses_standard_mode = normalized_mode
3107        .as_deref()
3108        .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint);
3109    let mode_base_url = normalized_mode
3110        .as_deref()
3111        .and_then(xiaomi_mimo_base_url_for_mode);
3112    let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key);
3113    match configured {
3114        Some(base_url) if uses_standard_mode => base_url,
3115        Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => {
3116            mode_base_url
3117                .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL)
3118                .to_string()
3119        }
3120        Some(base_url) => base_url,
3121        None => {
3122            if let Some(base_url) = mode_base_url {
3123                base_url.to_string()
3124            } else if uses_standard_mode {
3125                XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
3126            } else if uses_token_plan || api_key.is_none() {
3127                DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()
3128            } else {
3129                XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
3130            }
3131        }
3132    }
3133}
3134
3135fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
3136    api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
3137}
3138
3139fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool {
3140    matches!(
3141        base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
3142        "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
3143    )
3144}
3145
3146fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
3147    if provider.is_siliconflow() && siliconflow_base_url_is_official(base_url) {
3148        return false;
3149    }
3150    if provider == ProviderKind::XiaomiMimo
3151        && (xiaomi_mimo_base_url_uses_token_plan(base_url)
3152            || xiaomi_mimo_base_url_is_pay_as_you_go(base_url))
3153    {
3154        return false;
3155    }
3156    let actual = base_url.trim_end_matches('/');
3157    let default = default_base_url_for_provider(provider).trim_end_matches('/');
3158    actual != default
3159}
3160
3161fn siliconflow_base_url_is_official(base_url: &str) -> bool {
3162    matches!(
3163        base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
3164        "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
3165    )
3166}
3167
3168fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
3169    base_url_is_custom_for_provider(provider, base_url)
3170}
3171
3172fn should_skip_secret_store_for_provider(
3173    provider: ProviderKind,
3174    base_url: &str,
3175    auth_mode: Option<&str>,
3176) -> bool {
3177    if auth_mode_requires_api_key(auth_mode) {
3178        return false;
3179    }
3180    if auth_mode_disables_api_key(auth_mode) {
3181        return true;
3182    }
3183
3184    matches!(
3185        provider,
3186        ProviderKind::Sglang | ProviderKind::Vllm | ProviderKind::Ollama
3187    ) || base_url_uses_local_host(base_url)
3188}
3189
3190fn env_api_key_for_provider(provider: ProviderKind) -> Option<String> {
3191    if provider == ProviderKind::Huggingface {
3192        return std::env::var("HUGGINGFACE_API_KEY")
3193            .ok()
3194            .filter(|value| !value.trim().is_empty())
3195            .or_else(|| {
3196                std::env::var("HF_TOKEN")
3197                    .ok()
3198                    .filter(|value| !value.trim().is_empty())
3199            });
3200    }
3201
3202    codewhale_secrets::env_for(provider.as_str())
3203}
3204
3205fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
3206    matches!(
3207        auth_mode
3208            .map(str::trim)
3209            .filter(|value| !value.is_empty())
3210            .map(|value| value.to_ascii_lowercase()),
3211        Some(value)
3212            if matches!(
3213                value.as_str(),
3214                "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
3215            )
3216    )
3217}
3218
3219fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
3220    matches!(
3221        auth_mode
3222            .map(str::trim)
3223            .filter(|value| !value.is_empty())
3224            .map(|value| value.to_ascii_lowercase()),
3225        Some(value)
3226            if matches!(
3227                value.as_str(),
3228                "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
3229            )
3230    )
3231}
3232
3233fn auth_mode_uses_kimi_oauth(auth_mode: &str) -> bool {
3234    matches!(
3235        auth_mode
3236            .trim()
3237            .to_ascii_lowercase()
3238            .replace('-', "_")
3239            .as_str(),
3240        "kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
3241    )
3242}
3243
3244fn base_url_uses_local_host(base_url: &str) -> bool {
3245    let Some(host) = base_url_host(base_url) else {
3246        return false;
3247    };
3248    let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
3249    if matches!(host.as_str(), "localhost" | "0.0.0.0") {
3250        return true;
3251    }
3252    host.parse::<std::net::IpAddr>()
3253        .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
3254}
3255
3256fn base_url_host(base_url: &str) -> Option<&str> {
3257    let without_scheme = base_url
3258        .split_once("://")
3259        .map_or(base_url, |(_, rest)| rest);
3260    let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
3261    if let Some(rest) = authority.strip_prefix('[') {
3262        return rest.split_once(']').map(|(host, _)| host);
3263    }
3264    authority.split(':').next().filter(|host| !host.is_empty())
3265}
3266
3267#[derive(Debug, Clone, Default)]
3268pub struct CliRuntimeOverrides {
3269    pub provider: Option<ProviderKind>,
3270    pub model: Option<String>,
3271    pub api_key: Option<String>,
3272    pub base_url: Option<String>,
3273    pub auth_mode: Option<String>,
3274    pub output_mode: Option<String>,
3275    pub log_level: Option<String>,
3276    pub telemetry: Option<bool>,
3277    pub approval_policy: Option<String>,
3278    pub sandbox_mode: Option<String>,
3279    pub yolo: Option<bool>,
3280    pub verbosity: Option<String>,
3281}
3282
3283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3284pub enum RuntimeApiKeySource {
3285    Cli,
3286    ConfigFile,
3287    Keyring,
3288    Env,
3289}
3290
3291impl RuntimeApiKeySource {
3292    #[must_use]
3293    pub fn as_env_value(self) -> &'static str {
3294        match self {
3295            Self::Cli => "cli",
3296            Self::ConfigFile => "config",
3297            Self::Keyring => "keyring",
3298            Self::Env => "env",
3299        }
3300    }
3301}
3302
3303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3304pub enum ProviderSource {
3305    Cli,
3306    Env(&'static str),
3307    Config,
3308}
3309
3310#[derive(Debug, Clone)]
3311pub struct ResolvedRuntimeOptions {
3312    pub provider: ProviderKind,
3313    pub provider_source: ProviderSource,
3314    pub model: String,
3315    pub api_key: Option<String>,
3316    pub api_key_source: Option<RuntimeApiKeySource>,
3317    pub base_url: String,
3318    pub auth_mode: Option<String>,
3319    pub insecure_skip_tls_verify: bool,
3320    pub output_mode: Option<String>,
3321    pub log_level: Option<String>,
3322    pub telemetry: bool,
3323    pub approval_policy: Option<String>,
3324    pub sandbox_mode: Option<String>,
3325    pub yolo: Option<bool>,
3326    pub verbosity: Option<String>,
3327    pub http_headers: BTreeMap<String, String>,
3328}
3329
3330#[derive(Debug, Clone)]
3331pub struct ConfigStore {
3332    path: PathBuf,
3333    pub config: ConfigToml,
3334    permissions: PermissionsToml,
3335}
3336
3337impl ConfigStore {
3338    pub fn load(path: Option<PathBuf>) -> Result<Self> {
3339        let path = resolve_config_path(path)?;
3340        let config = if path.exists() {
3341            let raw = fs::read_to_string(&path)
3342                .with_context(|| format!("failed to read config at {}", path.display()))?;
3343            toml::from_str(&raw)
3344                .with_context(|| format!("failed to parse config at {}", path.display()))?
3345        } else {
3346            ConfigToml::default()
3347        };
3348        let permissions = load_sibling_permissions(&path)?;
3349
3350        Ok(Self {
3351            path,
3352            config,
3353            permissions,
3354        })
3355    }
3356
3357    pub fn save(&self) -> Result<()> {
3358        if let Some(parent) = self.path.parent() {
3359            fs::create_dir_all(parent).with_context(|| {
3360                format!("failed to create config directory {}", parent.display())
3361            })?;
3362        }
3363        let body = toml::to_string_pretty(&self.config).context("failed to serialize config")?;
3364        match fs::read_to_string(&self.path) {
3365            Ok(existing) => {
3366                if existing == body {
3367                    return Ok(());
3368                }
3369                write_one_time_config_backup(&self.path)?;
3370            }
3371            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
3372            Err(err) => {
3373                return Err(err)
3374                    .with_context(|| format!("failed to read config at {}", self.path.display()));
3375            }
3376        }
3377        #[cfg(unix)]
3378        {
3379            let mut file = fs::OpenOptions::new()
3380                .write(true)
3381                .create(true)
3382                .truncate(true)
3383                .mode(0o600)
3384                .open(&self.path)
3385                .with_context(|| format!("failed to write config at {}", self.path.display()))?;
3386            file.write_all(body.as_bytes())
3387                .with_context(|| format!("failed to write config at {}", self.path.display()))?;
3388            file.set_permissions(fs::Permissions::from_mode(0o600))
3389                .with_context(|| {
3390                    format!(
3391                        "failed to set config permissions at {}",
3392                        self.path.display()
3393                    )
3394                })?;
3395        }
3396        #[cfg(not(unix))]
3397        {
3398            fs::write(&self.path, body)
3399                .with_context(|| format!("failed to write config at {}", self.path.display()))?;
3400        }
3401        Ok(())
3402    }
3403
3404    #[must_use]
3405    pub fn path(&self) -> &Path {
3406        &self.path
3407    }
3408
3409    #[must_use]
3410    pub fn permissions(&self) -> &PermissionsToml {
3411        &self.permissions
3412    }
3413
3414    #[must_use]
3415    pub fn permissions_path(&self) -> PathBuf {
3416        permissions_path_for_config_path(&self.path)
3417    }
3418
3419    #[must_use]
3420    pub fn exec_policy_engine(&self) -> ExecPolicyEngine {
3421        if self.permissions.is_empty() {
3422            ExecPolicyEngine::new(Vec::new(), Vec::new())
3423        } else {
3424            ExecPolicyEngine::with_rulesets(vec![self.permissions.ruleset()])
3425        }
3426    }
3427
3428    /// Atomically append ask-only permission rules to the sibling
3429    /// `permissions.toml` file.
3430    ///
3431    /// Existing comments and formatting are preserved. Exact duplicate rules
3432    /// are ignored, and the in-memory permissions snapshot is refreshed after
3433    /// a successful write.
3434    pub fn append_ask_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
3435        if rules.is_empty() {
3436            return Ok(0);
3437        }
3438
3439        let path = self.permissions_path();
3440        let raw = if path.exists() {
3441            fs::read_to_string(&path)
3442                .with_context(|| format!("failed to read permissions at {}", path.display()))?
3443        } else {
3444            String::new()
3445        };
3446        let mut permissions = if raw.trim().is_empty() {
3447            PermissionsToml::default()
3448        } else {
3449            toml::from_str(&raw)
3450                .with_context(|| format!("failed to parse permissions at {}", path.display()))?
3451        };
3452        let mut document = if raw.trim().is_empty() {
3453            toml_edit::DocumentMut::new()
3454        } else {
3455            raw.parse::<toml_edit::DocumentMut>()
3456                .with_context(|| format!("failed to edit permissions at {}", path.display()))?
3457        };
3458
3459        if !document.contains_key("rules") {
3460            document["rules"] = toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
3461        }
3462        let rules_item = document
3463            .get_mut("rules")
3464            .expect("rules entry was inserted above");
3465
3466        let mut added = 0;
3467        for rule in rules {
3468            if permissions.rules.contains(rule) {
3469                continue;
3470            }
3471            append_ask_rule(rules_item, rule)?;
3472            permissions.rules.push(rule.clone());
3473            added += 1;
3474        }
3475        if added == 0 {
3476            self.permissions = permissions;
3477            return Ok(0);
3478        }
3479
3480        let body = document.to_string();
3481        let persisted: PermissionsToml = toml::from_str(&body).with_context(|| {
3482            format!(
3483                "generated invalid permissions document for {}",
3484                path.display()
3485            )
3486        })?;
3487        write_permissions_atomic(&path, body.as_bytes())?;
3488        self.permissions = persisted;
3489        Ok(added)
3490    }
3491}
3492
3493fn config_backup_path(path: &Path) -> PathBuf {
3494    let mut file_name = path
3495        .file_name()
3496        .map(std::ffi::OsString::from)
3497        .unwrap_or_else(|| std::ffi::OsString::from(CONFIG_FILE_NAME));
3498    file_name.push(".bak");
3499    path.with_file_name(file_name)
3500}
3501
3502fn write_one_time_config_backup(path: &Path) -> Result<()> {
3503    let backup = config_backup_path(path);
3504    if backup.exists() {
3505        return Ok(());
3506    }
3507    fs::copy(path, &backup).with_context(|| {
3508        format!(
3509            "failed to create config backup {} from {}",
3510            backup.display(),
3511            path.display()
3512        )
3513    })?;
3514    #[cfg(unix)]
3515    {
3516        fs::set_permissions(&backup, fs::Permissions::from_mode(0o600)).with_context(|| {
3517            format!(
3518                "failed to set config backup permissions at {}",
3519                backup.display()
3520            )
3521        })?;
3522    }
3523    Ok(())
3524}
3525
3526/// Process-wide default [`Secrets`] façade. The first caller wins; the
3527/// lock is exposed so test or CLI code can install an explicit
3528/// backend (e.g. an [`codewhale_secrets::InMemoryKeyringStore`]) before
3529/// any resolver runs.
3530pub fn default_secrets() -> &'static Secrets {
3531    static SECRETS: OnceLock<Secrets> = OnceLock::new();
3532    SECRETS.get_or_init(|| {
3533        // Tests should never poke real platform credential stores. Cargo sets the
3534        // `RUST_TEST_*` family of env vars (and `CARGO_PKG_NAME` is
3535        // always populated), but the `cfg(test)` flag is the canonical
3536        // signal here. See `install_test_secrets` for explicit installs.
3537        #[cfg(test)]
3538        {
3539            Secrets::new(std::sync::Arc::new(
3540                codewhale_secrets::InMemoryKeyringStore::new(),
3541            ))
3542        }
3543        #[cfg(not(test))]
3544        {
3545            Secrets::auto_detect()
3546        }
3547    })
3548}
3549
3550// ── CodeWhale state root (v0.8.44) ──────────────────────────────────
3551//
3552// v0.8.44 migrates product-owned app state from ~/.deepseek/ to
3553// ~/.codewhale/ while keeping ~/.deepseek/ as a compatibility fallback.
3554// New installs write to ~/.codewhale/. Existing installs with only
3555// ~/.deepseek/ continue working without data loss.
3556
3557/// Canonical CodeWhale app directory name under $HOME.
3558pub const CODEWHALE_APP_DIR: &str = ".codewhale";
3559
3560/// Legacy DeepSeek-branded app directory name (compatibility fallback).
3561pub const LEGACY_APP_DIR: &str = ".deepseek";
3562
3563/// Resolve the primary CodeWhale home directory.
3564///
3565/// `$CODEWHALE_HOME` takes precedence when set. Otherwise defaults to
3566/// `$HOME/.codewhale`. This is the write target for new product state.
3567pub fn codewhale_home() -> Result<PathBuf> {
3568    if let Ok(val) = std::env::var("CODEWHALE_HOME") {
3569        let trimmed = val.trim();
3570        if !trimmed.is_empty() {
3571            return Ok(PathBuf::from(trimmed));
3572        }
3573    }
3574    let home = effective_home_dir().context("failed to resolve home directory")?;
3575    Ok(home.join(CODEWHALE_APP_DIR))
3576}
3577
3578/// Resolve the legacy DeepSeek home directory (`$HOME/.deepseek`).
3579///
3580/// Always returns the legacy path regardless of whether it exists.
3581pub fn legacy_deepseek_home() -> Result<PathBuf> {
3582    let home = effective_home_dir().context("failed to resolve home directory")?;
3583    Ok(home.join(LEGACY_APP_DIR))
3584}
3585
3586fn effective_home_dir() -> Option<PathBuf> {
3587    std::env::var_os("HOME")
3588        .filter(|value| !value.is_empty())
3589        .map(PathBuf::from)
3590        .or_else(dirs::home_dir)
3591}
3592
3593/// Resolve a state subdirectory, preferring the CodeWhale root if
3594/// it already exists, otherwise falling back to the legacy root.
3595///
3596/// This is the read-path resolver: it returns the primary path when
3597/// migration has occurred or on a fresh install, but keeps reading
3598/// from the legacy path for users who haven't migrated yet.
3599pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
3600    let primary = codewhale_home()?.join(subdir);
3601    if primary.exists() {
3602        return Ok(primary);
3603    }
3604    let legacy = legacy_deepseek_home()?.join(subdir);
3605    if legacy.exists() {
3606        return Ok(legacy);
3607    }
3608    // Neither exists — return primary for first-write creation.
3609    Ok(primary)
3610}
3611
3612/// Ensure a state subdirectory exists under the primary CodeWhale root,
3613/// creating it if necessary. This is the write-path resolver.
3614pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
3615    let dir = codewhale_home()?.join(subdir);
3616    std::fs::create_dir_all(&dir)
3617        .with_context(|| format!("failed to create {}/", dir.display()))?;
3618    Ok(dir)
3619}
3620
3621/// Resolve a project-local state subdirectory, preferring `.codewhale/`
3622/// when it exists, falling back to `.deepseek/` for legacy projects.
3623///
3624/// Returns `(true, path)` when the primary `.codewhale/` path is used,
3625/// `(false, path)` for the legacy fallback. The boolean helps callers
3626/// emit a deprecation notice on legacy paths.
3627pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> (bool, PathBuf) {
3628    let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
3629    if primary.exists() {
3630        return (true, primary);
3631    }
3632    let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
3633    (false, legacy)
3634}
3635
3636/// Ensure a project-local state subdirectory exists under `.codewhale/`,
3637/// creating it if necessary. Returns the directory path.
3638pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
3639    let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
3640    std::fs::create_dir_all(&dir)
3641        .with_context(|| format!("failed to create {}/", dir.display()))?;
3642    Ok(dir)
3643}
3644
3645pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
3646    let path = if let Some(path) = explicit {
3647        path
3648    } else if let Ok(path) = std::env::var("CODEWHALE_CONFIG_PATH") {
3649        let trimmed = path.trim();
3650        if !trimmed.is_empty() {
3651            PathBuf::from(trimmed)
3652        } else {
3653            return default_config_path();
3654        }
3655    } else if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
3656        let trimmed = path.trim();
3657        if !trimmed.is_empty() {
3658            PathBuf::from(trimmed)
3659        } else {
3660            return default_config_path();
3661        }
3662    } else {
3663        return default_config_path();
3664    };
3665    normalize_config_file_path(path)
3666}
3667
3668#[must_use]
3669pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
3670    config_path.with_file_name(PERMISSIONS_FILE_NAME)
3671}
3672
3673pub fn resolve_permissions_path(config_path: Option<PathBuf>) -> Result<PathBuf> {
3674    Ok(permissions_path_for_config_path(&resolve_config_path(
3675        config_path,
3676    )?))
3677}
3678
3679fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
3680    let permissions_path = permissions_path_for_config_path(config_path);
3681    if !permissions_path.exists() {
3682        return Ok(PermissionsToml::default());
3683    }
3684
3685    let raw = fs::read_to_string(&permissions_path).with_context(|| {
3686        format!(
3687            "failed to read permissions at {}",
3688            permissions_path.display()
3689        )
3690    })?;
3691    toml::from_str(&raw).with_context(|| {
3692        format!(
3693            "failed to parse permissions at {}",
3694            permissions_path.display()
3695        )
3696    })
3697}
3698
3699fn append_ask_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
3700    match item {
3701        toml_edit::Item::ArrayOfTables(rules) => {
3702            rules.push(ask_rule_table(rule));
3703            Ok(())
3704        }
3705        toml_edit::Item::Value(value) => {
3706            let Some(rules) = value.as_array_mut() else {
3707                bail!("`rules` in permissions.toml must be an array");
3708            };
3709            rules.push(toml_edit::Value::InlineTable(ask_rule_inline_table(rule)));
3710            Ok(())
3711        }
3712        _ => bail!("`rules` in permissions.toml must be an array"),
3713    }
3714}
3715
3716fn ask_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
3717    let mut table = toml_edit::Table::new();
3718    table["tool"] = toml_edit::value(rule.tool.clone());
3719    if let Some(command) = rule.command.as_deref() {
3720        table["command"] = toml_edit::value(command);
3721    }
3722    if let Some(path) = rule.path.as_deref() {
3723        table["path"] = toml_edit::value(path);
3724    }
3725    table
3726}
3727
3728fn ask_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
3729    let mut table = toml_edit::InlineTable::new();
3730    table.insert("tool", toml_edit::Value::from(rule.tool.clone()));
3731    if let Some(command) = rule.command.as_deref() {
3732        table.insert("command", toml_edit::Value::from(command));
3733    }
3734    if let Some(path) = rule.path.as_deref() {
3735        table.insert("path", toml_edit::Value::from(path));
3736    }
3737    table
3738}
3739
3740fn write_permissions_atomic(path: &Path, body: &[u8]) -> Result<()> {
3741    let parent = path.parent().with_context(|| {
3742        format!(
3743            "permissions path has no parent directory: {}",
3744            path.display()
3745        )
3746    })?;
3747    fs::create_dir_all(parent).with_context(|| {
3748        format!(
3749            "failed to create permissions directory {}",
3750            parent.display()
3751        )
3752    })?;
3753
3754    let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
3755        format!(
3756            "failed to create temporary permissions file in {}",
3757            parent.display()
3758        )
3759    })?;
3760    #[cfg(unix)]
3761    temporary
3762        .as_file()
3763        .set_permissions(fs::Permissions::from_mode(0o600))
3764        .with_context(|| {
3765            format!(
3766                "failed to secure temporary permissions file for {}",
3767                path.display()
3768            )
3769        })?;
3770    temporary
3771        .write_all(body)
3772        .with_context(|| format!("failed to write permissions at {}", path.display()))?;
3773    temporary
3774        .as_file()
3775        .sync_all()
3776        .with_context(|| format!("failed to sync permissions at {}", path.display()))?;
3777    temporary
3778        .persist(path)
3779        .map_err(|error| error.error)
3780        .with_context(|| format!("failed to replace permissions at {}", path.display()))?;
3781    Ok(())
3782}
3783
3784pub fn default_config_path() -> Result<PathBuf> {
3785    // Prefer ~/.codewhale/config.toml when it exists (fresh install or
3786    // migrated), otherwise fall back to ~/.deepseek/config.toml.
3787    let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
3788    if primary.exists() {
3789        return Ok(primary);
3790    }
3791    let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
3792    if legacy.exists() {
3793        return Ok(legacy);
3794    }
3795    // Neither exists — return primary so first write creates it there.
3796    Ok(primary)
3797}
3798
3799#[derive(Debug, Clone, PartialEq, Eq)]
3800pub struct ConfigMigration {
3801    pub legacy_path: PathBuf,
3802    pub primary_path: PathBuf,
3803}
3804
3805impl ConfigMigration {
3806    pub fn user_notice(&self) -> String {
3807        format!(
3808            "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.",
3809            self.legacy_path.display(),
3810            self.primary_path.display()
3811        )
3812    }
3813}
3814
3815/// v0.8.44: one-time migration from `~/.deepseek/config.toml` to
3816/// `~/.codewhale/config.toml`. Called on first launch after the config
3817/// is loaded; copies the legacy file if the primary doesn't exist yet.
3818/// Never overwrites an existing primary config.
3819pub fn migrate_config_if_needed() -> Result<Option<ConfigMigration>> {
3820    let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
3821    if primary.exists() {
3822        return Ok(None);
3823    }
3824    let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
3825    if !legacy.exists() {
3826        return Ok(None);
3827    }
3828    // Copy the config to the new home.
3829    if let Some(parent) = primary.parent() {
3830        std::fs::create_dir_all(parent).context("failed to create codewhale config directory")?;
3831    }
3832    std::fs::copy(&legacy, &primary)
3833        .context("failed to migrate config from deepseek to codewhale home")?;
3834    tracing::info!(
3835        "Migrated config from {} to {}",
3836        legacy.display(),
3837        primary.display()
3838    );
3839    Ok(Some(ConfigMigration {
3840        legacy_path: legacy,
3841        primary_path: primary,
3842    }))
3843}
3844
3845fn parse_bool(raw: &str) -> Result<bool> {
3846    match raw.trim().to_ascii_lowercase().as_str() {
3847        "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
3848        "0" | "false" | "no" | "off" | "disabled" => Ok(false),
3849        _ => bail!("invalid boolean '{raw}'"),
3850    }
3851}
3852
3853fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
3854    let mut headers = BTreeMap::new();
3855    for pair in raw.trim().split(',') {
3856        let pair = pair.trim();
3857        if pair.is_empty() {
3858            continue;
3859        }
3860        let Some((name, value)) = pair.split_once('=') else {
3861            bail!("invalid header pair '{pair}', expected name=value");
3862        };
3863        let name = name.trim();
3864        let value = value.trim();
3865        if name.is_empty() {
3866            bail!("header name cannot be empty");
3867        }
3868        if value.is_empty() {
3869            continue;
3870        }
3871        headers.insert(name.to_string(), value.to_string());
3872    }
3873    Ok(headers)
3874}
3875
3876fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
3877    if headers.is_empty() {
3878        return None;
3879    }
3880    Some(
3881        headers
3882            .iter()
3883            .map(|(name, value)| format!("{name}={value}"))
3884            .collect::<Vec<_>>()
3885            .join(","),
3886    )
3887}
3888
3889fn redact_secret(secret: &str) -> String {
3890    let chars: Vec<char> = secret.chars().collect();
3891    if chars.len() <= 16 {
3892        return "********".to_string();
3893    }
3894    let prefix: String = chars.iter().take(4).collect();
3895    let suffix: String = chars
3896        .iter()
3897        .rev()
3898        .take(4)
3899        .collect::<Vec<_>>()
3900        .into_iter()
3901        .rev()
3902        .collect();
3903    format!("{prefix}***{suffix}")
3904}
3905
3906#[must_use]
3907pub fn is_sensitive_config_key(key: &str) -> bool {
3908    key == "api_key" || key.ends_with(".api_key")
3909}
3910
3911fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
3912    if path.as_os_str().is_empty() {
3913        bail!("config path cannot be empty");
3914    }
3915    if path
3916        .components()
3917        .any(|component| matches!(component, Component::ParentDir))
3918    {
3919        bail!("config path cannot contain '..' components");
3920    }
3921    if path.file_name().is_none() {
3922        bail!("config path must include a file name");
3923    }
3924    if path.is_absolute() {
3925        return Ok(path);
3926    }
3927    Ok(std::env::current_dir()
3928        .context("failed to resolve current directory for config path")?
3929        .join(path))
3930}
3931
3932#[derive(Debug, Clone, Default)]
3933struct EnvRuntimeOverrides {
3934    provider: Option<ProviderKind>,
3935    provider_source: Option<&'static str>,
3936    model: Option<String>,
3937    volcengine_model: Option<String>,
3938    wanjie_ark_model: Option<String>,
3939    openrouter_model: Option<String>,
3940    moonshot_model: Option<String>,
3941    xiaomi_mimo_model: Option<String>,
3942    xiaomi_mimo_mode: Option<String>,
3943    novita_model: Option<String>,
3944    fireworks_model: Option<String>,
3945    arcee_model: Option<String>,
3946    output_mode: Option<String>,
3947    auth_mode: Option<String>,
3948    log_level: Option<String>,
3949    telemetry: Option<bool>,
3950    approval_policy: Option<String>,
3951    sandbox_mode: Option<String>,
3952    yolo: Option<bool>,
3953    verbosity: Option<String>,
3954    http_headers: Option<BTreeMap<String, String>>,
3955    deepseek_base_url: Option<String>,
3956    nvidia_base_url: Option<String>,
3957    openai_base_url: Option<String>,
3958    atlascloud_base_url: Option<String>,
3959    volcengine_base_url: Option<String>,
3960    wanjie_ark_base_url: Option<String>,
3961    openrouter_base_url: Option<String>,
3962    xiaomi_mimo_base_url: Option<String>,
3963    novita_base_url: Option<String>,
3964    fireworks_base_url: Option<String>,
3965    siliconflow_base_url: Option<String>,
3966    siliconflow_model: Option<String>,
3967    arcee_base_url: Option<String>,
3968    moonshot_base_url: Option<String>,
3969    sglang_base_url: Option<String>,
3970    vllm_base_url: Option<String>,
3971    ollama_base_url: Option<String>,
3972    huggingface_base_url: Option<String>,
3973    huggingface_model: Option<String>,
3974    together_base_url: Option<String>,
3975    together_model: Option<String>,
3976    openai_codex_base_url: Option<String>,
3977    openai_codex_model: Option<String>,
3978    anthropic_base_url: Option<String>,
3979    anthropic_model: Option<String>,
3980    zai_base_url: Option<String>,
3981    zai_model: Option<String>,
3982    stepfun_base_url: Option<String>,
3983    stepfun_model: Option<String>,
3984    minimax_base_url: Option<String>,
3985    minimax_model: Option<String>,
3986    deepinfra_base_url: Option<String>,
3987    deepinfra_model: Option<String>,
3988}
3989
3990impl EnvRuntimeOverrides {
3991    fn load() -> Self {
3992        let (provider, provider_source) = Self::load_provider();
3993        Self {
3994            provider,
3995            provider_source,
3996            model: std::env::var("CODEWHALE_MODEL")
3997                .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
3998                .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
3999                .ok()
4000                .filter(|v| !v.trim().is_empty()),
4001            volcengine_model: std::env::var("VOLCENGINE_MODEL")
4002                .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
4003                .ok()
4004                .filter(|v| !v.trim().is_empty()),
4005            wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
4006                .or_else(|_| std::env::var("WANJIE_MODEL"))
4007                .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
4008                .ok()
4009                .filter(|v| !v.trim().is_empty()),
4010            openrouter_model: std::env::var("OPENROUTER_MODEL")
4011                .ok()
4012                .filter(|v| !v.trim().is_empty()),
4013            moonshot_model: std::env::var("MOONSHOT_MODEL")
4014                .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
4015                .or_else(|_| std::env::var("KIMI_MODEL"))
4016                .ok()
4017                .filter(|v| !v.trim().is_empty()),
4018            xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
4019                .or_else(|_| std::env::var("MIMO_MODEL"))
4020                .ok()
4021                .filter(|v| !v.trim().is_empty()),
4022            xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
4023                .or_else(|_| std::env::var("MIMO_MODE"))
4024                .ok()
4025                .filter(|v| !v.trim().is_empty()),
4026            novita_model: std::env::var("NOVITA_MODEL")
4027                .ok()
4028                .filter(|v| !v.trim().is_empty()),
4029            fireworks_model: std::env::var("FIREWORKS_MODEL")
4030                .ok()
4031                .filter(|v| !v.trim().is_empty()),
4032            arcee_model: std::env::var("ARCEE_MODEL")
4033                .ok()
4034                .filter(|v| !v.trim().is_empty()),
4035            verbosity: std::env::var("CODEWHALE_VERBOSITY")
4036                .or_else(|_| std::env::var("DEEPSEEK_VERBOSITY"))
4037                .ok(),
4038            output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(),
4039            auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(),
4040            log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(),
4041            telemetry: std::env::var("DEEPSEEK_TELEMETRY")
4042                .ok()
4043                .and_then(|v| match parse_bool(&v) {
4044                    Ok(b) => Some(b),
4045                    Err(_) => {
4046                        tracing::warn!("Invalid DEEPSEEK_TELEMETRY value '{v}', expected true/false");
4047                        None
4048                    }
4049                }),
4050            approval_policy: std::env::var("DEEPSEEK_APPROVAL_POLICY").ok(),
4051            sandbox_mode: std::env::var("DEEPSEEK_SANDBOX_MODE").ok(),
4052            yolo: std::env::var("DEEPSEEK_YOLO")
4053                .ok()
4054                .and_then(|v| match parse_bool(&v) {
4055                    Ok(b) => Some(b),
4056                    Err(_) => {
4057                        tracing::warn!("Invalid DEEPSEEK_YOLO value '{v}', expected true/false");
4058                        None
4059                    }
4060                }),
4061            http_headers: std::env::var("DEEPSEEK_HTTP_HEADERS")
4062                .ok()
4063                .and_then(|value| match parse_http_headers(&value) {
4064                    Ok(h) => Some(h),
4065                    Err(_) => {
4066                        tracing::warn!("Invalid DEEPSEEK_HTTP_HEADERS value, expected format: header1=val1,header2=val2");
4067                        None
4068                    }
4069                })
4070                .filter(|headers| !headers.is_empty()),
4071            deepseek_base_url: std::env::var("CODEWHALE_BASE_URL")
4072                .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
4073                .ok()
4074                .filter(|v| !v.trim().is_empty()),
4075            nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
4076                .or_else(|_| std::env::var("NIM_BASE_URL"))
4077                .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
4078                .ok()
4079                .filter(|v| !v.trim().is_empty()),
4080            openai_base_url: std::env::var("OPENAI_BASE_URL")
4081                .ok()
4082                .filter(|v| !v.trim().is_empty()),
4083            atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
4084                .ok()
4085                .filter(|v| !v.trim().is_empty()),
4086            volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
4087                .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
4088                .or_else(|_| std::env::var("ARK_BASE_URL"))
4089                .ok()
4090                .filter(|v| !v.trim().is_empty()),
4091            wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
4092                .or_else(|_| std::env::var("WANJIE_BASE_URL"))
4093                .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
4094                .ok()
4095                .filter(|v| !v.trim().is_empty()),
4096            openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
4097                .ok()
4098                .filter(|v| !v.trim().is_empty()),
4099            xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
4100                .or_else(|_| std::env::var("MIMO_BASE_URL"))
4101                .ok()
4102                .filter(|v| !v.trim().is_empty()),
4103            novita_base_url: std::env::var("NOVITA_BASE_URL")
4104                .ok()
4105                .filter(|v| !v.trim().is_empty()),
4106            fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
4107                .ok()
4108                .filter(|v| !v.trim().is_empty()),
4109            siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
4110                .ok()
4111                .filter(|v| !v.trim().is_empty()),
4112            siliconflow_model: std::env::var("SILICONFLOW_MODEL")
4113                .ok()
4114                .filter(|v| !v.trim().is_empty()),
4115            arcee_base_url: std::env::var("ARCEE_BASE_URL")
4116                .ok()
4117                .filter(|v| !v.trim().is_empty()),
4118            moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
4119                .or_else(|_| std::env::var("KIMI_BASE_URL"))
4120                .ok()
4121                .filter(|v| !v.trim().is_empty()),
4122            sglang_base_url: std::env::var("SGLANG_BASE_URL")
4123                .ok()
4124                .filter(|v| !v.trim().is_empty()),
4125            vllm_base_url: std::env::var("VLLM_BASE_URL")
4126                .ok()
4127                .filter(|v| !v.trim().is_empty()),
4128            ollama_base_url: std::env::var("OLLAMA_BASE_URL")
4129                .ok()
4130                .filter(|v| !v.trim().is_empty()),
4131            huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
4132                .or_else(|_| std::env::var("HF_BASE_URL"))
4133                .ok()
4134                .filter(|v| !v.trim().is_empty()),
4135            huggingface_model: std::env::var("HUGGINGFACE_MODEL")
4136                .or_else(|_| std::env::var("HF_MODEL"))
4137                .ok()
4138                .filter(|v| !v.trim().is_empty()),
4139            together_base_url: std::env::var("TOGETHER_BASE_URL")
4140                .ok()
4141                .filter(|v| !v.trim().is_empty()),
4142            together_model: std::env::var("TOGETHER_MODEL")
4143                .ok()
4144                .filter(|v| !v.trim().is_empty()),
4145            openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
4146                .or_else(|_| std::env::var("CODEX_BASE_URL"))
4147                .ok()
4148                .filter(|v| !v.trim().is_empty()),
4149            openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
4150                .or_else(|_| std::env::var("CODEX_MODEL"))
4151                .ok()
4152                .filter(|v| !v.trim().is_empty()),
4153            anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
4154                .ok()
4155                .filter(|v| !v.trim().is_empty()),
4156            anthropic_model: std::env::var("ANTHROPIC_MODEL")
4157                .ok()
4158                .filter(|v| !v.trim().is_empty()),
4159            zai_base_url: std::env::var("ZAI_BASE_URL")
4160                .or_else(|_| std::env::var("Z_AI_BASE_URL"))
4161                .ok()
4162                .filter(|v| !v.trim().is_empty()),
4163            zai_model: std::env::var("ZAI_MODEL")
4164                .or_else(|_| std::env::var("Z_AI_MODEL"))
4165                .ok()
4166                .filter(|v| !v.trim().is_empty()),
4167            stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
4168                .or_else(|_| std::env::var("STEP_BASE_URL"))
4169                .ok()
4170                .filter(|v| !v.trim().is_empty()),
4171            stepfun_model: std::env::var("STEPFUN_MODEL")
4172                .or_else(|_| std::env::var("STEP_MODEL"))
4173                .ok()
4174                .filter(|v| !v.trim().is_empty()),
4175            minimax_base_url: std::env::var("MINIMAX_BASE_URL")
4176                .ok()
4177                .filter(|v| !v.trim().is_empty()),
4178            minimax_model: std::env::var("MINIMAX_MODEL")
4179                .ok()
4180                .filter(|v| !v.trim().is_empty()),
4181            deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
4182                .ok()
4183                .filter(|v| !v.trim().is_empty()),
4184            deepinfra_model: std::env::var("DEEPINFRA_MODEL")
4185                .ok()
4186                .filter(|v| !v.trim().is_empty()),
4187        }
4188    }
4189
4190    fn load_provider() -> (Option<ProviderKind>, Option<&'static str>) {
4191        if let Ok(value) = std::env::var("CODEWHALE_PROVIDER") {
4192            let parsed = ProviderKind::parse(&value);
4193            return (parsed, parsed.map(|_| "CODEWHALE_PROVIDER"));
4194        }
4195
4196        if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
4197            let parsed = ProviderKind::parse(&value);
4198            return (parsed, parsed.map(|_| "DEEPSEEK_PROVIDER"));
4199        }
4200
4201        (None, None)
4202    }
4203
4204    fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
4205        // Defaults belong in the resolver's final fallback so config-file
4206        // values (`providers.<name>.base_url`) still win when env is unset.
4207        match provider {
4208            ProviderKind::Deepseek => self.deepseek_base_url.clone(),
4209            ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
4210            ProviderKind::Openai => self.openai_base_url.clone(),
4211            ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
4212            ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
4213            ProviderKind::Volcengine => self.volcengine_base_url.clone(),
4214            ProviderKind::Openrouter => self.openrouter_base_url.clone(),
4215            ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
4216            ProviderKind::Novita => self.novita_base_url.clone(),
4217            ProviderKind::Fireworks => self.fireworks_base_url.clone(),
4218            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
4219                self.siliconflow_base_url.clone()
4220            }
4221            ProviderKind::Arcee => self.arcee_base_url.clone(),
4222            ProviderKind::Moonshot => self.moonshot_base_url.clone(),
4223            ProviderKind::Sglang => self.sglang_base_url.clone(),
4224            ProviderKind::Vllm => self.vllm_base_url.clone(),
4225            ProviderKind::Ollama => self.ollama_base_url.clone(),
4226            ProviderKind::Huggingface => self.huggingface_base_url.clone(),
4227            ProviderKind::Together => self.together_base_url.clone(),
4228            ProviderKind::OpenaiCodex => self.openai_codex_base_url.clone(),
4229            ProviderKind::Anthropic => self.anthropic_base_url.clone(),
4230            ProviderKind::Zai => self.zai_base_url.clone(),
4231            ProviderKind::Stepfun => self.stepfun_base_url.clone(),
4232            ProviderKind::Minimax => self.minimax_base_url.clone(),
4233            ProviderKind::Deepinfra => self.deepinfra_base_url.clone(),
4234        }
4235    }
4236
4237    fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
4238        let model = match provider {
4239            ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
4240            ProviderKind::Volcengine => self.volcengine_model.clone(),
4241            ProviderKind::Openrouter => self.openrouter_model.clone(),
4242            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
4243                self.siliconflow_model.clone()
4244            }
4245            ProviderKind::Arcee => self.arcee_model.clone(),
4246            ProviderKind::Moonshot => self.moonshot_model.clone(),
4247            ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
4248            ProviderKind::Novita => self.novita_model.clone(),
4249            ProviderKind::Fireworks => self.fireworks_model.clone(),
4250            ProviderKind::Huggingface => self.huggingface_model.clone(),
4251            ProviderKind::Together => self.together_model.clone(),
4252            ProviderKind::OpenaiCodex => self.openai_codex_model.clone(),
4253            ProviderKind::Anthropic => self.anthropic_model.clone(),
4254            ProviderKind::Zai => self.zai_model.clone(),
4255            ProviderKind::Stepfun => self.stepfun_model.clone(),
4256            ProviderKind::Minimax => self.minimax_model.clone(),
4257            ProviderKind::Deepinfra => self.deepinfra_model.clone(),
4258            _ => None,
4259        }?;
4260
4261        if provider_preserves_custom_base_url_model(provider, base_url) {
4262            Some(model.trim().to_string())
4263        } else {
4264            Some(normalize_model_for_provider(provider, &model))
4265        }
4266    }
4267}
4268
4269#[cfg(test)]
4270mod tests {
4271    use super::*;
4272    use std::env;
4273    use std::ffi::OsString;
4274    use std::sync::Arc;
4275    use std::sync::{Mutex, OnceLock};
4276
4277    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
4278        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
4279        LOCK.get_or_init(|| Mutex::new(()))
4280            .lock()
4281            .unwrap_or_else(std::sync::PoisonError::into_inner)
4282    }
4283
4284    #[test]
4285    fn network_policy_toml_deserializes_proxy_hosts() {
4286        let policy: NetworkPolicyToml = toml::from_str(
4287            r#"
4288            default = "allow"
4289            proxy = ["github.com", ".githubusercontent.com"]
4290            "#,
4291        )
4292        .expect("network policy toml");
4293
4294        assert_eq!(policy.default, "allow");
4295        assert_eq!(policy.proxy, ["github.com", ".githubusercontent.com"]);
4296        assert!(policy.audit);
4297    }
4298
4299    #[test]
4300    fn permissions_toml_deserializes_typed_ask_rules() {
4301        let permissions: PermissionsToml = toml::from_str(
4302            r#"
4303            [[rules]]
4304            tool = "exec_shell"
4305            command = "cargo test"
4306
4307            [[rules]]
4308            tool = "read_file"
4309            path = "secrets/api_key.txt"
4310            "#,
4311        )
4312        .expect("permissions toml");
4313
4314        assert_eq!(
4315            permissions.rules,
4316            vec![
4317                ToolAskRule::exec_shell("cargo test"),
4318                ToolAskRule::file_path("read_file", "secrets/api_key.txt"),
4319            ]
4320        );
4321    }
4322
4323    #[test]
4324    fn permissions_toml_rejects_typed_allow_deny_shape() {
4325        let err = toml::from_str::<PermissionsToml>(
4326            r#"
4327            [[rules]]
4328            tool = "exec_shell"
4329            decision = "allow"
4330            command = "cargo test"
4331            "#,
4332        )
4333        .expect_err("permissions.toml should be ask-only in this slice");
4334
4335        assert!(err.message().contains("unknown field"));
4336    }
4337
4338    #[test]
4339    fn hotbar_defaults_when_config_is_absent() {
4340        let config = ConfigToml::default();
4341
4342        let resolved = config.resolve_hotbar_bindings(&DEFAULT_HOTBAR_ACTIONS);
4343
4344        assert_eq!(resolved.warnings, Vec::new());
4345        assert_eq!(resolved.bindings, default_hotbar_bindings());
4346        assert_eq!(
4347            resolved
4348                .bindings
4349                .iter()
4350                .map(|binding| (binding.slot, binding.action.as_str()))
4351                .collect::<Vec<_>>(),
4352            vec![
4353                (1, "voice.toggle"),
4354                (2, "session.compact"),
4355                (3, "mode.plan"),
4356                (4, "mode.agent"),
4357                (5, "mode.yolo"),
4358                (6, "palette.open"),
4359                (7, "sidebar.toggle"),
4360                (8, "trust.toggle"),
4361            ]
4362        );
4363    }
4364
4365    #[test]
4366    fn hotbar_tables_parse_and_round_trip() {
4367        let config: ConfigToml = toml::from_str(
4368            r#"
4369[[hotbar]]
4370slot = 1
4371label = "Plan"
4372action = "mode.plan"
4373
4374[[hotbar]]
4375slot = 2
4376action = "session.compact"
4377"#,
4378        )
4379        .expect("parse hotbar tables");
4380
4381        let resolved = config.resolve_hotbar_bindings(&["mode.plan", "session.compact"]);
4382
4383        assert_eq!(
4384            resolved.bindings,
4385            vec![
4386                HotbarBinding {
4387                    slot: 1,
4388                    action: "mode.plan".to_string(),
4389                    label: Some("Plan".to_string()),
4390                },
4391                HotbarBinding {
4392                    slot: 2,
4393                    action: "session.compact".to_string(),
4394                    label: None,
4395                },
4396            ]
4397        );
4398        assert_eq!(resolved.warnings, Vec::new());
4399
4400        let serialized = toml::to_string_pretty(&config).expect("serialize config");
4401        let round_tripped: ConfigToml =
4402            toml::from_str(&serialized).expect("deserialize serialized config");
4403        assert_eq!(round_tripped.hotbar, config.hotbar);
4404    }
4405
4406    #[test]
4407    fn hotbar_validation_warns_without_dropping_unknown_actions() {
4408        let config: ConfigToml = toml::from_str(
4409            r#"
4410[[hotbar]]
4411slot = 0
4412action = "mode.plan"
4413
4414[[hotbar]]
4415slot = 2
4416action = "mode.plan"
4417
4418[[hotbar]]
4419slot = 2
4420action = "custom.action"
4421
4422[[hotbar]]
4423slot = 9
4424action = "mode.agent"
4425"#,
4426        )
4427        .expect("parse hotbar tables");
4428
4429        let resolved = config.resolve_hotbar_bindings(&["mode.plan", "mode.agent"]);
4430
4431        assert_eq!(
4432            resolved.bindings,
4433            vec![HotbarBinding {
4434                slot: 2,
4435                action: "custom.action".to_string(),
4436                label: None,
4437            }]
4438        );
4439        assert_eq!(
4440            resolved.warnings,
4441            vec![
4442                HotbarConfigWarning::SlotOutOfRange {
4443                    slot: 0,
4444                    action: "mode.plan".to_string(),
4445                },
4446                HotbarConfigWarning::UnknownAction {
4447                    slot: 2,
4448                    action: "custom.action".to_string(),
4449                },
4450                HotbarConfigWarning::DuplicateSlot {
4451                    slot: 2,
4452                    previous_action: "mode.plan".to_string(),
4453                    replacement_action: "custom.action".to_string(),
4454                },
4455                HotbarConfigWarning::SlotOutOfRange {
4456                    slot: 9,
4457                    action: "mode.agent".to_string(),
4458                },
4459            ]
4460        );
4461        assert!(resolved.warnings[1].to_string().contains("keeping binding"));
4462    }
4463
4464    #[test]
4465    fn config_store_loads_sibling_permissions_toml() {
4466        use std::time::{SystemTime, UNIX_EPOCH};
4467
4468        let unique = SystemTime::now()
4469            .duration_since(UNIX_EPOCH)
4470            .expect("clock")
4471            .as_nanos();
4472        let dir = std::env::temp_dir().join(format!(
4473            "codewhale-permissions-schema-{}-{unique}",
4474            std::process::id()
4475        ));
4476        fs::create_dir_all(&dir).expect("mkdir");
4477        let config_path = dir.join(CONFIG_FILE_NAME);
4478        fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
4479        fs::write(
4480            dir.join(PERMISSIONS_FILE_NAME),
4481            r#"
4482            [[rules]]
4483            tool = "exec_shell"
4484            command = "cargo test"
4485
4486            [[rules]]
4487            tool = "read_file"
4488            path = "secrets/api_key.txt"
4489            "#,
4490        )
4491        .expect("write permissions");
4492
4493        let store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
4494
4495        assert_eq!(store.config.model.as_deref(), Some("deepseek-v4-flash"));
4496        assert_eq!(
4497            store.permissions().rules.as_slice(),
4498            &[
4499                ToolAskRule::exec_shell("cargo test"),
4500                ToolAskRule::file_path("read_file", "secrets/api_key.txt"),
4501            ]
4502        );
4503        assert_eq!(
4504            store.permissions_path(),
4505            config_path.with_file_name(PERMISSIONS_FILE_NAME)
4506        );
4507
4508        let _ = fs::remove_dir_all(dir);
4509    }
4510
4511    #[test]
4512    fn config_store_loads_permissions_even_when_config_is_absent() {
4513        use std::time::{SystemTime, UNIX_EPOCH};
4514
4515        let unique = SystemTime::now()
4516            .duration_since(UNIX_EPOCH)
4517            .expect("clock")
4518            .as_nanos();
4519        let dir = std::env::temp_dir().join(format!(
4520            "codewhale-permissions-only-{}-{unique}",
4521            std::process::id()
4522        ));
4523        fs::create_dir_all(&dir).expect("mkdir");
4524        let config_path = dir.join(CONFIG_FILE_NAME);
4525        fs::write(
4526            dir.join(PERMISSIONS_FILE_NAME),
4527            r#"
4528            [[rules]]
4529            tool = "exec_shell"
4530            command = "cargo check"
4531            "#,
4532        )
4533        .expect("write permissions");
4534
4535        let store = ConfigStore::load(Some(config_path)).expect("load config store");
4536
4537        assert!(store.config.model.is_none());
4538        assert_eq!(
4539            store.permissions().rules.as_slice(),
4540            &[ToolAskRule::exec_shell("cargo check")]
4541        );
4542
4543        let _ = fs::remove_dir_all(dir);
4544    }
4545
4546    #[test]
4547    fn config_store_exec_policy_engine_uses_sibling_permissions() {
4548        use std::time::{SystemTime, UNIX_EPOCH};
4549
4550        let unique = SystemTime::now()
4551            .duration_since(UNIX_EPOCH)
4552            .expect("clock")
4553            .as_nanos();
4554        let dir = std::env::temp_dir().join(format!(
4555            "codewhale-permissions-engine-{}-{unique}",
4556            std::process::id()
4557        ));
4558        fs::create_dir_all(&dir).expect("mkdir");
4559        let config_path = dir.join(CONFIG_FILE_NAME);
4560        fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
4561        fs::write(
4562            dir.join(PERMISSIONS_FILE_NAME),
4563            r#"
4564            [[rules]]
4565            tool = "exec_shell"
4566            command = "cargo test"
4567            "#,
4568        )
4569        .expect("write permissions");
4570
4571        let store = ConfigStore::load(Some(config_path)).expect("load config store");
4572        let decision = store
4573            .exec_policy_engine()
4574            .check(codewhale_execpolicy::ExecPolicyContext {
4575                command: "cargo test --workspace",
4576                cwd: "/workspace",
4577                tool: Some("exec_shell"),
4578                path: None,
4579                ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
4580                sandbox_mode: Some("workspace-write"),
4581            })
4582            .expect("policy check");
4583
4584        assert!(decision.allow);
4585        assert!(decision.requires_approval);
4586        assert_eq!(
4587            decision.matched_rule.as_deref(),
4588            Some("tool=exec_shell command=cargo test")
4589        );
4590
4591        let _ = fs::remove_dir_all(dir);
4592    }
4593
4594    #[test]
4595    fn config_store_appends_ask_rules_without_losing_comments_or_duplicates() {
4596        let dir = tempfile::tempdir().expect("tempdir");
4597        let config_path = dir.path().join(CONFIG_FILE_NAME);
4598        let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
4599        fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
4600        fs::write(
4601            &permissions_path,
4602            r#"# keep this permission note
4603[[rules]]
4604tool = "exec_shell"
4605command = "cargo check"
4606"#,
4607        )
4608        .expect("write permissions");
4609
4610        let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
4611        let existing = ToolAskRule::exec_shell("cargo check");
4612        let added_rule = ToolAskRule::file_path("read_file", "docs/README.md");
4613        let added = store
4614            .append_ask_rules(&[existing, added_rule.clone(), added_rule.clone()])
4615            .expect("append ask rules");
4616
4617        assert_eq!(added, 1);
4618        assert_eq!(
4619            store.permissions().rules,
4620            vec![ToolAskRule::exec_shell("cargo check"), added_rule.clone(),]
4621        );
4622        let body = fs::read_to_string(&permissions_path).expect("read permissions");
4623        assert!(body.contains("# keep this permission note"));
4624        assert_eq!(body.matches("docs/README.md").count(), 1);
4625        assert!(!body.contains("decision"));
4626
4627        let before_duplicate_append = body;
4628        assert_eq!(
4629            store
4630                .append_ask_rules(&[added_rule])
4631                .expect("dedupe ask rule"),
4632            0
4633        );
4634        assert_eq!(
4635            fs::read_to_string(&permissions_path).expect("read unchanged permissions"),
4636            before_duplicate_append
4637        );
4638
4639        let reloaded = ConfigStore::load(Some(dir.path().join(CONFIG_FILE_NAME)))
4640            .expect("reload config store");
4641        assert_eq!(reloaded.permissions(), store.permissions());
4642    }
4643
4644    #[test]
4645    fn config_store_appends_ask_rule_to_inline_rules_array() {
4646        let dir = tempfile::tempdir().expect("tempdir");
4647        let config_path = dir.path().join(CONFIG_FILE_NAME);
4648        let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
4649        fs::write(
4650            &permissions_path,
4651            "# inline rules stay valid\nrules = [{ tool = \"exec_shell\", command = \"cargo check\" }]\n",
4652        )
4653        .expect("write permissions");
4654
4655        let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
4656        assert_eq!(
4657            store
4658                .append_ask_rules(&[ToolAskRule::file_path("read_file", "README.md")])
4659                .expect("append inline ask rule"),
4660            1
4661        );
4662
4663        let body = fs::read_to_string(&permissions_path).expect("read permissions");
4664        assert!(body.contains("# inline rules stay valid"));
4665        let parsed: PermissionsToml = toml::from_str(&body).expect("parse persisted permissions");
4666        assert_eq!(
4667            parsed.rules,
4668            vec![
4669                ToolAskRule::exec_shell("cargo check"),
4670                ToolAskRule::file_path("read_file", "README.md"),
4671            ]
4672        );
4673    }
4674
4675    #[test]
4676    fn config_store_does_not_overwrite_invalid_permissions_file() {
4677        let dir = tempfile::tempdir().expect("tempdir");
4678        let config_path = dir.path().join(CONFIG_FILE_NAME);
4679        let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
4680        let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
4681        let invalid = "rules = \"not-an-array\"\n";
4682        fs::write(&permissions_path, invalid).expect("write invalid permissions");
4683
4684        let error = store
4685            .append_ask_rules(&[ToolAskRule::exec_shell("cargo test")])
4686            .expect_err("invalid permissions should fail");
4687
4688        assert!(error.to_string().contains("failed to parse permissions"));
4689        assert_eq!(
4690            fs::read_to_string(&permissions_path).expect("read invalid permissions"),
4691            invalid
4692        );
4693        assert!(store.permissions().is_empty());
4694    }
4695
4696    #[test]
4697    fn duplicate_append_refreshes_permissions_changed_on_disk() {
4698        let dir = tempfile::tempdir().expect("tempdir");
4699        let config_path = dir.path().join(CONFIG_FILE_NAME);
4700        let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
4701        let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
4702        fs::write(
4703            permissions_path,
4704            "[[rules]]\ntool = \"exec_shell\"\ncommand = \"cargo check\"\n",
4705        )
4706        .expect("write external permissions update");
4707
4708        assert_eq!(
4709            store
4710                .append_ask_rules(&[ToolAskRule::exec_shell("cargo check")])
4711                .expect("dedupe external ask rule"),
4712            0
4713        );
4714        assert_eq!(
4715            store.permissions().rules,
4716            vec![ToolAskRule::exec_shell("cargo check")]
4717        );
4718    }
4719
4720    #[cfg(unix)]
4721    #[test]
4722    fn config_store_secures_persisted_permissions_file() {
4723        let dir = tempfile::tempdir().expect("tempdir");
4724        let config_path = dir.path().join(CONFIG_FILE_NAME);
4725        let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
4726        let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
4727
4728        store
4729            .append_ask_rules(&[ToolAskRule::exec_shell("cargo test")])
4730            .expect("append ask rule");
4731
4732        let mode = fs::metadata(permissions_path)
4733            .expect("permissions metadata")
4734            .permissions()
4735            .mode()
4736            & 0o777;
4737        assert_eq!(mode, 0o600);
4738    }
4739
4740    struct EnvGuard {
4741        deepseek_api_key: Option<OsString>,
4742        deepseek_base_url: Option<OsString>,
4743        deepseek_http_headers: Option<OsString>,
4744        deepseek_model: Option<OsString>,
4745        deepseek_default_text_model: Option<OsString>,
4746        deepseek_provider: Option<OsString>,
4747        deepseek_auth_mode: Option<OsString>,
4748        nvidia_api_key: Option<OsString>,
4749        nvidia_nim_api_key: Option<OsString>,
4750        nim_base_url: Option<OsString>,
4751        nvidia_base_url: Option<OsString>,
4752        nvidia_nim_base_url: Option<OsString>,
4753        openrouter_api_key: Option<OsString>,
4754        openrouter_base_url: Option<OsString>,
4755        openrouter_model: Option<OsString>,
4756        xiaomi_mimo_token_plan_api_key: Option<OsString>,
4757        mimo_token_plan_api_key: Option<OsString>,
4758        xiaomi_mimo_api_key: Option<OsString>,
4759        xiaomi_api_key: Option<OsString>,
4760        mimo_api_key: Option<OsString>,
4761        xiaomi_mimo_base_url: Option<OsString>,
4762        mimo_base_url: Option<OsString>,
4763        xiaomi_mimo_model: Option<OsString>,
4764        mimo_model: Option<OsString>,
4765        xiaomi_mimo_mode: Option<OsString>,
4766        mimo_mode: Option<OsString>,
4767        wanjie_ark_api_key: Option<OsString>,
4768        volcengine_api_key: Option<OsString>,
4769        volcengine_ark_api_key: Option<OsString>,
4770        ark_api_key: Option<OsString>,
4771        volcengine_base_url: Option<OsString>,
4772        volcengine_ark_base_url: Option<OsString>,
4773        ark_base_url: Option<OsString>,
4774        wanjie_ark_base_url: Option<OsString>,
4775        wanjie_base_url: Option<OsString>,
4776        wanjie_maas_base_url: Option<OsString>,
4777        volcengine_model: Option<OsString>,
4778        volcengine_ark_model: Option<OsString>,
4779        wanjie_ark_model: Option<OsString>,
4780        wanjie_model: Option<OsString>,
4781        wanjie_maas_model: Option<OsString>,
4782        novita_api_key: Option<OsString>,
4783        novita_base_url: Option<OsString>,
4784        novita_model: Option<OsString>,
4785        fireworks_api_key: Option<OsString>,
4786        fireworks_base_url: Option<OsString>,
4787        fireworks_model: Option<OsString>,
4788        siliconflow_api_key: Option<OsString>,
4789        siliconflow_base_url: Option<OsString>,
4790        siliconflow_model: Option<OsString>,
4791        arcee_api_key: Option<OsString>,
4792        arcee_base_url: Option<OsString>,
4793        arcee_model: Option<OsString>,
4794        moonshot_api_key: Option<OsString>,
4795        moonshot_base_url: Option<OsString>,
4796        moonshot_model: Option<OsString>,
4797        kimi_api_key: Option<OsString>,
4798        kimi_base_url: Option<OsString>,
4799        kimi_model: Option<OsString>,
4800        kimi_model_name: Option<OsString>,
4801        zai_api_key: Option<OsString>,
4802        z_ai_api_key: Option<OsString>,
4803        zai_base_url: Option<OsString>,
4804        zai_model: Option<OsString>,
4805        stepfun_api_key: Option<OsString>,
4806        step_api_key: Option<OsString>,
4807        stepfun_base_url: Option<OsString>,
4808        stepfun_model: Option<OsString>,
4809        minimax_api_key: Option<OsString>,
4810        minimax_base_url: Option<OsString>,
4811        minimax_model: Option<OsString>,
4812        sglang_api_key: Option<OsString>,
4813        sglang_base_url: Option<OsString>,
4814        vllm_api_key: Option<OsString>,
4815        vllm_base_url: Option<OsString>,
4816        ollama_api_key: Option<OsString>,
4817        ollama_base_url: Option<OsString>,
4818        huggingface_api_key: Option<OsString>,
4819        huggingface_token: Option<OsString>,
4820        huggingface_base_url: Option<OsString>,
4821        hf_base_url: Option<OsString>,
4822        huggingface_model: Option<OsString>,
4823        hf_model: Option<OsString>,
4824        codewhale_provider: Option<OsString>,
4825        codewhale_model: Option<OsString>,
4826        codewhale_base_url: Option<OsString>,
4827    }
4828
4829    impl EnvGuard {
4830        fn without_deepseek_runtime_overrides() -> Self {
4831            let guard = Self {
4832                deepseek_api_key: env::var_os("DEEPSEEK_API_KEY"),
4833                deepseek_base_url: env::var_os("DEEPSEEK_BASE_URL"),
4834                deepseek_http_headers: env::var_os("DEEPSEEK_HTTP_HEADERS"),
4835                deepseek_model: env::var_os("DEEPSEEK_MODEL"),
4836                deepseek_default_text_model: env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL"),
4837                deepseek_provider: env::var_os("DEEPSEEK_PROVIDER"),
4838                deepseek_auth_mode: env::var_os("DEEPSEEK_AUTH_MODE"),
4839                codewhale_provider: env::var_os("CODEWHALE_PROVIDER"),
4840                codewhale_model: env::var_os("CODEWHALE_MODEL"),
4841                codewhale_base_url: env::var_os("CODEWHALE_BASE_URL"),
4842                nvidia_api_key: env::var_os("NVIDIA_API_KEY"),
4843                nvidia_nim_api_key: env::var_os("NVIDIA_NIM_API_KEY"),
4844                nim_base_url: env::var_os("NIM_BASE_URL"),
4845                nvidia_base_url: env::var_os("NVIDIA_BASE_URL"),
4846                nvidia_nim_base_url: env::var_os("NVIDIA_NIM_BASE_URL"),
4847                openrouter_api_key: env::var_os("OPENROUTER_API_KEY"),
4848                openrouter_base_url: env::var_os("OPENROUTER_BASE_URL"),
4849                openrouter_model: env::var_os("OPENROUTER_MODEL"),
4850                xiaomi_mimo_token_plan_api_key: env::var_os("XIAOMI_MIMO_TOKEN_PLAN_API_KEY"),
4851                mimo_token_plan_api_key: env::var_os("MIMO_TOKEN_PLAN_API_KEY"),
4852                xiaomi_mimo_api_key: env::var_os("XIAOMI_MIMO_API_KEY"),
4853                xiaomi_api_key: env::var_os("XIAOMI_API_KEY"),
4854                mimo_api_key: env::var_os("MIMO_API_KEY"),
4855                xiaomi_mimo_base_url: env::var_os("XIAOMI_MIMO_BASE_URL"),
4856                mimo_base_url: env::var_os("MIMO_BASE_URL"),
4857                xiaomi_mimo_model: env::var_os("XIAOMI_MIMO_MODEL"),
4858                mimo_model: env::var_os("MIMO_MODEL"),
4859                xiaomi_mimo_mode: env::var_os("XIAOMI_MIMO_MODE"),
4860                mimo_mode: env::var_os("MIMO_MODE"),
4861                wanjie_ark_api_key: env::var_os("WANJIE_ARK_API_KEY"),
4862                volcengine_api_key: env::var_os("VOLCENGINE_API_KEY"),
4863                volcengine_ark_api_key: env::var_os("VOLCENGINE_ARK_API_KEY"),
4864                ark_api_key: env::var_os("ARK_API_KEY"),
4865                volcengine_base_url: env::var_os("VOLCENGINE_BASE_URL"),
4866                volcengine_ark_base_url: env::var_os("VOLCENGINE_ARK_BASE_URL"),
4867                ark_base_url: env::var_os("ARK_BASE_URL"),
4868                wanjie_ark_base_url: env::var_os("WANJIE_ARK_BASE_URL"),
4869                wanjie_base_url: env::var_os("WANJIE_BASE_URL"),
4870                wanjie_maas_base_url: env::var_os("WANJIE_MAAS_BASE_URL"),
4871                volcengine_model: env::var_os("VOLCENGINE_MODEL"),
4872                volcengine_ark_model: env::var_os("VOLCENGINE_ARK_MODEL"),
4873                wanjie_ark_model: env::var_os("WANJIE_ARK_MODEL"),
4874                wanjie_model: env::var_os("WANJIE_MODEL"),
4875                wanjie_maas_model: env::var_os("WANJIE_MAAS_MODEL"),
4876                novita_api_key: env::var_os("NOVITA_API_KEY"),
4877                novita_base_url: env::var_os("NOVITA_BASE_URL"),
4878                novita_model: env::var_os("NOVITA_MODEL"),
4879                fireworks_api_key: env::var_os("FIREWORKS_API_KEY"),
4880                fireworks_base_url: env::var_os("FIREWORKS_BASE_URL"),
4881                fireworks_model: env::var_os("FIREWORKS_MODEL"),
4882                siliconflow_api_key: env::var_os("SILICONFLOW_API_KEY"),
4883                siliconflow_base_url: env::var_os("SILICONFLOW_BASE_URL"),
4884                siliconflow_model: env::var_os("SILICONFLOW_MODEL"),
4885                arcee_api_key: env::var_os("ARCEE_API_KEY"),
4886                arcee_base_url: env::var_os("ARCEE_BASE_URL"),
4887                arcee_model: env::var_os("ARCEE_MODEL"),
4888                moonshot_api_key: env::var_os("MOONSHOT_API_KEY"),
4889                moonshot_base_url: env::var_os("MOONSHOT_BASE_URL"),
4890                moonshot_model: env::var_os("MOONSHOT_MODEL"),
4891                kimi_api_key: env::var_os("KIMI_API_KEY"),
4892                kimi_base_url: env::var_os("KIMI_BASE_URL"),
4893                kimi_model: env::var_os("KIMI_MODEL"),
4894                kimi_model_name: env::var_os("KIMI_MODEL_NAME"),
4895                zai_api_key: env::var_os("ZAI_API_KEY"),
4896                z_ai_api_key: env::var_os("Z_AI_API_KEY"),
4897                zai_base_url: env::var_os("ZAI_BASE_URL"),
4898                zai_model: env::var_os("ZAI_MODEL"),
4899                stepfun_api_key: env::var_os("STEPFUN_API_KEY"),
4900                step_api_key: env::var_os("STEP_API_KEY"),
4901                stepfun_base_url: env::var_os("STEPFUN_BASE_URL"),
4902                stepfun_model: env::var_os("STEPFUN_MODEL"),
4903                minimax_api_key: env::var_os("MINIMAX_API_KEY"),
4904                minimax_base_url: env::var_os("MINIMAX_BASE_URL"),
4905                minimax_model: env::var_os("MINIMAX_MODEL"),
4906                sglang_api_key: env::var_os("SGLANG_API_KEY"),
4907                sglang_base_url: env::var_os("SGLANG_BASE_URL"),
4908                vllm_api_key: env::var_os("VLLM_API_KEY"),
4909                vllm_base_url: env::var_os("VLLM_BASE_URL"),
4910                ollama_api_key: env::var_os("OLLAMA_API_KEY"),
4911                ollama_base_url: env::var_os("OLLAMA_BASE_URL"),
4912                huggingface_api_key: env::var_os("HUGGINGFACE_API_KEY"),
4913                huggingface_token: env::var_os("HF_TOKEN"),
4914                huggingface_base_url: env::var_os("HUGGINGFACE_BASE_URL"),
4915                hf_base_url: env::var_os("HF_BASE_URL"),
4916                huggingface_model: env::var_os("HUGGINGFACE_MODEL"),
4917                hf_model: env::var_os("HF_MODEL"),
4918            };
4919            // Safety: test-only environment mutation guarded by a module mutex.
4920            unsafe {
4921                env::remove_var("DEEPSEEK_API_KEY");
4922                env::remove_var("DEEPSEEK_BASE_URL");
4923                env::remove_var("DEEPSEEK_HTTP_HEADERS");
4924                env::remove_var("DEEPSEEK_MODEL");
4925                env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL");
4926                env::remove_var("DEEPSEEK_PROVIDER");
4927                env::remove_var("DEEPSEEK_AUTH_MODE");
4928                env::remove_var("CODEWHALE_PROVIDER");
4929                env::remove_var("CODEWHALE_MODEL");
4930                env::remove_var("CODEWHALE_BASE_URL");
4931                env::remove_var("NVIDIA_API_KEY");
4932                env::remove_var("NVIDIA_NIM_API_KEY");
4933                env::remove_var("NIM_BASE_URL");
4934                env::remove_var("NVIDIA_BASE_URL");
4935                env::remove_var("NVIDIA_NIM_BASE_URL");
4936                env::remove_var("OPENROUTER_API_KEY");
4937                env::remove_var("OPENROUTER_BASE_URL");
4938                env::remove_var("OPENROUTER_MODEL");
4939                env::remove_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY");
4940                env::remove_var("MIMO_TOKEN_PLAN_API_KEY");
4941                env::remove_var("XIAOMI_MIMO_API_KEY");
4942                env::remove_var("XIAOMI_API_KEY");
4943                env::remove_var("MIMO_API_KEY");
4944                env::remove_var("XIAOMI_MIMO_BASE_URL");
4945                env::remove_var("MIMO_BASE_URL");
4946                env::remove_var("XIAOMI_MIMO_MODEL");
4947                env::remove_var("MIMO_MODEL");
4948                env::remove_var("XIAOMI_MIMO_MODE");
4949                env::remove_var("MIMO_MODE");
4950                env::remove_var("WANJIE_ARK_API_KEY");
4951                env::remove_var("VOLCENGINE_API_KEY");
4952                env::remove_var("VOLCENGINE_ARK_API_KEY");
4953                env::remove_var("ARK_API_KEY");
4954                env::remove_var("VOLCENGINE_BASE_URL");
4955                env::remove_var("VOLCENGINE_ARK_BASE_URL");
4956                env::remove_var("ARK_BASE_URL");
4957                env::remove_var("WANJIE_ARK_BASE_URL");
4958                env::remove_var("WANJIE_BASE_URL");
4959                env::remove_var("WANJIE_MAAS_BASE_URL");
4960                env::remove_var("VOLCENGINE_MODEL");
4961                env::remove_var("VOLCENGINE_ARK_MODEL");
4962                env::remove_var("WANJIE_ARK_MODEL");
4963                env::remove_var("WANJIE_MODEL");
4964                env::remove_var("WANJIE_MAAS_MODEL");
4965                env::remove_var("NOVITA_API_KEY");
4966                env::remove_var("NOVITA_BASE_URL");
4967                env::remove_var("NOVITA_MODEL");
4968                env::remove_var("FIREWORKS_API_KEY");
4969                env::remove_var("FIREWORKS_BASE_URL");
4970                env::remove_var("FIREWORKS_MODEL");
4971                env::remove_var("SILICONFLOW_API_KEY");
4972                env::remove_var("SILICONFLOW_BASE_URL");
4973                env::remove_var("SILICONFLOW_MODEL");
4974                env::remove_var("ARCEE_API_KEY");
4975                env::remove_var("ARCEE_BASE_URL");
4976                env::remove_var("ARCEE_MODEL");
4977                env::remove_var("MOONSHOT_API_KEY");
4978                env::remove_var("MOONSHOT_BASE_URL");
4979                env::remove_var("MOONSHOT_MODEL");
4980                env::remove_var("KIMI_API_KEY");
4981                env::remove_var("KIMI_BASE_URL");
4982                env::remove_var("KIMI_MODEL");
4983                env::remove_var("KIMI_MODEL_NAME");
4984                env::remove_var("ZAI_API_KEY");
4985                env::remove_var("Z_AI_API_KEY");
4986                env::remove_var("ZAI_BASE_URL");
4987                env::remove_var("ZAI_MODEL");
4988                env::remove_var("STEPFUN_API_KEY");
4989                env::remove_var("STEP_API_KEY");
4990                env::remove_var("STEPFUN_BASE_URL");
4991                env::remove_var("STEPFUN_MODEL");
4992                env::remove_var("MINIMAX_API_KEY");
4993                env::remove_var("MINIMAX_BASE_URL");
4994                env::remove_var("MINIMAX_MODEL");
4995                env::remove_var("SGLANG_API_KEY");
4996                env::remove_var("SGLANG_BASE_URL");
4997                env::remove_var("VLLM_API_KEY");
4998                env::remove_var("VLLM_BASE_URL");
4999                env::remove_var("OLLAMA_API_KEY");
5000                env::remove_var("OLLAMA_BASE_URL");
5001                env::remove_var("HUGGINGFACE_API_KEY");
5002                env::remove_var("HF_TOKEN");
5003                env::remove_var("HUGGINGFACE_BASE_URL");
5004                env::remove_var("HF_BASE_URL");
5005                env::remove_var("HUGGINGFACE_MODEL");
5006                env::remove_var("HF_MODEL");
5007            }
5008            guard
5009        }
5010
5011        unsafe fn restore_var(key: &str, value: Option<OsString>) {
5012            if let Some(value) = value {
5013                unsafe { env::set_var(key, value) };
5014            } else {
5015                unsafe { env::remove_var(key) };
5016            }
5017        }
5018    }
5019
5020    impl Drop for EnvGuard {
5021        fn drop(&mut self) {
5022            // Safety: test-only environment mutation guarded by a module mutex.
5023            unsafe {
5024                Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take());
5025                Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take());
5026                Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take());
5027                Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take());
5028                Self::restore_var(
5029                    "DEEPSEEK_DEFAULT_TEXT_MODEL",
5030                    self.deepseek_default_text_model.take(),
5031                );
5032                Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take());
5033                Self::restore_var("DEEPSEEK_AUTH_MODE", self.deepseek_auth_mode.take());
5034                Self::restore_var("CODEWHALE_PROVIDER", self.codewhale_provider.take());
5035                Self::restore_var("CODEWHALE_MODEL", self.codewhale_model.take());
5036                Self::restore_var("CODEWHALE_BASE_URL", self.codewhale_base_url.take());
5037                Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take());
5038                Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take());
5039                Self::restore_var("NIM_BASE_URL", self.nim_base_url.take());
5040                Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take());
5041                Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take());
5042                Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take());
5043                Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take());
5044                Self::restore_var("OPENROUTER_MODEL", self.openrouter_model.take());
5045                Self::restore_var(
5046                    "XIAOMI_MIMO_TOKEN_PLAN_API_KEY",
5047                    self.xiaomi_mimo_token_plan_api_key.take(),
5048                );
5049                Self::restore_var(
5050                    "MIMO_TOKEN_PLAN_API_KEY",
5051                    self.mimo_token_plan_api_key.take(),
5052                );
5053                Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take());
5054                Self::restore_var("XIAOMI_API_KEY", self.xiaomi_api_key.take());
5055                Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take());
5056                Self::restore_var("XIAOMI_MIMO_BASE_URL", self.xiaomi_mimo_base_url.take());
5057                Self::restore_var("MIMO_BASE_URL", self.mimo_base_url.take());
5058                Self::restore_var("XIAOMI_MIMO_MODEL", self.xiaomi_mimo_model.take());
5059                Self::restore_var("MIMO_MODEL", self.mimo_model.take());
5060                Self::restore_var("XIAOMI_MIMO_MODE", self.xiaomi_mimo_mode.take());
5061                Self::restore_var("MIMO_MODE", self.mimo_mode.take());
5062                Self::restore_var("WANJIE_ARK_API_KEY", self.wanjie_ark_api_key.take());
5063                Self::restore_var("VOLCENGINE_API_KEY", self.volcengine_api_key.take());
5064                Self::restore_var("VOLCENGINE_ARK_API_KEY", self.volcengine_ark_api_key.take());
5065                Self::restore_var("ARK_API_KEY", self.ark_api_key.take());
5066                Self::restore_var("VOLCENGINE_BASE_URL", self.volcengine_base_url.take());
5067                Self::restore_var(
5068                    "VOLCENGINE_ARK_BASE_URL",
5069                    self.volcengine_ark_base_url.take(),
5070                );
5071                Self::restore_var("ARK_BASE_URL", self.ark_base_url.take());
5072                Self::restore_var("WANJIE_ARK_BASE_URL", self.wanjie_ark_base_url.take());
5073                Self::restore_var("WANJIE_BASE_URL", self.wanjie_base_url.take());
5074                Self::restore_var("WANJIE_MAAS_BASE_URL", self.wanjie_maas_base_url.take());
5075                Self::restore_var("VOLCENGINE_MODEL", self.volcengine_model.take());
5076                Self::restore_var("VOLCENGINE_ARK_MODEL", self.volcengine_ark_model.take());
5077                Self::restore_var("WANJIE_ARK_MODEL", self.wanjie_ark_model.take());
5078                Self::restore_var("WANJIE_MODEL", self.wanjie_model.take());
5079                Self::restore_var("WANJIE_MAAS_MODEL", self.wanjie_maas_model.take());
5080                Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take());
5081                Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take());
5082                Self::restore_var("NOVITA_MODEL", self.novita_model.take());
5083                Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take());
5084                Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take());
5085                Self::restore_var("FIREWORKS_MODEL", self.fireworks_model.take());
5086                Self::restore_var("SILICONFLOW_API_KEY", self.siliconflow_api_key.take());
5087                Self::restore_var("SILICONFLOW_BASE_URL", self.siliconflow_base_url.take());
5088                Self::restore_var("SILICONFLOW_MODEL", self.siliconflow_model.take());
5089                Self::restore_var("ARCEE_API_KEY", self.arcee_api_key.take());
5090                Self::restore_var("ARCEE_BASE_URL", self.arcee_base_url.take());
5091                Self::restore_var("ARCEE_MODEL", self.arcee_model.take());
5092                Self::restore_var("MOONSHOT_API_KEY", self.moonshot_api_key.take());
5093                Self::restore_var("MOONSHOT_BASE_URL", self.moonshot_base_url.take());
5094                Self::restore_var("MOONSHOT_MODEL", self.moonshot_model.take());
5095                Self::restore_var("KIMI_API_KEY", self.kimi_api_key.take());
5096                Self::restore_var("KIMI_BASE_URL", self.kimi_base_url.take());
5097                Self::restore_var("KIMI_MODEL", self.kimi_model.take());
5098                Self::restore_var("KIMI_MODEL_NAME", self.kimi_model_name.take());
5099                Self::restore_var("ZAI_API_KEY", self.zai_api_key.take());
5100                Self::restore_var("Z_AI_API_KEY", self.z_ai_api_key.take());
5101                Self::restore_var("ZAI_BASE_URL", self.zai_base_url.take());
5102                Self::restore_var("ZAI_MODEL", self.zai_model.take());
5103                Self::restore_var("STEPFUN_API_KEY", self.stepfun_api_key.take());
5104                Self::restore_var("STEP_API_KEY", self.step_api_key.take());
5105                Self::restore_var("STEPFUN_BASE_URL", self.stepfun_base_url.take());
5106                Self::restore_var("STEPFUN_MODEL", self.stepfun_model.take());
5107                Self::restore_var("MINIMAX_API_KEY", self.minimax_api_key.take());
5108                Self::restore_var("MINIMAX_BASE_URL", self.minimax_base_url.take());
5109                Self::restore_var("MINIMAX_MODEL", self.minimax_model.take());
5110                Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take());
5111                Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take());
5112                Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take());
5113                Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take());
5114                Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take());
5115                Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take());
5116                Self::restore_var("HUGGINGFACE_API_KEY", self.huggingface_api_key.take());
5117                Self::restore_var("HF_TOKEN", self.huggingface_token.take());
5118                Self::restore_var("HUGGINGFACE_BASE_URL", self.huggingface_base_url.take());
5119                Self::restore_var("HF_BASE_URL", self.hf_base_url.take());
5120                Self::restore_var("HUGGINGFACE_MODEL", self.huggingface_model.take());
5121                Self::restore_var("HF_MODEL", self.hf_model.take());
5122            }
5123        }
5124    }
5125
5126    struct RecordingSecretsStore {
5127        gets: Mutex<Vec<String>>,
5128        value: Option<String>,
5129    }
5130
5131    impl RecordingSecretsStore {
5132        fn with_value(value: &str) -> Self {
5133            Self {
5134                gets: Mutex::new(Vec::new()),
5135                value: Some(value.to_string()),
5136            }
5137        }
5138    }
5139
5140    impl codewhale_secrets::KeyringStore for RecordingSecretsStore {
5141        fn get(&self, key: &str) -> Result<Option<String>, codewhale_secrets::SecretsError> {
5142            self.gets.lock().unwrap().push(key.to_string());
5143            Ok(self.value.clone())
5144        }
5145
5146        fn set(&self, _key: &str, _value: &str) -> Result<(), codewhale_secrets::SecretsError> {
5147            Ok(())
5148        }
5149
5150        fn delete(&self, _key: &str) -> Result<(), codewhale_secrets::SecretsError> {
5151            Ok(())
5152        }
5153
5154        fn backend_name(&self) -> &'static str {
5155            "recording"
5156        }
5157    }
5158
5159    #[test]
5160    fn root_deepseek_fields_are_runtime_fallbacks() {
5161        let _lock = env_lock();
5162        let _env = EnvGuard::without_deepseek_runtime_overrides();
5163        let config = ConfigToml {
5164            api_key: Some("root-key".to_string()),
5165            base_url: Some("https://api.deepseek.com".to_string()),
5166            default_text_model: Some("deepseek-v4-pro".to_string()),
5167            ..ConfigToml::default()
5168        };
5169
5170        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5171
5172        assert_eq!(resolved.provider, ProviderKind::Deepseek);
5173        assert_eq!(resolved.api_key.as_deref(), Some("root-key"));
5174        assert_eq!(resolved.base_url, "https://api.deepseek.com");
5175        assert_eq!(resolved.model, "deepseek-v4-pro");
5176    }
5177
5178    #[test]
5179    fn deepseek_runtime_defaults_to_beta_endpoint() {
5180        let _lock = env_lock();
5181        let _env = EnvGuard::without_deepseek_runtime_overrides();
5182        let config = ConfigToml::default();
5183
5184        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5185
5186        assert_eq!(resolved.provider, ProviderKind::Deepseek);
5187        assert_eq!(resolved.base_url, DEFAULT_DEEPSEEK_BASE_URL);
5188        assert_eq!(resolved.model, DEFAULT_DEEPSEEK_MODEL);
5189    }
5190
5191    #[test]
5192    fn provider_specific_deepseek_fields_override_tui_compat_fields() {
5193        let _lock = env_lock();
5194        let _env = EnvGuard::without_deepseek_runtime_overrides();
5195        let mut config = ConfigToml {
5196            api_key: Some("root-key".to_string()),
5197            base_url: Some("https://api.deepseek.com".to_string()),
5198            default_text_model: Some("deepseek-v4-pro".to_string()),
5199            ..ConfigToml::default()
5200        };
5201        config.providers.deepseek.api_key = Some("provider-key".to_string());
5202        config.providers.deepseek.base_url = Some("https://gateway.example/v1".to_string());
5203        config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
5204
5205        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5206
5207        assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
5208        assert_eq!(resolved.base_url, "https://gateway.example/v1");
5209        assert_eq!(resolved.model, "deepseek-v4-flash");
5210    }
5211
5212    #[test]
5213    fn provider_http_headers_override_root_headers() {
5214        let _lock = env_lock();
5215        let _env = EnvGuard::without_deepseek_runtime_overrides();
5216        let mut config = ConfigToml {
5217            api_key: Some("root-key".to_string()),
5218            base_url: Some("https://api.deepseek.com".to_string()),
5219            default_text_model: Some("deepseek-v4-pro".to_string()),
5220            ..ConfigToml::default()
5221        };
5222        config.providers.deepseek.api_key = Some("provider-key".to_string());
5223        config.providers.deepseek.base_url = Some("https://gateway.example/v1".to_string());
5224        config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
5225        config
5226            .http_headers
5227            .insert("X-Shared".to_string(), "root".to_string());
5228        config
5229            .providers
5230            .deepseek
5231            .http_headers
5232            .insert("X-Model-Provider-Id".to_string(), "tongyi".to_string());
5233        config
5234            .providers
5235            .deepseek
5236            .http_headers
5237            .insert("X-Shared".to_string(), "provider".to_string());
5238
5239        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5240
5241        assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
5242        assert_eq!(resolved.base_url, "https://gateway.example/v1");
5243        assert_eq!(resolved.model, "deepseek-v4-flash");
5244        assert_eq!(
5245            resolved
5246                .http_headers
5247                .get("X-Model-Provider-Id")
5248                .map(String::as_str),
5249            Some("tongyi")
5250        );
5251        assert_eq!(
5252            resolved.http_headers.get("X-Shared").map(String::as_str),
5253            Some("provider")
5254        );
5255    }
5256
5257    #[test]
5258    fn insecure_skip_tls_verify_resolves_only_for_active_provider() {
5259        let _lock = env_lock();
5260        let _env = EnvGuard::without_deepseek_runtime_overrides();
5261        let mut config = ConfigToml {
5262            provider: ProviderKind::Openai,
5263            ..ConfigToml::default()
5264        };
5265        config.providers.deepseek.insecure_skip_tls_verify = Some(true);
5266
5267        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5268
5269        assert_eq!(resolved.provider, ProviderKind::Openai);
5270        assert!(!resolved.insecure_skip_tls_verify);
5271
5272        config.providers.openai.insecure_skip_tls_verify = Some(true);
5273        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5274
5275        assert_eq!(resolved.provider, ProviderKind::Openai);
5276        assert!(resolved.insecure_skip_tls_verify);
5277    }
5278
5279    #[test]
5280    fn http_headers_env_overrides_config() {
5281        let _lock = env_lock();
5282        let _env = EnvGuard::without_deepseek_runtime_overrides();
5283        let mut config = ConfigToml::default();
5284        config
5285            .http_headers
5286            .insert("X-Model-Provider-Id".to_string(), "from-file".to_string());
5287        // Safety: test-only environment mutation guarded by a module mutex.
5288        unsafe {
5289            env::set_var("DEEPSEEK_HTTP_HEADERS", "X-Model-Provider-Id=from-env");
5290        }
5291
5292        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5293
5294        assert_eq!(
5295            resolved
5296                .http_headers
5297                .get("X-Model-Provider-Id")
5298                .map(String::as_str),
5299            Some("from-env")
5300        );
5301    }
5302
5303    #[test]
5304    fn nvidia_nim_provider_defaults_to_catalog_endpoint_and_model() {
5305        let _lock = env_lock();
5306        let _env = EnvGuard::without_deepseek_runtime_overrides();
5307        let config = ConfigToml {
5308            provider: ProviderKind::NvidiaNim,
5309            ..ConfigToml::default()
5310        };
5311
5312        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5313
5314        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
5315        assert_eq!(resolved.base_url, DEFAULT_NVIDIA_NIM_BASE_URL);
5316        assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
5317    }
5318
5319    #[test]
5320    fn nvidia_nim_provider_uses_provider_specific_credentials() {
5321        let _lock = env_lock();
5322        let _env = EnvGuard::without_deepseek_runtime_overrides();
5323        let mut config = ConfigToml {
5324            provider: ProviderKind::NvidiaNim,
5325            ..ConfigToml::default()
5326        };
5327        config.providers.nvidia_nim.api_key = Some("nim-key".to_string());
5328        config.providers.nvidia_nim.base_url = Some("https://nim.example/v1".to_string());
5329        config.providers.nvidia_nim.model = Some("deepseek-ai/deepseek-v4-pro".to_string());
5330
5331        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5332
5333        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
5334        assert_eq!(resolved.api_key.as_deref(), Some("nim-key"));
5335        assert_eq!(resolved.base_url, "https://nim.example/v1");
5336        assert_eq!(resolved.model, "deepseek-ai/deepseek-v4-pro");
5337    }
5338
5339    #[test]
5340    fn nvidia_nim_provider_normalizes_flash_aliases() {
5341        let _lock = env_lock();
5342        let _env = EnvGuard::without_deepseek_runtime_overrides();
5343        let cli = CliRuntimeOverrides {
5344            provider: Some(ProviderKind::NvidiaNim),
5345            model: Some("deepseek-v4-flash".to_string()),
5346            ..CliRuntimeOverrides::default()
5347        };
5348
5349        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
5350
5351        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
5352        assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_FLASH_MODEL);
5353    }
5354
5355    #[test]
5356    fn nvidia_nim_provider_uses_nvidia_env_credentials() {
5357        let _lock = env_lock();
5358        let _env = EnvGuard::without_deepseek_runtime_overrides();
5359        // Safety: test-only environment mutation guarded by a module mutex.
5360        unsafe {
5361            env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
5362            env::set_var("NVIDIA_API_KEY", "nim-env-key");
5363            env::set_var("NVIDIA_NIM_BASE_URL", "https://nim-env.example/v1");
5364        }
5365
5366        let config = ConfigToml::default();
5367        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5368
5369        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
5370        assert_eq!(resolved.api_key.as_deref(), Some("nim-env-key"));
5371        assert_eq!(resolved.base_url, "https://nim-env.example/v1");
5372        assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
5373    }
5374
5375    #[test]
5376    fn nvidia_nim_provider_accepts_short_nim_base_url_alias() {
5377        let _lock = env_lock();
5378        let _env = EnvGuard::without_deepseek_runtime_overrides();
5379        // Safety: test-only environment mutation guarded by a module mutex.
5380        unsafe {
5381            env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
5382            env::set_var("NVIDIA_API_KEY", "nim-env-key");
5383            env::set_var("NIM_BASE_URL", "https://short-nim.example/v1");
5384        }
5385
5386        let config = ConfigToml::default();
5387        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5388
5389        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
5390        assert_eq!(resolved.base_url, "https://short-nim.example/v1");
5391    }
5392
5393    #[test]
5394    fn nvidia_nim_provider_can_fallback_to_deepseek_api_key_env() {
5395        let _lock = env_lock();
5396        let _env = EnvGuard::without_deepseek_runtime_overrides();
5397        // Safety: test-only environment mutation guarded by a module mutex.
5398        unsafe {
5399            env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
5400            env::set_var("DEEPSEEK_API_KEY", "deepseek-compat-key");
5401        }
5402
5403        let config = ConfigToml::default();
5404        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5405
5406        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
5407        assert_eq!(resolved.api_key.as_deref(), Some("deepseek-compat-key"));
5408    }
5409
5410    #[test]
5411    fn list_values_redacts_root_api_key() {
5412        let config = ConfigToml {
5413            api_key: Some("sk-deepseek-secret".to_string()),
5414            ..ConfigToml::default()
5415        };
5416
5417        let values = config.list_values();
5418
5419        assert_eq!(
5420            values.get("api_key").map(String::as_str),
5421            Some("sk-d***cret")
5422        );
5423    }
5424
5425    #[test]
5426    fn list_values_fully_redacts_short_api_key() {
5427        let config = ConfigToml {
5428            api_key: Some("short-key".to_string()),
5429            ..ConfigToml::default()
5430        };
5431
5432        let values = config.list_values();
5433
5434        assert_eq!(values.get("api_key").map(String::as_str), Some("********"));
5435    }
5436
5437    #[test]
5438    fn get_display_value_redacts_sensitive_keys() {
5439        let mut config = ConfigToml {
5440            api_key: Some("sk-deepseek-secret".to_string()),
5441            ..ConfigToml::default()
5442        };
5443        config.providers.openrouter.api_key = Some("openrouter-secret-value".to_string());
5444        config.model = Some("deepseek-v4-pro".to_string());
5445
5446        assert_eq!(
5447            config.get_display_value("api_key").as_deref(),
5448            Some("sk-d***cret")
5449        );
5450        assert_eq!(
5451            config
5452                .get_display_value("providers.openrouter.api_key")
5453                .as_deref(),
5454            Some("open***alue")
5455        );
5456        assert_eq!(
5457            config.get_display_value("model").as_deref(),
5458            Some("deepseek-v4-pro")
5459        );
5460    }
5461
5462    #[test]
5463    fn hook_sinks_config_uses_separate_table_from_lifecycle_hooks() -> Result<()> {
5464        let raw = r#"
5465[hooks]
5466enabled = true
5467default_timeout_secs = 20
5468
5469[[hooks.hooks]]
5470event = "message_submit"
5471command = "echo ok"
5472
5473[hook_sinks]
5474unix_socket_path = "/tmp/cw-hooks.sock"
5475"#;
5476
5477        let config: ConfigToml = toml::from_str(raw)?;
5478
5479        assert_eq!(
5480            config.get_value("hook_sinks.unix_socket_path").as_deref(),
5481            Some("/tmp/cw-hooks.sock")
5482        );
5483        assert!(
5484            config.extras.contains_key("hooks"),
5485            "legacy lifecycle hooks table must remain an opaque extra"
5486        );
5487
5488        let serialized = toml::to_string_pretty(&config)?;
5489        let round_tripped: ConfigToml = toml::from_str(&serialized)?;
5490        let hooks = round_tripped
5491            .extras
5492            .get("hooks")
5493            .and_then(toml::Value::as_table)
5494            .expect("hooks table preserved");
5495
5496        assert_eq!(
5497            hooks.get("enabled").and_then(toml::Value::as_bool),
5498            Some(true)
5499        );
5500        assert_eq!(
5501            hooks
5502                .get("default_timeout_secs")
5503                .and_then(toml::Value::as_integer),
5504            Some(20)
5505        );
5506        assert!(
5507            hooks.get("hooks").and_then(toml::Value::as_array).is_some(),
5508            "nested lifecycle hooks array must survive config rewrites"
5509        );
5510        assert_eq!(
5511            round_tripped
5512                .get_value("hook_sinks.unix_socket_path")
5513                .as_deref(),
5514            Some("/tmp/cw-hooks.sock")
5515        );
5516
5517        Ok(())
5518    }
5519
5520    #[test]
5521    fn hook_sinks_unix_socket_path_round_trips_through_key_value_api() -> Result<()> {
5522        let mut config = ConfigToml::default();
5523
5524        config.set_value("hook_sinks.unix_socket_path", "/tmp/cw-events.sock")?;
5525
5526        assert_eq!(
5527            config.get_value("hook_sinks.unix_socket_path").as_deref(),
5528            Some("/tmp/cw-events.sock")
5529        );
5530        assert_eq!(
5531            config
5532                .list_values()
5533                .get("hook_sinks.unix_socket_path")
5534                .map(String::as_str),
5535            Some("/tmp/cw-events.sock")
5536        );
5537
5538        config.unset_value("hook_sinks.unix_socket_path")?;
5539        assert_eq!(config.get_value("hook_sinks.unix_socket_path"), None);
5540
5541        Ok(())
5542    }
5543
5544    /// End-to-end smoke for the preferred Kimi Code setup path:
5545    ///   1. Start from a fresh root config that uses DeepSeek defaults.
5546    ///   2. Mutate it through the same key-value setters the
5547    ///      `codewhale config set providers.moonshot.*` CLI invokes.
5548    ///   3. Switch the active provider through `CODEWHALE_PROVIDER` —
5549    ///      the public env alias — without ever touching the legacy
5550    ///      `DEEPSEEK_PROVIDER` name.
5551    ///   4. Resolve the runtime and confirm the doctor/runtime values.
5552    ///
5553    /// No real API key is required; the `api_key` here is just a
5554    /// non-empty placeholder.
5555    #[test]
5556    fn moonshot_kimi_code_smoke_config_set_then_resolve() -> Result<()> {
5557        let _lock = env_lock();
5558        let _env = EnvGuard::without_deepseek_runtime_overrides();
5559
5560        let mut config = ConfigToml {
5561            provider: ProviderKind::Deepseek,
5562            default_text_model: Some("deepseek-v4-pro".to_string()),
5563            ..ConfigToml::default()
5564        };
5565
5566        // Same key paths a user would run via `codewhale config set`.
5567        config.set_value("providers.moonshot.api_key", "kimi-code-key-placeholder")?;
5568        config.set_value("providers.moonshot.auth_mode", "api_key")?;
5569        config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?;
5570        config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?;
5571
5572        // Public env alias for the active-provider switch.
5573        // Safety: test-only env mutation guarded by env_lock().
5574        unsafe { env::set_var("CODEWHALE_PROVIDER", "moonshot") };
5575
5576        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5577
5578        assert_eq!(resolved.provider, ProviderKind::Moonshot);
5579        assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
5580        assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
5581        assert_eq!(resolved.auth_mode.as_deref(), Some("api_key"));
5582        assert_eq!(
5583            resolved.api_key.as_deref(),
5584            Some("kimi-code-key-placeholder")
5585        );
5586        assert_eq!(
5587            resolved.api_key_source,
5588            Some(RuntimeApiKeySource::ConfigFile)
5589        );
5590        Ok(())
5591    }
5592
5593    #[test]
5594    fn moonshot_provider_config_values_round_trip() -> Result<()> {
5595        let mut config = ConfigToml::default();
5596
5597        config.set_value("providers.moonshot.api_key", "moonshot-secret-value")?;
5598        config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?;
5599        config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?;
5600        config.set_value("providers.moonshot.auth_mode", "api_key")?;
5601        config.set_value("providers.moonshot.http_headers", "X-Test=ok")?;
5602
5603        assert_eq!(
5604            config
5605                .get_display_value("providers.moonshot.api_key")
5606                .as_deref(),
5607            Some("moon***alue")
5608        );
5609        assert_eq!(
5610            config.get_value("providers.moonshot.base_url").as_deref(),
5611            Some(DEFAULT_KIMI_CODE_BASE_URL)
5612        );
5613        assert_eq!(
5614            config.get_value("providers.moonshot.model").as_deref(),
5615            Some(DEFAULT_KIMI_CODE_MODEL)
5616        );
5617        assert_eq!(
5618            config.get_value("providers.moonshot.auth_mode").as_deref(),
5619            Some("api_key")
5620        );
5621        assert_eq!(
5622            config
5623                .list_values()
5624                .get("providers.moonshot.api_key")
5625                .map(String::as_str),
5626            Some("moon***alue")
5627        );
5628
5629        config.unset_value("providers.moonshot.auth_mode")?;
5630        config.unset_value("providers.moonshot.base_url")?;
5631        config.unset_value("providers.moonshot.model")?;
5632
5633        assert_eq!(config.get_value("providers.moonshot.auth_mode"), None);
5634        assert_eq!(config.get_value("providers.moonshot.base_url"), None);
5635        assert_eq!(config.get_value("providers.moonshot.model"), None);
5636        Ok(())
5637    }
5638
5639    #[test]
5640    fn siliconflow_cn_provider_config_values_round_trip() -> Result<()> {
5641        let mut config = ConfigToml::default();
5642
5643        config.set_value("providers.siliconflow_cn.api_key", "sf-cn-secret-value")?;
5644        config.set_value(
5645            "providers.siliconflow_cn.base_url",
5646            DEFAULT_SILICONFLOW_CN_BASE_URL,
5647        )?;
5648        config.set_value("providers.siliconflow_cn.model", DEFAULT_SILICONFLOW_MODEL)?;
5649        config.set_value("providers.siliconflow_cn.http_headers", "X-Test=ok")?;
5650
5651        assert_eq!(
5652            config
5653                .get_display_value("providers.siliconflow_cn.api_key")
5654                .as_deref(),
5655            Some("sf-c***alue")
5656        );
5657        assert_eq!(
5658            config
5659                .get_value("providers.siliconflow_cn.base_url")
5660                .as_deref(),
5661            Some(DEFAULT_SILICONFLOW_CN_BASE_URL)
5662        );
5663        assert_eq!(
5664            config
5665                .get_value("providers.siliconflow_cn.model")
5666                .as_deref(),
5667            Some(DEFAULT_SILICONFLOW_MODEL)
5668        );
5669        assert_eq!(
5670            config
5671                .list_values()
5672                .get("providers.siliconflow_cn.api_key")
5673                .map(String::as_str),
5674            Some("sf-c***alue")
5675        );
5676
5677        config.unset_value("providers.siliconflow_cn.api_key")?;
5678        config.unset_value("providers.siliconflow_cn.base_url")?;
5679        config.unset_value("providers.siliconflow_cn.model")?;
5680        config.unset_value("providers.siliconflow_cn.http_headers")?;
5681
5682        assert_eq!(config.get_value("providers.siliconflow_cn.api_key"), None);
5683        assert_eq!(config.get_value("providers.siliconflow_cn.base_url"), None);
5684        assert_eq!(config.get_value("providers.siliconflow_cn.model"), None);
5685        assert_eq!(
5686            config.get_value("providers.siliconflow_cn.http_headers"),
5687            None
5688        );
5689        Ok(())
5690    }
5691
5692    #[test]
5693    fn volcengine_provider_config_values_round_trip() -> Result<()> {
5694        let mut config = ConfigToml::default();
5695
5696        config.set_value("providers.volcengine.api_key", "volcengine-secret-value")?;
5697        config.set_value("providers.volcengine.base_url", DEFAULT_VOLCENGINE_BASE_URL)?;
5698        config.set_value("providers.volcengine.model", DEFAULT_VOLCENGINE_MODEL)?;
5699        config.set_value("providers.volcengine.http_headers", "X-Test=ok")?;
5700
5701        assert_eq!(
5702            config
5703                .get_display_value("providers.volcengine.api_key")
5704                .as_deref(),
5705            Some("volc***alue")
5706        );
5707        assert_eq!(
5708            config.get_value("providers.volcengine.base_url").as_deref(),
5709            Some(DEFAULT_VOLCENGINE_BASE_URL)
5710        );
5711        assert_eq!(
5712            config.get_value("providers.volcengine.model").as_deref(),
5713            Some(DEFAULT_VOLCENGINE_MODEL)
5714        );
5715        assert_eq!(
5716            config
5717                .get_value("providers.volcengine.http_headers")
5718                .as_deref(),
5719            Some("X-Test=ok")
5720        );
5721        assert_eq!(
5722            config
5723                .list_values()
5724                .get("providers.volcengine.http_headers")
5725                .map(String::as_str),
5726            Some("X-Test=ok")
5727        );
5728
5729        config.unset_value("providers.volcengine.http_headers")?;
5730        assert_eq!(config.get_value("providers.volcengine.http_headers"), None);
5731        Ok(())
5732    }
5733
5734    #[test]
5735    fn project_merge_denies_credentials_endpoints_and_provider_selection() {
5736        let mut base = ConfigToml {
5737            provider: ProviderKind::Deepseek,
5738            api_key: Some("user-key".to_string()),
5739            base_url: Some("https://api.deepseek.com".to_string()),
5740            default_text_model: Some("deepseek-v4-flash".to_string()),
5741            ..ConfigToml::default()
5742        };
5743        base.providers.openrouter.api_key = Some("user-openrouter-key".to_string());
5744        base.providers.openrouter.path_suffix = Some("/chat/completions".to_string());
5745
5746        let mut project = ConfigToml {
5747            provider: ProviderKind::Openrouter,
5748            api_key: Some("attacker-key".to_string()),
5749            base_url: Some("https://evil.example/v1".to_string()),
5750            default_text_model: Some("deepseek-v4-pro".to_string()),
5751            auth_mode: Some("oauth".to_string()),
5752            telemetry: Some(true),
5753            ..ConfigToml::default()
5754        };
5755        project.providers.openrouter.api_key = Some("attacker-openrouter-key".to_string());
5756        project.providers.openrouter.base_url = Some("https://evil.example/openrouter".to_string());
5757        project.providers.openrouter.insecure_skip_tls_verify = Some(true);
5758        project.providers.openrouter.path_suffix = Some("/attacker/chat".to_string());
5759        project.providers.openrouter.model = Some("deepseek/deepseek-v4-pro".to_string());
5760        project.providers.volcengine.model = Some("DeepSeek-V4-Pro".to_string());
5761        project.providers.moonshot.model = Some("kimi-k2.6".to_string());
5762
5763        base.merge_project_overrides(project);
5764
5765        assert_eq!(base.provider, ProviderKind::Deepseek);
5766        assert_eq!(base.api_key.as_deref(), Some("user-key"));
5767        assert_eq!(base.base_url.as_deref(), Some("https://api.deepseek.com"));
5768        assert_eq!(base.auth_mode, None);
5769        assert_eq!(base.telemetry, None);
5770        assert_eq!(
5771            base.providers.openrouter.api_key.as_deref(),
5772            Some("user-openrouter-key")
5773        );
5774        assert_eq!(base.providers.openrouter.base_url, None);
5775        assert_eq!(base.providers.openrouter.insecure_skip_tls_verify, None);
5776        assert_eq!(
5777            base.providers.openrouter.path_suffix.as_deref(),
5778            Some("/chat/completions")
5779        );
5780        assert_eq!(base.default_text_model.as_deref(), Some("deepseek-v4-pro"));
5781        assert_eq!(
5782            base.providers.openrouter.model.as_deref(),
5783            Some("deepseek/deepseek-v4-pro")
5784        );
5785        assert_eq!(
5786            base.providers.volcengine.model.as_deref(),
5787            Some("DeepSeek-V4-Pro")
5788        );
5789        assert_eq!(base.providers.moonshot.model.as_deref(), Some("kimi-k2.6"));
5790    }
5791
5792    #[test]
5793    fn project_merge_only_tightens_approval_and_sandbox_policy() {
5794        let mut strict = ConfigToml {
5795            approval_policy: Some("never".to_string()),
5796            sandbox_mode: Some("read-only".to_string()),
5797            ..ConfigToml::default()
5798        };
5799        strict.merge_project_overrides(ConfigToml {
5800            approval_policy: Some("on-request".to_string()),
5801            sandbox_mode: Some("workspace-write".to_string()),
5802            ..ConfigToml::default()
5803        });
5804        assert_eq!(strict.approval_policy.as_deref(), Some("never"));
5805        assert_eq!(strict.sandbox_mode.as_deref(), Some("read-only"));
5806
5807        let mut permissive = ConfigToml {
5808            approval_policy: Some("auto".to_string()),
5809            sandbox_mode: Some("workspace-write".to_string()),
5810            ..ConfigToml::default()
5811        };
5812        permissive.merge_project_overrides(ConfigToml {
5813            approval_policy: Some("never".to_string()),
5814            sandbox_mode: Some("read-only".to_string()),
5815            ..ConfigToml::default()
5816        });
5817        assert_eq!(permissive.approval_policy.as_deref(), Some("never"));
5818        assert_eq!(permissive.sandbox_mode.as_deref(), Some("read-only"));
5819
5820        let mut unset = ConfigToml::default();
5821        unset.merge_project_overrides(ConfigToml {
5822            approval_policy: Some("on-request".to_string()),
5823            sandbox_mode: Some("workspace-write".to_string()),
5824            ..ConfigToml::default()
5825        });
5826        assert_eq!(unset.approval_policy, None);
5827        assert_eq!(unset.sandbox_mode, None);
5828    }
5829
5830    #[test]
5831    fn list_values_redacts_unicode_api_key_without_byte_slicing() {
5832        let config = ConfigToml {
5833            api_key: Some("密钥密钥密钥密钥123456789".to_string()),
5834            ..ConfigToml::default()
5835        };
5836
5837        let values = config.list_values();
5838
5839        assert_eq!(
5840            values.get("api_key").map(String::as_str),
5841            Some("密钥密钥***6789")
5842        );
5843    }
5844
5845    #[test]
5846    fn app_homes_prefer_home_env_before_platform_home_fallback() {
5847        let _lock = env_lock();
5848        struct HomeEnvGuard {
5849            home: Option<OsString>,
5850            userprofile: Option<OsString>,
5851            codewhale_home: Option<OsString>,
5852        }
5853
5854        impl Drop for HomeEnvGuard {
5855            fn drop(&mut self) {
5856                // Safety: test-only environment mutation is serialized by env_lock().
5857                unsafe {
5858                    match self.home.take() {
5859                        Some(value) => env::set_var("HOME", value),
5860                        None => env::remove_var("HOME"),
5861                    }
5862                    match self.userprofile.take() {
5863                        Some(value) => env::set_var("USERPROFILE", value),
5864                        None => env::remove_var("USERPROFILE"),
5865                    }
5866                    match self.codewhale_home.take() {
5867                        Some(value) => env::set_var("CODEWHALE_HOME", value),
5868                        None => env::remove_var("CODEWHALE_HOME"),
5869                    }
5870                }
5871            }
5872        }
5873
5874        let home =
5875            std::env::temp_dir().join(format!("codewhale-config-home-env-{}", std::process::id()));
5876        let userprofile = std::env::temp_dir().join(format!(
5877            "codewhale-config-userprofile-{}",
5878            std::process::id()
5879        ));
5880        let _env = HomeEnvGuard {
5881            home: env::var_os("HOME"),
5882            userprofile: env::var_os("USERPROFILE"),
5883            codewhale_home: env::var_os("CODEWHALE_HOME"),
5884        };
5885        // Safety: test-only environment mutation is serialized by env_lock().
5886        unsafe {
5887            env::set_var("HOME", &home);
5888            env::set_var("USERPROFILE", &userprofile);
5889            env::remove_var("CODEWHALE_HOME");
5890        }
5891
5892        assert_eq!(
5893            codewhale_home().expect("codewhale home"),
5894            home.join(CODEWHALE_APP_DIR)
5895        );
5896        assert_eq!(
5897            legacy_deepseek_home().expect("legacy home"),
5898            home.join(LEGACY_APP_DIR)
5899        );
5900
5901        let explicit = std::env::temp_dir().join(format!(
5902            "codewhale-config-explicit-home-{}",
5903            std::process::id()
5904        ));
5905        // Safety: test-only environment mutation is serialized by env_lock().
5906        unsafe {
5907            env::set_var("CODEWHALE_HOME", &explicit);
5908        }
5909        assert_eq!(codewhale_home().expect("explicit home"), explicit);
5910    }
5911
5912    #[test]
5913    fn migrate_config_reports_copied_legacy_path() {
5914        let _lock = env_lock();
5915        struct HomeEnvGuard {
5916            home: Option<OsString>,
5917            userprofile: Option<OsString>,
5918            codewhale_home: Option<OsString>,
5919        }
5920
5921        impl Drop for HomeEnvGuard {
5922            fn drop(&mut self) {
5923                // Safety: test-only environment mutation is serialized by env_lock().
5924                unsafe {
5925                    match self.home.take() {
5926                        Some(value) => env::set_var("HOME", value),
5927                        None => env::remove_var("HOME"),
5928                    }
5929                    match self.userprofile.take() {
5930                        Some(value) => env::set_var("USERPROFILE", value),
5931                        None => env::remove_var("USERPROFILE"),
5932                    }
5933                    match self.codewhale_home.take() {
5934                        Some(value) => env::set_var("CODEWHALE_HOME", value),
5935                        None => env::remove_var("CODEWHALE_HOME"),
5936                    }
5937                }
5938            }
5939        }
5940
5941        struct LegacyConfigGuard {
5942            path: PathBuf,
5943            original: Option<Vec<u8>>,
5944        }
5945
5946        impl LegacyConfigGuard {
5947            fn install(path: PathBuf, contents: &[u8]) -> Self {
5948                let original = fs::read(&path).ok();
5949                fs::create_dir_all(path.parent().expect("legacy config parent"))
5950                    .expect("legacy dir");
5951                fs::write(&path, contents).expect("legacy config");
5952                Self { path, original }
5953            }
5954        }
5955
5956        impl Drop for LegacyConfigGuard {
5957            fn drop(&mut self) {
5958                if let Some(original) = self.original.take() {
5959                    let _ = fs::write(&self.path, original);
5960                } else {
5961                    let _ = fs::remove_file(&self.path);
5962                    if let Some(parent) = self.path.parent() {
5963                        let _ = fs::remove_dir(parent);
5964                    }
5965                }
5966            }
5967        }
5968
5969        let unique = std::time::SystemTime::now()
5970            .duration_since(std::time::UNIX_EPOCH)
5971            .expect("clock")
5972            .as_nanos();
5973        let home = std::env::temp_dir().join(format!(
5974            "codewhale-config-migration-{}-{unique}",
5975            std::process::id()
5976        ));
5977        let legacy_dir = home.join(LEGACY_APP_DIR);
5978        let primary_dir = home.join(CODEWHALE_APP_DIR);
5979        let legacy_config = legacy_dir.join(CONFIG_FILE_NAME);
5980        let _legacy =
5981            LegacyConfigGuard::install(legacy_config.clone(), b"provider = \"deepseek\"\n");
5982
5983        let _env = HomeEnvGuard {
5984            home: env::var_os("HOME"),
5985            userprofile: env::var_os("USERPROFILE"),
5986            codewhale_home: env::var_os("CODEWHALE_HOME"),
5987        };
5988        // Safety: test-only environment mutation is serialized by env_lock().
5989        unsafe {
5990            env::set_var("HOME", &home);
5991            env::set_var("USERPROFILE", &home);
5992            env::set_var("CODEWHALE_HOME", &primary_dir);
5993        }
5994
5995        let migration = migrate_config_if_needed()
5996            .expect("migration")
5997            .expect("legacy config should be copied");
5998
5999        assert_eq!(migration.legacy_path, legacy_config);
6000        assert_eq!(migration.primary_path, primary_dir.join(CONFIG_FILE_NAME));
6001        let notice = migration.user_notice();
6002        assert!(notice.contains(&legacy_dir.join(CONFIG_FILE_NAME).display().to_string()));
6003        assert!(notice.contains(&primary_dir.join(CONFIG_FILE_NAME).display().to_string()));
6004        assert!(notice.contains(".codewhale path for future edits"));
6005        assert!(notice.contains(".deepseek file remains only as a compatibility fallback"));
6006        assert_eq!(
6007            fs::read_to_string(primary_dir.join(CONFIG_FILE_NAME)).expect("primary config"),
6008            "provider = \"deepseek\"\n"
6009        );
6010
6011        let _ = fs::remove_dir_all(home);
6012    }
6013
6014    #[test]
6015    fn normalize_config_file_path_rejects_traversal() {
6016        let err = normalize_config_file_path(PathBuf::from("../config.toml"))
6017            .expect_err("traversal path should fail");
6018        assert!(format!("{err:#}").contains("cannot contain '..'"));
6019    }
6020
6021    #[cfg(unix)]
6022    #[test]
6023    fn save_clamps_existing_config_permissions() {
6024        use std::time::{SystemTime, UNIX_EPOCH};
6025
6026        let unique = SystemTime::now()
6027            .duration_since(UNIX_EPOCH)
6028            .expect("clock")
6029            .as_nanos();
6030        let dir = std::env::temp_dir().join(format!(
6031            "deepseek-config-perms-{}-{unique}",
6032            std::process::id()
6033        ));
6034        fs::create_dir_all(&dir).expect("mkdir");
6035        let path = dir.join(CONFIG_FILE_NAME);
6036        fs::write(&path, "api_key = \"old\"\n").expect("seed config");
6037        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod seed");
6038
6039        let store = ConfigStore {
6040            path: path.clone(),
6041            config: ConfigToml {
6042                api_key: Some("new-secret".to_string()),
6043                ..ConfigToml::default()
6044            },
6045            permissions: PermissionsToml::default(),
6046        };
6047        store.save().expect("save");
6048
6049        let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
6050        assert_eq!(mode, 0o600);
6051
6052        let _ = fs::remove_dir_all(dir);
6053    }
6054
6055    #[test]
6056    fn config_store_save_skips_identical_serialized_body() {
6057        use std::time::{SystemTime, UNIX_EPOCH};
6058
6059        let unique = SystemTime::now()
6060            .duration_since(UNIX_EPOCH)
6061            .expect("clock")
6062            .as_nanos();
6063        let dir = std::env::temp_dir().join(format!(
6064            "codewhale-config-noop-save-{}-{unique}",
6065            std::process::id()
6066        ));
6067        fs::create_dir_all(&dir).expect("mkdir");
6068        let path = dir.join(CONFIG_FILE_NAME);
6069        let config = ConfigToml {
6070            model: Some("deepseek-v4-flash".to_string()),
6071            ..ConfigToml::default()
6072        };
6073        let body = toml::to_string_pretty(&config).expect("serialize");
6074        fs::write(&path, &body).expect("seed config");
6075        #[cfg(unix)]
6076        fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).expect("chmod seed");
6077
6078        let store = ConfigStore {
6079            path: path.clone(),
6080            config,
6081            permissions: PermissionsToml::default(),
6082        };
6083        store.save().expect("identical save should not rewrite");
6084
6085        #[cfg(unix)]
6086        fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("chmod restore");
6087        assert_eq!(fs::read_to_string(&path).expect("read config"), body);
6088        assert!(
6089            !config_backup_path(&path).exists(),
6090            "no-op save must not create a migration backup"
6091        );
6092
6093        let _ = fs::remove_dir_all(dir);
6094    }
6095
6096    #[test]
6097    fn config_store_save_creates_one_time_backup_before_changed_write() {
6098        use std::time::{SystemTime, UNIX_EPOCH};
6099
6100        let unique = SystemTime::now()
6101            .duration_since(UNIX_EPOCH)
6102            .expect("clock")
6103            .as_nanos();
6104        let dir = std::env::temp_dir().join(format!(
6105            "codewhale-config-backup-save-{}-{unique}",
6106            std::process::id()
6107        ));
6108        fs::create_dir_all(&dir).expect("mkdir");
6109        let path = dir.join(CONFIG_FILE_NAME);
6110        let original = "model = \"deepseek-v4-flash\"\n";
6111        fs::write(&path, original).expect("seed config");
6112
6113        let store = ConfigStore {
6114            path: path.clone(),
6115            config: ConfigToml {
6116                model: Some("deepseek-v4-pro".to_string()),
6117                ..ConfigToml::default()
6118            },
6119            permissions: PermissionsToml::default(),
6120        };
6121        store.save().expect("changed save");
6122
6123        let backup_path = config_backup_path(&path);
6124        assert_eq!(
6125            fs::read_to_string(&backup_path).expect("read backup"),
6126            original
6127        );
6128        let updated = fs::read_to_string(&path).expect("read updated config");
6129        assert!(updated.contains("model = \"deepseek-v4-pro\""));
6130
6131        let _ = fs::remove_dir_all(dir);
6132    }
6133
6134    #[test]
6135    fn provider_kind_parses_openrouter_and_novita_aliases() {
6136        assert_eq!(
6137            ProviderKind::parse("openrouter"),
6138            Some(ProviderKind::Openrouter)
6139        );
6140        assert_eq!(
6141            ProviderKind::parse("OPEN_ROUTER"),
6142            Some(ProviderKind::Openrouter)
6143        );
6144        assert_eq!(
6145            ProviderKind::parse("xiaomi-mimo"),
6146            Some(ProviderKind::XiaomiMimo)
6147        );
6148        assert_eq!(
6149            ProviderKind::parse("xiaomi"),
6150            Some(ProviderKind::XiaomiMimo)
6151        );
6152        assert_eq!(ProviderKind::parse("novita"), Some(ProviderKind::Novita));
6153        assert_eq!(ProviderKind::parse("Novita"), Some(ProviderKind::Novita));
6154        assert_eq!(
6155            ProviderKind::parse("fireworks-ai"),
6156            Some(ProviderKind::Fireworks)
6157        );
6158        assert_eq!(
6159            ProviderKind::parse("silicon-flow"),
6160            Some(ProviderKind::Siliconflow)
6161        );
6162        assert_eq!(
6163            ProviderKind::parse("silicon_flow"),
6164            Some(ProviderKind::Siliconflow)
6165        );
6166        assert_eq!(ProviderKind::parse("kimi"), Some(ProviderKind::Moonshot));
6167        assert_eq!(
6168            ProviderKind::parse("moonshot-ai"),
6169            Some(ProviderKind::Moonshot)
6170        );
6171        assert_eq!(ProviderKind::parse("sg-lang"), Some(ProviderKind::Sglang));
6172        assert_eq!(ProviderKind::parse("v-llm"), Some(ProviderKind::Vllm));
6173        assert_eq!(ProviderKind::parse("vllm"), Some(ProviderKind::Vllm));
6174        assert_eq!(ProviderKind::parse("ollama"), Some(ProviderKind::Ollama));
6175        assert_eq!(
6176            ProviderKind::parse("ollama-local"),
6177            Some(ProviderKind::Ollama)
6178        );
6179        assert_eq!(
6180            ProviderKind::parse("wanjie-ark"),
6181            Some(ProviderKind::WanjieArk)
6182        );
6183        assert_eq!(
6184            ProviderKind::parse("ark_wanjie"),
6185            Some(ProviderKind::WanjieArk)
6186        );
6187        for alias in ["huggingface", "hugging-face", "hugging_face", "hf"] {
6188            assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Huggingface));
6189
6190            let parsed: ConfigToml =
6191                toml::from_str(&format!("provider = \"{alias}\"")).expect("huggingface alias");
6192            assert_eq!(parsed.provider, ProviderKind::Huggingface);
6193        }
6194
6195        for alias in ["deepinfra", "deep-infra", "deep_infra"] {
6196            assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Deepinfra));
6197
6198            let parsed: ConfigToml =
6199                toml::from_str(&format!("provider = \"{alias}\"")).expect("deepinfra alias");
6200            assert_eq!(parsed.provider, ProviderKind::Deepinfra);
6201        }
6202
6203        let parsed: ConfigToml =
6204            toml::from_str("provider = \"ark-wanjie\"").expect("wanjie provider alias");
6205        assert_eq!(parsed.provider, ProviderKind::WanjieArk);
6206
6207        let parsed: ConfigToml =
6208            toml::from_str("provider = \"silicon-flow\"").expect("siliconflow provider alias");
6209        assert_eq!(parsed.provider, ProviderKind::Siliconflow);
6210    }
6211
6212    #[test]
6213    fn unknown_provider_error_lists_huggingface() {
6214        let mut config = ConfigToml::default();
6215        let err = config
6216            .set_value("provider", "not-a-provider")
6217            .expect_err("unknown provider should fail");
6218        let message = err.to_string();
6219        assert!(message.contains("unknown provider 'not-a-provider'"));
6220        assert!(message.contains("huggingface"));
6221    }
6222
6223    #[test]
6224    fn provider_kind_accepts_legacy_deepseek_cn_aliases() {
6225        for alias in [
6226            "deepseek-cn",
6227            "deepseek_china",
6228            "deepseekcn",
6229            "deepseek-china",
6230        ] {
6231            assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Deepseek));
6232
6233            let parsed: ConfigToml =
6234                toml::from_str(&format!("provider = \"{alias}\"")).expect("legacy provider alias");
6235            assert_eq!(parsed.provider, ProviderKind::Deepseek);
6236        }
6237    }
6238
6239    #[test]
6240    fn provider_metadata_registry_covers_every_provider_kind_once() {
6241        let providers = provider::all_providers();
6242        assert_eq!(providers.len(), ProviderKind::ALL.len());
6243
6244        for (kind, provider) in ProviderKind::ALL.iter().zip(providers.iter()) {
6245            assert_eq!(provider.kind(), *kind);
6246            assert_eq!(provider.id(), kind.as_str());
6247            assert_eq!(kind.provider().id(), kind.as_str());
6248        }
6249
6250        let mut ids = std::collections::BTreeSet::new();
6251        for provider in providers {
6252            assert!(ids.insert(provider.id()), "duplicate provider id");
6253        }
6254    }
6255
6256    #[test]
6257    fn provider_metadata_lookup_does_not_fall_back_to_deepseek() {
6258        assert!(provider::lookup_provider("not-a-provider").is_none());
6259        assert!(provider::resolve_provider("not-a-provider").is_none());
6260        assert!(provider::lookup_provider("deepseek-cn").is_none());
6261        assert_eq!(
6262            provider::resolve_provider("deepseek-cn")
6263                .expect("legacy alias resolves")
6264                .kind(),
6265            ProviderKind::Deepseek
6266        );
6267    }
6268
6269    #[test]
6270    fn provider_metadata_preserves_alias_and_config_key_semantics() {
6271        assert_eq!(
6272            provider::resolve_provider("open_router")
6273                .expect("openrouter alias")
6274                .kind(),
6275            ProviderKind::Openrouter
6276        );
6277        assert_eq!(
6278            provider::resolve_provider("xiaomi")
6279                .expect("xiaomi alias")
6280                .kind(),
6281            ProviderKind::XiaomiMimo
6282        );
6283        assert_eq!(
6284            provider::resolve_provider("kimi")
6285                .expect("kimi alias")
6286                .kind(),
6287            ProviderKind::Moonshot
6288        );
6289        assert_eq!(
6290            provider::resolve_provider("hf")
6291                .expect("huggingface alias")
6292                .kind(),
6293            ProviderKind::Huggingface
6294        );
6295
6296        let siliconflow_cn =
6297            provider::resolve_provider("siliconflow-cn").expect("siliconflow-cn alias resolves");
6298        assert_eq!(siliconflow_cn.kind(), ProviderKind::SiliconflowCN);
6299        assert_eq!(siliconflow_cn.id(), "siliconflow-CN");
6300        assert_eq!(siliconflow_cn.provider_config_key(), "siliconflow_cn");
6301
6302        let config = ProvidersToml::default();
6303        let shared_table = config.for_provider(ProviderKind::SiliconflowCN);
6304        assert!(!std::ptr::eq(
6305            shared_table,
6306            config.for_provider(ProviderKind::Siliconflow)
6307        ));
6308    }
6309
6310    #[test]
6311    fn provider_metadata_defaults_match_runtime_helpers() {
6312        for kind in ProviderKind::ALL {
6313            let provider = kind.provider();
6314            assert_eq!(provider.default_model(), default_model_for_provider(kind));
6315            assert_eq!(
6316                provider.default_base_url(),
6317                default_base_url_for_provider(kind)
6318            );
6319            assert!(!provider.display_name().trim().is_empty());
6320            assert!(!provider.env_vars().is_empty());
6321            // OpenAI Codex (ChatGPT) speaks the Responses API and Anthropic
6322            // speaks the native Messages API; every other built-in provider
6323            // is OpenAI-compatible Chat Completions.
6324            let expected_wire = match kind {
6325                ProviderKind::OpenaiCodex => provider::WireFormat::Responses,
6326                ProviderKind::Anthropic => provider::WireFormat::AnthropicMessages,
6327                _ => provider::WireFormat::ChatCompletions,
6328            };
6329            assert_eq!(provider.wire(), expected_wire);
6330        }
6331    }
6332
6333    #[test]
6334    fn openrouter_provider_defaults_to_canonical_endpoint_and_model() {
6335        let _lock = env_lock();
6336        let _env = EnvGuard::without_deepseek_runtime_overrides();
6337        let config = ConfigToml {
6338            provider: ProviderKind::Openrouter,
6339            ..ConfigToml::default()
6340        };
6341
6342        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6343
6344        assert_eq!(resolved.provider, ProviderKind::Openrouter);
6345        assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL);
6346        assert_eq!(resolved.model, DEFAULT_OPENROUTER_MODEL);
6347    }
6348
6349    #[test]
6350    fn xiaomi_mimo_provider_defaults_to_canonical_endpoint_and_model() {
6351        let _lock = env_lock();
6352        let _env = EnvGuard::without_deepseek_runtime_overrides();
6353        let config = ConfigToml {
6354            provider: ProviderKind::XiaomiMimo,
6355            ..ConfigToml::default()
6356        };
6357
6358        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6359
6360        assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6361        assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
6362        assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL);
6363    }
6364
6365    #[test]
6366    fn xiaomi_provider_alias_table_maps_to_mimo_runtime_config() {
6367        let _lock = env_lock();
6368        let _env = EnvGuard::without_deepseek_runtime_overrides();
6369        let config: ConfigToml = toml::from_str(
6370            r#"
6371provider = "xiaomi-mimo"
6372default_text_model = "deepseek/deepseek-v4-pro"
6373
6374[providers.xiaomi]
6375api_key = "mimo-table-key"
6376base_url = "https://token-plan-sgp.xiaomimimo.com/v1"
6377model = "mimo-v2.5-pro"
6378"#,
6379        )
6380        .expect("xiaomi provider alias config");
6381
6382        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6383
6384        assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6385        assert_eq!(resolved.api_key.as_deref(), Some("mimo-table-key"));
6386        assert_eq!(
6387            resolved.base_url,
6388            "https://token-plan-sgp.xiaomimimo.com/v1"
6389        );
6390        assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL);
6391    }
6392
6393    #[test]
6394    fn xiaomi_token_plan_key_rewrites_saved_pay_as_you_go_base_url() {
6395        let _lock = env_lock();
6396        let _env = EnvGuard::without_deepseek_runtime_overrides();
6397        let config: ConfigToml = toml::from_str(
6398            r#"
6399provider = "xiaomi-mimo"
6400
6401[providers.xiaomi_mimo]
6402api_key = "tp-test-token-plan-key"
6403base_url = "https://api.xiaomimimo.com/v1"
6404model = "mimo-v2.5-pro"
6405"#,
6406        )
6407        .expect("xiaomi token-plan config");
6408
6409        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6410
6411        assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6412        assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
6413        assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL);
6414    }
6415
6416    #[test]
6417    fn xiaomi_mimo_token_plan_mode_accepts_region_aliases() {
6418        let _lock = env_lock();
6419        let _env = EnvGuard::without_deepseek_runtime_overrides();
6420        let config: ConfigToml = toml::from_str(
6421            r#"
6422provider = "mimo"
6423
6424[providers.mimo]
6425mode = "token-plan-ams"
6426"#,
6427        )
6428        .expect("xiaomi token-plan region config");
6429
6430        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6431
6432        assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6433        assert_eq!(resolved.base_url, XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL);
6434    }
6435
6436    #[test]
6437    fn xiaomi_mimo_unknown_mode_stays_on_token_plan_endpoint() {
6438        let _lock = env_lock();
6439        let _env = EnvGuard::without_deepseek_runtime_overrides();
6440        let config: ConfigToml = toml::from_str(
6441            r#"
6442provider = "mimo"
6443
6444[providers.mimo]
6445mode = "token-plan-usa"
6446"#,
6447        )
6448        .expect("xiaomi token-plan unknown mode config");
6449
6450        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6451
6452        assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6453        assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
6454    }
6455
6456    #[test]
6457    fn xiaomi_mimo_aliases_resolve_to_canonical_models() {
6458        assert_eq!(
6459            normalize_model_for_provider(ProviderKind::XiaomiMimo, "omni"),
6460            "mimo-v2.5"
6461        );
6462        assert_eq!(
6463            normalize_model_for_provider(ProviderKind::XiaomiMimo, "tts"),
6464            "mimo-v2.5-tts"
6465        );
6466        assert_eq!(
6467            normalize_model_for_provider(ProviderKind::XiaomiMimo, "voice-design"),
6468            "mimo-v2.5-tts-voicedesign"
6469        );
6470        assert_eq!(
6471            normalize_model_for_provider(ProviderKind::XiaomiMimo, "voiceclone"),
6472            "mimo-v2.5-tts-voiceclone"
6473        );
6474        assert_eq!(
6475            normalize_model_for_provider(ProviderKind::XiaomiMimo, "custom-mimo-model"),
6476            "custom-mimo-model"
6477        );
6478    }
6479
6480    #[test]
6481    fn zai_aliases_resolve_to_canonical_models() {
6482        assert_eq!(
6483            normalize_model_for_provider(ProviderKind::Zai, "glm-5.1"),
6484            DEFAULT_ZAI_MODEL
6485        );
6486        assert_eq!(
6487            normalize_model_for_provider(ProviderKind::Zai, "glm-5-2"),
6488            ZAI_GLM_5_2_MODEL
6489        );
6490        assert_eq!(
6491            normalize_model_for_provider(ProviderKind::Zai, "custom-glm-preview"),
6492            "custom-glm-preview"
6493        );
6494    }
6495
6496    #[test]
6497    fn novita_provider_defaults_to_canonical_endpoint_and_model() {
6498        let _lock = env_lock();
6499        let _env = EnvGuard::without_deepseek_runtime_overrides();
6500        let config = ConfigToml {
6501            provider: ProviderKind::Novita,
6502            ..ConfigToml::default()
6503        };
6504
6505        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6506
6507        assert_eq!(resolved.provider, ProviderKind::Novita);
6508        assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL);
6509        assert_eq!(resolved.model, DEFAULT_NOVITA_MODEL);
6510    }
6511
6512    #[test]
6513    fn fireworks_provider_defaults_to_canonical_endpoint_and_model() {
6514        let _lock = env_lock();
6515        let _env = EnvGuard::without_deepseek_runtime_overrides();
6516        let config = ConfigToml {
6517            provider: ProviderKind::Fireworks,
6518            ..ConfigToml::default()
6519        };
6520
6521        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6522
6523        assert_eq!(resolved.provider, ProviderKind::Fireworks);
6524        assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL);
6525        assert_eq!(resolved.model, DEFAULT_FIREWORKS_MODEL);
6526    }
6527
6528    #[test]
6529    fn siliconflow_provider_defaults_to_canonical_endpoint_and_model() {
6530        let _lock = env_lock();
6531        let _env = EnvGuard::without_deepseek_runtime_overrides();
6532        let config = ConfigToml {
6533            provider: ProviderKind::Siliconflow,
6534            ..ConfigToml::default()
6535        };
6536
6537        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6538
6539        assert_eq!(resolved.provider, ProviderKind::Siliconflow);
6540        assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_BASE_URL);
6541        assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL);
6542    }
6543
6544    #[test]
6545    fn siliconflow_cn_config_falls_back_to_shared_table_when_unset() {
6546        let _lock = env_lock();
6547        let _env = EnvGuard::without_deepseek_runtime_overrides();
6548        let mut config = ConfigToml {
6549            provider: ProviderKind::SiliconflowCN,
6550            ..ConfigToml::default()
6551        };
6552        config.providers.siliconflow.api_key = Some("sf-shared-key".to_string());
6553        config.providers.siliconflow.base_url = Some(DEFAULT_SILICONFLOW_BASE_URL.to_string());
6554        config.providers.siliconflow.model = Some("deepseek-chat".to_string());
6555        config.providers.siliconflow_cn.base_url =
6556            Some(DEFAULT_SILICONFLOW_CN_BASE_URL.to_string());
6557
6558        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6559
6560        assert_eq!(resolved.provider, ProviderKind::SiliconflowCN);
6561        assert_eq!(resolved.api_key.as_deref(), Some("sf-shared-key"));
6562        assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_CN_BASE_URL);
6563        assert_eq!(resolved.model, DEFAULT_SILICONFLOW_FLASH_MODEL);
6564    }
6565
6566    #[test]
6567    fn moonshot_provider_defaults_to_kimi_k27_code() {
6568        let _lock = env_lock();
6569        let _env = EnvGuard::without_deepseek_runtime_overrides();
6570        let config = ConfigToml {
6571            provider: ProviderKind::Moonshot,
6572            ..ConfigToml::default()
6573        };
6574
6575        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6576
6577        assert_eq!(resolved.provider, ProviderKind::Moonshot);
6578        assert_eq!(resolved.base_url, DEFAULT_MOONSHOT_BASE_URL);
6579        assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL);
6580    }
6581
6582    #[test]
6583    fn zai_stepfun_and_minimax_default_to_first_party_routes() {
6584        let _lock = env_lock();
6585        let _env = EnvGuard::without_deepseek_runtime_overrides();
6586
6587        for (provider, expected_base_url, expected_model) in [
6588            (ProviderKind::Zai, DEFAULT_ZAI_BASE_URL, DEFAULT_ZAI_MODEL),
6589            (
6590                ProviderKind::Stepfun,
6591                DEFAULT_STEPFUN_BASE_URL,
6592                DEFAULT_STEPFUN_MODEL,
6593            ),
6594            (
6595                ProviderKind::Minimax,
6596                DEFAULT_MINIMAX_BASE_URL,
6597                DEFAULT_MINIMAX_MODEL,
6598            ),
6599        ] {
6600            let config = ConfigToml {
6601                provider,
6602                ..ConfigToml::default()
6603            };
6604            let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6605
6606            assert_eq!(resolved.provider, provider);
6607            assert_eq!(resolved.base_url, expected_base_url);
6608            assert_eq!(resolved.model, expected_model);
6609        }
6610    }
6611
6612    #[test]
6613    fn first_party_provider_env_model_overrides_pass_through() {
6614        let _lock = env_lock();
6615        let _env = EnvGuard::without_deepseek_runtime_overrides();
6616        unsafe {
6617            env::set_var("CODEWHALE_PROVIDER", "minimax");
6618            env::set_var("MINIMAX_MODEL", "MiniMax-M2.7-highspeed");
6619            env::set_var("MINIMAX_BASE_URL", "https://minimax.example/v1");
6620        }
6621
6622        let resolved =
6623            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6624
6625        assert_eq!(resolved.provider, ProviderKind::Minimax);
6626        assert_eq!(resolved.base_url, "https://minimax.example/v1");
6627        assert_eq!(resolved.model, "MiniMax-M2.7-highspeed");
6628    }
6629
6630    #[test]
6631    fn minimax_env_model_override_canonicalizes_known_aliases() {
6632        let _lock = env_lock();
6633        let _env = EnvGuard::without_deepseek_runtime_overrides();
6634        unsafe {
6635            env::set_var("CODEWHALE_PROVIDER", "minimax");
6636            env::set_var("MINIMAX_MODEL", "minimax-m2-5-highspeed");
6637        }
6638
6639        let resolved =
6640            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6641
6642        assert_eq!(resolved.provider, ProviderKind::Minimax);
6643        assert_eq!(resolved.model, "MiniMax-M2.5-highspeed");
6644    }
6645
6646    #[test]
6647    fn moonshot_provider_preserves_explicit_kimi_k26() {
6648        let _lock = env_lock();
6649        let _env = EnvGuard::without_deepseek_runtime_overrides();
6650        let mut config = ConfigToml {
6651            provider: ProviderKind::Moonshot,
6652            ..ConfigToml::default()
6653        };
6654        config.providers.moonshot.model = Some("kimi-k2.6".to_string());
6655
6656        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6657
6658        assert_eq!(resolved.provider, ProviderKind::Moonshot);
6659        assert_eq!(resolved.model, MOONSHOT_KIMI_K2_6_MODEL);
6660    }
6661
6662    #[test]
6663    fn moonshot_kimi_oauth_uses_kimi_code_endpoint_and_model() {
6664        let _lock = env_lock();
6665        let _env = EnvGuard::without_deepseek_runtime_overrides();
6666        let mut config = ConfigToml {
6667            provider: ProviderKind::Moonshot,
6668            ..ConfigToml::default()
6669        };
6670        config.providers.moonshot.auth_mode = Some("kimi_oauth".to_string());
6671
6672        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6673
6674        assert_eq!(resolved.provider, ProviderKind::Moonshot);
6675        assert_eq!(resolved.auth_mode.as_deref(), Some("kimi_oauth"));
6676        assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
6677        assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
6678        assert_eq!(resolved.api_key, None);
6679        assert_eq!(resolved.api_key_source, None);
6680    }
6681
6682    #[test]
6683    fn moonshot_kimi_code_api_key_endpoint_defaults_to_kimi_for_coding() {
6684        let _lock = env_lock();
6685        let _env = EnvGuard::without_deepseek_runtime_overrides();
6686        let mut config = ConfigToml {
6687            provider: ProviderKind::Moonshot,
6688            ..ConfigToml::default()
6689        };
6690        config.providers.moonshot.api_key = Some("kimi-code-key".to_string());
6691        config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string());
6692
6693        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6694
6695        assert_eq!(resolved.provider, ProviderKind::Moonshot);
6696        assert_eq!(resolved.auth_mode, None);
6697        assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
6698        assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
6699        assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key"));
6700        assert_eq!(
6701            resolved.api_key_source,
6702            Some(RuntimeApiKeySource::ConfigFile)
6703        );
6704    }
6705
6706    /// `CODEWHALE_PROVIDER` is the user-facing env alias for switching the
6707    /// active provider. It must be honored by the runtime resolver and win
6708    /// over a root `provider = "deepseek"` config entry.
6709    #[test]
6710    fn codewhale_provider_env_switches_active_provider() {
6711        let _lock = env_lock();
6712        let _env = EnvGuard::without_deepseek_runtime_overrides();
6713        // Safety: test-only env mutation guarded by env_lock().
6714        unsafe {
6715            env::set_var("CODEWHALE_PROVIDER", "moonshot");
6716        }
6717        let mut config = ConfigToml {
6718            provider: ProviderKind::Deepseek,
6719            ..ConfigToml::default()
6720        };
6721        config.providers.moonshot.api_key = Some("kimi-code-key".to_string());
6722        config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string());
6723
6724        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6725
6726        assert_eq!(resolved.provider, ProviderKind::Moonshot);
6727        assert_eq!(
6728            resolved.provider_source,
6729            ProviderSource::Env("CODEWHALE_PROVIDER")
6730        );
6731        assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
6732        assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
6733        assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key"));
6734    }
6735
6736    /// When both `CODEWHALE_PROVIDER` and the legacy `DEEPSEEK_PROVIDER`
6737    /// are set, the public alias wins — a user adopting `CODEWHALE_*` in a
6738    /// fresh shell config is not tripped up by a stale legacy export still
6739    /// living in their dotfiles.
6740    #[test]
6741    fn codewhale_provider_env_wins_over_deepseek_provider_env() {
6742        let _lock = env_lock();
6743        let _env = EnvGuard::without_deepseek_runtime_overrides();
6744        // Safety: test-only env mutation guarded by env_lock().
6745        unsafe {
6746            env::set_var("CODEWHALE_PROVIDER", "moonshot");
6747            env::set_var("DEEPSEEK_PROVIDER", "openrouter");
6748        }
6749        let config = ConfigToml {
6750            provider: ProviderKind::Deepseek,
6751            ..ConfigToml::default()
6752        };
6753
6754        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6755
6756        assert_eq!(resolved.provider, ProviderKind::Moonshot);
6757        assert_eq!(
6758            resolved.provider_source,
6759            ProviderSource::Env("CODEWHALE_PROVIDER")
6760        );
6761    }
6762
6763    #[test]
6764    fn legacy_deepseek_provider_env_records_provider_source() {
6765        let _lock = env_lock();
6766        let _env = EnvGuard::without_deepseek_runtime_overrides();
6767        // Safety: test-only env mutation guarded by env_lock().
6768        unsafe {
6769            env::set_var("DEEPSEEK_PROVIDER", "openrouter");
6770        }
6771        let config = ConfigToml {
6772            provider: ProviderKind::Deepseek,
6773            ..ConfigToml::default()
6774        };
6775
6776        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6777
6778        assert_eq!(resolved.provider, ProviderKind::Openrouter);
6779        assert_eq!(
6780            resolved.provider_source,
6781            ProviderSource::Env("DEEPSEEK_PROVIDER")
6782        );
6783    }
6784
6785    #[test]
6786    fn cli_provider_records_provider_source() {
6787        let _lock = env_lock();
6788        let _env = EnvGuard::without_deepseek_runtime_overrides();
6789        // Safety: test-only env mutation guarded by env_lock().
6790        unsafe {
6791            env::set_var("CODEWHALE_PROVIDER", "moonshot");
6792        }
6793        let cli = CliRuntimeOverrides {
6794            provider: Some(ProviderKind::Openai),
6795            ..CliRuntimeOverrides::default()
6796        };
6797        let config = ConfigToml {
6798            provider: ProviderKind::Deepseek,
6799            ..ConfigToml::default()
6800        };
6801
6802        let resolved = config.resolve_runtime_options(&cli);
6803
6804        assert_eq!(resolved.provider, ProviderKind::Openai);
6805        assert_eq!(resolved.provider_source, ProviderSource::Cli);
6806    }
6807
6808    #[test]
6809    fn config_provider_records_provider_source() {
6810        let _lock = env_lock();
6811        let _env = EnvGuard::without_deepseek_runtime_overrides();
6812        let config = ConfigToml {
6813            provider: ProviderKind::Moonshot,
6814            ..ConfigToml::default()
6815        };
6816
6817        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6818
6819        assert_eq!(resolved.provider, ProviderKind::Moonshot);
6820        assert_eq!(resolved.provider_source, ProviderSource::Config);
6821    }
6822
6823    /// `CODEWHALE_MODEL` is the user-facing env alias for picking a model
6824    /// against the active provider. It must be honored by the runtime
6825    /// resolver in place of `DEEPSEEK_MODEL`.
6826    #[test]
6827    fn codewhale_model_env_alias_overrides_default_for_active_provider() {
6828        let _lock = env_lock();
6829        let _env = EnvGuard::without_deepseek_runtime_overrides();
6830        // Safety: test-only env mutation guarded by env_lock().
6831        unsafe {
6832            env::set_var("CODEWHALE_PROVIDER", "moonshot");
6833            env::set_var("CODEWHALE_MODEL", "custom-kimi-test-model");
6834        }
6835        let config = ConfigToml::default();
6836
6837        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6838
6839        assert_eq!(resolved.provider, ProviderKind::Moonshot);
6840        assert_eq!(resolved.model, "custom-kimi-test-model");
6841    }
6842
6843    #[test]
6844    fn blank_codewhale_model_env_alias_does_not_override_default_for_active_provider() {
6845        let _lock = env_lock();
6846        let _env = EnvGuard::without_deepseek_runtime_overrides();
6847        // Safety: test-only env mutation guarded by env_lock().
6848        unsafe {
6849            env::set_var("CODEWHALE_PROVIDER", "moonshot");
6850            env::set_var("CODEWHALE_MODEL", "   ");
6851        }
6852        let config = ConfigToml::default();
6853
6854        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6855
6856        assert_eq!(resolved.provider, ProviderKind::Moonshot);
6857        assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL);
6858    }
6859
6860    #[test]
6861    fn deepseek_default_text_model_legacy_alias_still_overrides_active_provider_model() {
6862        let _lock = env_lock();
6863        let _env = EnvGuard::without_deepseek_runtime_overrides();
6864        // Safety: test-only env mutation guarded by env_lock().
6865        unsafe {
6866            env::set_var("CODEWHALE_PROVIDER", "moonshot");
6867            env::set_var("DEEPSEEK_DEFAULT_TEXT_MODEL", "legacy-env-model");
6868        }
6869        let config = ConfigToml::default();
6870
6871        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6872
6873        assert_eq!(resolved.provider, ProviderKind::Moonshot);
6874        assert_eq!(resolved.model, "legacy-env-model");
6875    }
6876
6877    #[test]
6878    fn wanjie_ark_provider_defaults_to_openai_compatible_endpoint_and_model() {
6879        let _lock = env_lock();
6880        let _env = EnvGuard::without_deepseek_runtime_overrides();
6881        let config = ConfigToml {
6882            provider: ProviderKind::WanjieArk,
6883            ..ConfigToml::default()
6884        };
6885
6886        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6887
6888        assert_eq!(resolved.provider, ProviderKind::WanjieArk);
6889        assert_eq!(resolved.base_url, DEFAULT_WANJIE_ARK_BASE_URL);
6890        assert_eq!(resolved.model, DEFAULT_WANJIE_ARK_MODEL);
6891    }
6892
6893    #[test]
6894    fn sglang_provider_defaults_to_local_endpoint_and_model() {
6895        let _lock = env_lock();
6896        let _env = EnvGuard::without_deepseek_runtime_overrides();
6897        let config = ConfigToml {
6898            provider: ProviderKind::Sglang,
6899            ..ConfigToml::default()
6900        };
6901
6902        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6903
6904        assert_eq!(resolved.provider, ProviderKind::Sglang);
6905        assert_eq!(resolved.base_url, DEFAULT_SGLANG_BASE_URL);
6906        assert_eq!(resolved.model, DEFAULT_SGLANG_MODEL);
6907    }
6908
6909    #[test]
6910    fn vllm_provider_defaults_to_local_endpoint_and_model() {
6911        let _lock = env_lock();
6912        let _env = EnvGuard::without_deepseek_runtime_overrides();
6913        let config = ConfigToml {
6914            provider: ProviderKind::Vllm,
6915            ..ConfigToml::default()
6916        };
6917
6918        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6919
6920        assert_eq!(resolved.provider, ProviderKind::Vllm);
6921        assert_eq!(resolved.base_url, DEFAULT_VLLM_BASE_URL);
6922        assert_eq!(resolved.model, DEFAULT_VLLM_MODEL);
6923    }
6924
6925    #[test]
6926    fn ollama_provider_defaults_to_local_endpoint_and_small_model() {
6927        let _lock = env_lock();
6928        let _env = EnvGuard::without_deepseek_runtime_overrides();
6929        let config = ConfigToml {
6930            provider: ProviderKind::Ollama,
6931            ..ConfigToml::default()
6932        };
6933
6934        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6935
6936        assert_eq!(resolved.provider, ProviderKind::Ollama);
6937        assert_eq!(resolved.base_url, DEFAULT_OLLAMA_BASE_URL);
6938        assert_eq!(resolved.model, DEFAULT_OLLAMA_MODEL);
6939        assert_eq!(resolved.api_key, None);
6940    }
6941
6942    #[test]
6943    fn self_hosted_providers_do_not_probe_secret_store_by_default() {
6944        let _lock = env_lock();
6945        let _env = EnvGuard::without_deepseek_runtime_overrides();
6946        let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
6947        let secrets = Secrets::new(store.clone());
6948
6949        for provider in [
6950            ProviderKind::Sglang,
6951            ProviderKind::Vllm,
6952            ProviderKind::Ollama,
6953        ] {
6954            let config = ConfigToml {
6955                provider,
6956                ..ConfigToml::default()
6957            };
6958
6959            let resolved = config
6960                .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
6961
6962            assert_eq!(resolved.provider, provider);
6963            assert_eq!(resolved.api_key, None);
6964        }
6965
6966        assert!(
6967            store.gets.lock().unwrap().is_empty(),
6968            "self-hosted providers should not read the secret store by default"
6969        );
6970    }
6971
6972    #[test]
6973    fn self_hosted_api_key_auth_can_use_secret_store_when_requested() {
6974        let _lock = env_lock();
6975        let _env = EnvGuard::without_deepseek_runtime_overrides();
6976        let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
6977        let secrets = Secrets::new(store.clone());
6978        let config = ConfigToml {
6979            provider: ProviderKind::Ollama,
6980            auth_mode: Some("api_key".to_string()),
6981            ..ConfigToml::default()
6982        };
6983
6984        let resolved =
6985            config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
6986
6987        assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key"));
6988        assert_eq!(store.gets.lock().unwrap().as_slice(), ["ollama"]);
6989    }
6990
6991    #[test]
6992    fn moonshot_api_key_mode_can_use_secret_store_by_default() {
6993        let _lock = env_lock();
6994        let _env = EnvGuard::without_deepseek_runtime_overrides();
6995        let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
6996        let secrets = Secrets::new(store.clone());
6997        let config = ConfigToml {
6998            provider: ProviderKind::Moonshot,
6999            ..ConfigToml::default()
7000        };
7001
7002        let resolved =
7003            config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7004
7005        assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key"));
7006        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
7007        assert_eq!(store.gets.lock().unwrap().as_slice(), ["moonshot"]);
7008    }
7009
7010    #[test]
7011    fn loopback_custom_deepseek_base_url_does_not_probe_secret_store_by_default() {
7012        let _lock = env_lock();
7013        let _env = EnvGuard::without_deepseek_runtime_overrides();
7014        let store = Arc::new(RecordingSecretsStore::with_value("stale-deepseek-key"));
7015        let secrets = Secrets::new(store.clone());
7016        let config = ConfigToml {
7017            base_url: Some("http://127.0.0.1:8000/v1".to_string()),
7018            ..ConfigToml::default()
7019        };
7020
7021        let resolved =
7022            config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7023
7024        assert_eq!(resolved.provider, ProviderKind::Deepseek);
7025        assert_eq!(resolved.base_url, "http://127.0.0.1:8000/v1");
7026        assert_eq!(resolved.api_key, None);
7027        assert!(
7028            store.gets.lock().unwrap().is_empty(),
7029            "loopback custom endpoints should not read macOS Keychain or any secret store"
7030        );
7031    }
7032
7033    #[test]
7034    fn ollama_provider_preserves_model_tags() {
7035        let _lock = env_lock();
7036        let _env = EnvGuard::without_deepseek_runtime_overrides();
7037        let cli = CliRuntimeOverrides {
7038            provider: Some(ProviderKind::Ollama),
7039            model: Some("deepseek-coder-v2:16b".to_string()),
7040            ..CliRuntimeOverrides::default()
7041        };
7042
7043        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7044
7045        assert_eq!(resolved.provider, ProviderKind::Ollama);
7046        assert_eq!(resolved.model, "deepseek-coder-v2:16b");
7047    }
7048
7049    #[test]
7050    fn ollama_env_overrides_provider_base_url_and_optional_key() {
7051        let _lock = env_lock();
7052        let _env = EnvGuard::without_deepseek_runtime_overrides();
7053        // Safety: test-only environment mutation guarded by a module mutex.
7054        unsafe {
7055            env::set_var("DEEPSEEK_PROVIDER", "ollama-local");
7056            env::set_var("OLLAMA_BASE_URL", "http://ollama.example/v1");
7057            env::set_var("OLLAMA_API_KEY", "ollama-env-key");
7058        }
7059
7060        let resolved =
7061            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7062
7063        assert_eq!(resolved.provider, ProviderKind::Ollama);
7064        assert_eq!(resolved.base_url, "http://ollama.example/v1");
7065        assert_eq!(resolved.api_key.as_deref(), Some("ollama-env-key"));
7066    }
7067
7068    #[test]
7069    fn openrouter_env_overrides_key_and_model_when_config_missing() {
7070        let _lock = env_lock();
7071        let _env = EnvGuard::without_deepseek_runtime_overrides();
7072        // Safety: test-only environment mutation guarded by a module mutex.
7073        unsafe {
7074            env::set_var("DEEPSEEK_PROVIDER", "openrouter");
7075            env::set_var("OPENROUTER_API_KEY", "or-env-key");
7076            env::set_var("OPENROUTER_MODEL", "deepseek-v4-flash");
7077        }
7078
7079        let resolved =
7080            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7081
7082        assert_eq!(resolved.provider, ProviderKind::Openrouter);
7083        assert_eq!(resolved.api_key.as_deref(), Some("or-env-key"));
7084        assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL);
7085        assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL);
7086    }
7087
7088    #[test]
7089    fn xiaomi_mimo_env_overrides_provider_key_base_url_and_model() {
7090        let _lock = env_lock();
7091        let _env = EnvGuard::without_deepseek_runtime_overrides();
7092        // Safety: test-only environment mutation guarded by a module mutex.
7093        unsafe {
7094            env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
7095            env::set_var("MIMO_API_KEY", "mimo-env-key");
7096            env::set_var("MIMO_BASE_URL", "https://mimo-gateway.example/v1");
7097            env::set_var("MIMO_MODEL", "mimo-v2.5");
7098        }
7099
7100        let resolved =
7101            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7102
7103        assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
7104        assert_eq!(resolved.api_key.as_deref(), Some("mimo-env-key"));
7105        assert_eq!(resolved.base_url, "https://mimo-gateway.example/v1");
7106        assert_eq!(resolved.model, "mimo-v2.5");
7107    }
7108
7109    #[test]
7110    fn xiaomi_mimo_env_token_plan_mode_uses_token_plan_key_and_endpoint() {
7111        let _lock = env_lock();
7112        let _env = EnvGuard::without_deepseek_runtime_overrides();
7113        // Safety: test-only environment mutation guarded by a module mutex.
7114        unsafe {
7115            env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
7116            env::set_var("XIAOMI_MIMO_MODE", "token-plan-cn");
7117            env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key");
7118            env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key");
7119        }
7120
7121        let resolved =
7122            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7123
7124        assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
7125        assert_eq!(resolved.api_key.as_deref(), Some("tp-env-key"));
7126        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
7127        assert_eq!(resolved.base_url, XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL);
7128    }
7129
7130    #[test]
7131    fn xiaomi_mimo_env_pay_as_you_go_mode_prefers_standard_key() {
7132        let _lock = env_lock();
7133        let _env = EnvGuard::without_deepseek_runtime_overrides();
7134        // Safety: test-only environment mutation guarded by a module mutex.
7135        unsafe {
7136            env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
7137            env::set_var("XIAOMI_MIMO_MODE", "pay-as-you-go");
7138            env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key");
7139            env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key");
7140        }
7141
7142        let resolved =
7143            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7144
7145        assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
7146        assert_eq!(resolved.api_key.as_deref(), Some("sk-env-key"));
7147        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
7148        assert_eq!(resolved.base_url, XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL);
7149    }
7150
7151    #[test]
7152    fn novita_env_overrides_key_and_model_when_config_missing() {
7153        let _lock = env_lock();
7154        let _env = EnvGuard::without_deepseek_runtime_overrides();
7155        // Safety: test-only environment mutation guarded by a module mutex.
7156        unsafe {
7157            env::set_var("DEEPSEEK_PROVIDER", "novita");
7158            env::set_var("NOVITA_API_KEY", "novita-env-key");
7159            env::set_var("NOVITA_MODEL", "deepseek-v4-flash");
7160        }
7161
7162        let resolved =
7163            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7164
7165        assert_eq!(resolved.provider, ProviderKind::Novita);
7166        assert_eq!(resolved.api_key.as_deref(), Some("novita-env-key"));
7167        assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL);
7168        assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL);
7169    }
7170
7171    #[test]
7172    fn fireworks_env_overrides_key_and_model_when_config_missing() {
7173        let _lock = env_lock();
7174        let _env = EnvGuard::without_deepseek_runtime_overrides();
7175        // Safety: test-only environment mutation guarded by a module mutex.
7176        unsafe {
7177            env::set_var("DEEPSEEK_PROVIDER", "fireworks");
7178            env::set_var("FIREWORKS_API_KEY", "fw-env-key");
7179            env::set_var(
7180                "FIREWORKS_MODEL",
7181                "accounts/fireworks/models/account-specific-model",
7182            );
7183        }
7184
7185        let resolved =
7186            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7187
7188        assert_eq!(resolved.provider, ProviderKind::Fireworks);
7189        assert_eq!(resolved.api_key.as_deref(), Some("fw-env-key"));
7190        assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL);
7191        assert_eq!(
7192            resolved.model,
7193            "accounts/fireworks/models/account-specific-model"
7194        );
7195    }
7196
7197    #[test]
7198    fn siliconflow_env_overrides_key_base_url_and_model() {
7199        let _lock = env_lock();
7200        let _env = EnvGuard::without_deepseek_runtime_overrides();
7201        // Safety: test-only environment mutation guarded by a module mutex.
7202        unsafe {
7203            env::set_var("CODEWHALE_PROVIDER", "siliconflow");
7204            env::set_var("SILICONFLOW_API_KEY", "sf-env-key");
7205            env::set_var("SILICONFLOW_BASE_URL", "https://sf-mirror.example/v1");
7206            env::set_var("SILICONFLOW_MODEL", "deepseek-v4-flash");
7207        }
7208
7209        let resolved =
7210            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7211
7212        assert_eq!(resolved.provider, ProviderKind::Siliconflow);
7213        assert_eq!(resolved.api_key.as_deref(), Some("sf-env-key"));
7214        assert_eq!(resolved.base_url, "https://sf-mirror.example/v1");
7215        assert_eq!(resolved.model, "deepseek-v4-flash");
7216    }
7217
7218    #[test]
7219    fn arcee_provider_defaults_to_direct_api_endpoint_and_model() {
7220        let _lock = env_lock();
7221        let _env = EnvGuard::without_deepseek_runtime_overrides();
7222        let config = ConfigToml {
7223            provider: ProviderKind::Arcee,
7224            ..ConfigToml::default()
7225        };
7226
7227        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7228
7229        assert_eq!(resolved.provider, ProviderKind::Arcee);
7230        assert_eq!(resolved.base_url, DEFAULT_ARCEE_BASE_URL);
7231        assert_eq!(resolved.model, DEFAULT_ARCEE_MODEL);
7232    }
7233
7234    #[test]
7235    fn arcee_env_overrides_key_base_url_and_model() {
7236        let _lock = env_lock();
7237        let _env = EnvGuard::without_deepseek_runtime_overrides();
7238        // Safety: test-only environment mutation guarded by a module mutex.
7239        unsafe {
7240            env::set_var("CODEWHALE_PROVIDER", "arcee");
7241            env::set_var("ARCEE_API_KEY", "arcee-env-key");
7242            env::set_var("ARCEE_BASE_URL", "https://arcee-mirror.example/api/v1");
7243            env::set_var("ARCEE_MODEL", "trinity-large-preview");
7244        }
7245
7246        let resolved =
7247            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7248
7249        assert_eq!(resolved.provider, ProviderKind::Arcee);
7250        assert_eq!(resolved.api_key.as_deref(), Some("arcee-env-key"));
7251        assert_eq!(resolved.base_url, "https://arcee-mirror.example/api/v1");
7252        assert_eq!(resolved.model, "trinity-large-preview");
7253    }
7254
7255    #[test]
7256    fn arcee_provider_config_overrides_runtime_defaults() {
7257        let _lock = env_lock();
7258        let _env = EnvGuard::without_deepseek_runtime_overrides();
7259        let mut config = ConfigToml {
7260            provider: ProviderKind::Arcee,
7261            ..ConfigToml::default()
7262        };
7263        config.providers.arcee.api_key = Some("arcee-file-key".to_string());
7264        config.providers.arcee.base_url = Some(DEFAULT_ARCEE_BASE_URL.to_string());
7265        config.providers.arcee.model = Some("arcee-trinity-large-preview".to_string());
7266
7267        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7268
7269        assert_eq!(resolved.provider, ProviderKind::Arcee);
7270        assert_eq!(resolved.api_key.as_deref(), Some("arcee-file-key"));
7271        assert_eq!(resolved.base_url, DEFAULT_ARCEE_BASE_URL);
7272        assert_eq!(resolved.model, ARCEE_TRINITY_LARGE_PREVIEW_MODEL);
7273    }
7274
7275    #[test]
7276    fn huggingface_env_precedence_prefers_documented_names() {
7277        let _lock = env_lock();
7278        let _env = EnvGuard::without_deepseek_runtime_overrides();
7279        // Safety: test-only environment mutation guarded by a module mutex.
7280        unsafe {
7281            env::set_var("CODEWHALE_PROVIDER", "hf");
7282            env::set_var("HUGGINGFACE_API_KEY", "hf-full-key");
7283            env::set_var("HF_TOKEN", "hf-token-fallback");
7284            env::set_var("HUGGINGFACE_BASE_URL", "https://hf-full.example/v1");
7285            env::set_var("HF_BASE_URL", "https://hf-short.example/v1");
7286            env::set_var("HUGGINGFACE_MODEL", "org/full-model");
7287            env::set_var("HF_MODEL", "org/short-model");
7288        }
7289
7290        let resolved =
7291            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7292
7293        assert_eq!(resolved.provider, ProviderKind::Huggingface);
7294        assert_eq!(resolved.api_key.as_deref(), Some("hf-full-key"));
7295        assert_eq!(resolved.base_url, "https://hf-full.example/v1");
7296        assert_eq!(resolved.model, "org/full-model");
7297    }
7298
7299    #[test]
7300    fn huggingface_short_env_fallbacks_resolve_when_primary_names_are_absent() {
7301        let _lock = env_lock();
7302        let _env = EnvGuard::without_deepseek_runtime_overrides();
7303        // Safety: test-only environment mutation guarded by a module mutex.
7304        unsafe {
7305            env::set_var("CODEWHALE_PROVIDER", "huggingface");
7306            env::set_var("HF_TOKEN", "hf-token-fallback");
7307            env::set_var("HF_BASE_URL", "https://hf-short.example/v1");
7308            env::set_var("HF_MODEL", "org/short-model");
7309        }
7310
7311        let resolved =
7312            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7313
7314        assert_eq!(resolved.provider, ProviderKind::Huggingface);
7315        assert_eq!(resolved.api_key.as_deref(), Some("hf-token-fallback"));
7316        assert_eq!(resolved.base_url, "https://hf-short.example/v1");
7317        assert_eq!(resolved.model, "org/short-model");
7318    }
7319
7320    #[test]
7321    fn huggingface_token_fallback_resolves_when_primary_api_key_is_blank() {
7322        let _lock = env_lock();
7323        let _env = EnvGuard::without_deepseek_runtime_overrides();
7324        // Safety: test-only environment mutation guarded by a module mutex.
7325        unsafe {
7326            env::set_var("CODEWHALE_PROVIDER", "huggingface");
7327            env::set_var("HUGGINGFACE_API_KEY", " ");
7328            env::set_var("HF_TOKEN", "hf-token-fallback");
7329        }
7330
7331        let resolved =
7332            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7333
7334        assert_eq!(resolved.provider, ProviderKind::Huggingface);
7335        assert_eq!(resolved.api_key.as_deref(), Some("hf-token-fallback"));
7336    }
7337
7338    #[test]
7339    fn siliconflow_cn_base_url_env_normalizes_model_aliases() {
7340        let _lock = env_lock();
7341        let _env = EnvGuard::without_deepseek_runtime_overrides();
7342        // Safety: test-only environment mutation guarded by a module mutex.
7343        unsafe {
7344            env::set_var("CODEWHALE_PROVIDER", "siliconflow");
7345            env::set_var("SILICONFLOW_API_KEY", "sf-env-key");
7346            env::set_var("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1");
7347        }
7348
7349        for (alias, expected) in [
7350            ("deepseek-v4-flash", DEFAULT_SILICONFLOW_FLASH_MODEL),
7351            ("deepseek-reasoner", DEFAULT_SILICONFLOW_MODEL),
7352        ] {
7353            // Safety: test-only environment mutation guarded by a module mutex.
7354            unsafe {
7355                env::set_var("SILICONFLOW_MODEL", alias);
7356            }
7357
7358            let resolved =
7359                ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7360
7361            assert_eq!(resolved.provider, ProviderKind::Siliconflow);
7362            assert_eq!(resolved.base_url, "https://api.siliconflow.cn/v1");
7363            assert_eq!(resolved.model, expected);
7364        }
7365    }
7366
7367    #[test]
7368    fn wanjie_ark_env_api_key_and_base_url_fall_back_when_config_missing() {
7369        let _lock = env_lock();
7370        let _env = EnvGuard::without_deepseek_runtime_overrides();
7371        // Safety: test-only environment mutation guarded by a module mutex.
7372        unsafe {
7373            env::set_var("DEEPSEEK_PROVIDER", "wanjie-ark");
7374            env::set_var("WANJIE_ARK_API_KEY", "wanjie-env-key");
7375            env::set_var("WANJIE_ARK_BASE_URL", "https://wanjie.example/api/v1");
7376            env::set_var("WANJIE_ARK_MODEL", "account-model-id");
7377        }
7378
7379        let resolved =
7380            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7381
7382        assert_eq!(resolved.provider, ProviderKind::WanjieArk);
7383        assert_eq!(resolved.api_key.as_deref(), Some("wanjie-env-key"));
7384        assert_eq!(resolved.base_url, "https://wanjie.example/api/v1");
7385        assert_eq!(resolved.model, "account-model-id");
7386    }
7387
7388    #[test]
7389    fn volcengine_env_aliases_override_key_base_url_and_model() {
7390        let _lock = env_lock();
7391        let _env = EnvGuard::without_deepseek_runtime_overrides();
7392        // Safety: test-only environment mutation guarded by a module mutex.
7393        unsafe {
7394            env::set_var("DEEPSEEK_PROVIDER", "volcengine");
7395            env::set_var("ARK_API_KEY", "volcengine-env-key");
7396            env::set_var("ARK_BASE_URL", "https://volcengine.example/api/coding/v3");
7397            env::set_var("VOLCENGINE_ARK_MODEL", "DeepSeek-V4-Flash");
7398        }
7399
7400        let resolved =
7401            ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7402
7403        assert_eq!(resolved.provider, ProviderKind::Volcengine);
7404        assert_eq!(resolved.api_key.as_deref(), Some("volcengine-env-key"));
7405        assert_eq!(
7406            resolved.base_url,
7407            "https://volcengine.example/api/coding/v3"
7408        );
7409        assert_eq!(resolved.model, "DeepSeek-V4-Flash");
7410    }
7411
7412    #[test]
7413    fn openrouter_provider_normalizes_flash_aliases() {
7414        let _lock = env_lock();
7415        let _env = EnvGuard::without_deepseek_runtime_overrides();
7416        let cli = CliRuntimeOverrides {
7417            provider: Some(ProviderKind::Openrouter),
7418            model: Some("deepseek-v4-flash".to_string()),
7419            ..CliRuntimeOverrides::default()
7420        };
7421
7422        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7423
7424        assert_eq!(resolved.provider, ProviderKind::Openrouter);
7425        assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL);
7426    }
7427
7428    #[test]
7429    fn qwen3_6_plus_resolves_to_canonical_on_openrouter() {
7430        let _lock = env_lock();
7431        let _env = EnvGuard::without_deepseek_runtime_overrides();
7432        let config = ConfigToml {
7433            provider: ProviderKind::Openrouter,
7434            ..ConfigToml::default()
7435        };
7436
7437        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides {
7438            model: Some("qwen3.6-plus".to_string()),
7439            ..CliRuntimeOverrides::default()
7440        });
7441
7442        assert_eq!(resolved.provider, ProviderKind::Openrouter);
7443        assert_eq!(resolved.model, OPENROUTER_QWEN_3_6_PLUS_MODEL);
7444    }
7445
7446    #[test]
7447    fn qwen3_6_plus_alias_qwen_dash_resolves() {
7448        let _lock = env_lock();
7449        let _env = EnvGuard::without_deepseek_runtime_overrides();
7450        let config = ConfigToml {
7451            provider: ProviderKind::Openrouter,
7452            ..ConfigToml::default()
7453        };
7454
7455        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides {
7456            model: Some("qwen-3.6-plus".to_string()),
7457            ..CliRuntimeOverrides::default()
7458        });
7459
7460        assert_eq!(resolved.model, OPENROUTER_QWEN_3_6_PLUS_MODEL);
7461    }
7462
7463    #[test]
7464    fn openrouter_provider_normalizes_recent_large_model_aliases() {
7465        let _lock = env_lock();
7466        let _env = EnvGuard::without_deepseek_runtime_overrides();
7467
7468        for (alias, expected) in [
7469            (
7470                "trinity-large-thinking",
7471                OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL,
7472            ),
7473            ("qwen3.6-flash", OPENROUTER_QWEN_3_6_FLASH_MODEL),
7474            ("qwen3.6-35b-a3b", OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
7475            ("qwen3.6-max-preview", OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
7476            ("qwen3.6-plus", OPENROUTER_QWEN_3_6_PLUS_MODEL),
7477            ("mimo-v2.5-pro", OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
7478            ("kimi-k2.7-code", OPENROUTER_KIMI_K2_7_CODE_MODEL),
7479            ("kimi", OPENROUTER_KIMI_K2_7_CODE_MODEL),
7480            ("kimi-k2.6", OPENROUTER_KIMI_K2_6_MODEL),
7481            ("minimax-m3", OPENROUTER_MINIMAX_M3_MODEL),
7482            ("minimax-2.7", OPENROUTER_MINIMAX_2_7_MODEL),
7483            ("gemma-4-31b-it", OPENROUTER_GEMMA_4_31B_MODEL),
7484            ("glm-5.1", OPENROUTER_GLM_5_1_MODEL),
7485            ("glm-5.2", OPENROUTER_GLM_5_2_MODEL),
7486        ] {
7487            let cli = CliRuntimeOverrides {
7488                provider: Some(ProviderKind::Openrouter),
7489                model: Some(alias.to_string()),
7490                ..CliRuntimeOverrides::default()
7491            };
7492
7493            let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7494
7495            assert_eq!(resolved.provider, ProviderKind::Openrouter);
7496            assert_eq!(resolved.model, expected);
7497        }
7498    }
7499
7500    #[test]
7501    fn novita_provider_normalizes_flash_aliases() {
7502        let _lock = env_lock();
7503        let _env = EnvGuard::without_deepseek_runtime_overrides();
7504        let cli = CliRuntimeOverrides {
7505            provider: Some(ProviderKind::Novita),
7506            model: Some("deepseek-v4-flash".to_string()),
7507            ..CliRuntimeOverrides::default()
7508        };
7509
7510        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7511
7512        assert_eq!(resolved.provider, ProviderKind::Novita);
7513        assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL);
7514    }
7515
7516    #[test]
7517    fn siliconflow_provider_normalizes_flash_aliases() {
7518        let _lock = env_lock();
7519        let _env = EnvGuard::without_deepseek_runtime_overrides();
7520        let cli = CliRuntimeOverrides {
7521            provider: Some(ProviderKind::Siliconflow),
7522            model: Some("deepseek-v4-flash".to_string()),
7523            ..CliRuntimeOverrides::default()
7524        };
7525
7526        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7527
7528        assert_eq!(resolved.provider, ProviderKind::Siliconflow);
7529        assert_eq!(resolved.model, DEFAULT_SILICONFLOW_FLASH_MODEL);
7530    }
7531
7532    #[test]
7533    fn siliconflow_provider_normalizes_reasoning_aliases_to_pro() {
7534        let _lock = env_lock();
7535        let _env = EnvGuard::without_deepseek_runtime_overrides();
7536
7537        for alias in ["deepseek-reasoner", "deepseek-r1"] {
7538            let cli = CliRuntimeOverrides {
7539                provider: Some(ProviderKind::Siliconflow),
7540                model: Some(alias.to_string()),
7541                ..CliRuntimeOverrides::default()
7542            };
7543
7544            let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7545
7546            assert_eq!(resolved.provider, ProviderKind::Siliconflow);
7547            assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL);
7548        }
7549    }
7550
7551    #[test]
7552    fn siliconflow_provider_preserves_deepseek_v3_2_alias() {
7553        let _lock = env_lock();
7554        let _env = EnvGuard::without_deepseek_runtime_overrides();
7555        let cli = CliRuntimeOverrides {
7556            provider: Some(ProviderKind::Siliconflow),
7557            model: Some("deepseek-v3.2".to_string()),
7558            ..CliRuntimeOverrides::default()
7559        };
7560
7561        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7562
7563        assert_eq!(resolved.provider, ProviderKind::Siliconflow);
7564        assert_eq!(resolved.model, "deepseek-v3.2");
7565    }
7566
7567    #[test]
7568    fn sglang_provider_normalizes_flash_aliases() {
7569        let _lock = env_lock();
7570        let _env = EnvGuard::without_deepseek_runtime_overrides();
7571        let cli = CliRuntimeOverrides {
7572            provider: Some(ProviderKind::Sglang),
7573            model: Some("deepseek-v4-flash".to_string()),
7574            ..CliRuntimeOverrides::default()
7575        };
7576
7577        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7578
7579        assert_eq!(resolved.provider, ProviderKind::Sglang);
7580        assert_eq!(resolved.model, DEFAULT_SGLANG_FLASH_MODEL);
7581    }
7582
7583    #[test]
7584    fn vllm_provider_normalizes_flash_aliases() {
7585        let _lock = env_lock();
7586        let _env = EnvGuard::without_deepseek_runtime_overrides();
7587        let cli = CliRuntimeOverrides {
7588            provider: Some(ProviderKind::Vllm),
7589            model: Some("deepseek-v4-flash".to_string()),
7590            ..CliRuntimeOverrides::default()
7591        };
7592
7593        let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7594
7595        assert_eq!(resolved.provider, ProviderKind::Vllm);
7596        assert_eq!(resolved.model, DEFAULT_VLLM_FLASH_MODEL);
7597    }
7598
7599    #[test]
7600    fn openrouter_provider_specific_config_overrides_env() {
7601        let _lock = env_lock();
7602        let _env = EnvGuard::without_deepseek_runtime_overrides();
7603        let mut config = ConfigToml {
7604            provider: ProviderKind::Openrouter,
7605            ..ConfigToml::default()
7606        };
7607        config.providers.openrouter.api_key = Some("file-key".to_string());
7608        config.providers.openrouter.base_url = Some("https://or-mirror.example/v1".to_string());
7609
7610        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7611
7612        assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
7613        assert_eq!(resolved.base_url, "https://or-mirror.example/v1");
7614    }
7615
7616    #[test]
7617    fn openrouter_custom_base_url_preserves_provider_model() {
7618        let _lock = env_lock();
7619        let _env = EnvGuard::without_deepseek_runtime_overrides();
7620        let mut config = ConfigToml {
7621            provider: ProviderKind::Openrouter,
7622            ..ConfigToml::default()
7623        };
7624        config.providers.openrouter.base_url = Some("https://gateway.example.com/v1".to_string());
7625        config.providers.openrouter.model = Some("DeepSeek-V4-Pro".to_string());
7626
7627        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7628
7629        assert_eq!(resolved.provider, ProviderKind::Openrouter);
7630        assert_eq!(resolved.base_url, "https://gateway.example.com/v1");
7631        assert_eq!(resolved.model, "DeepSeek-V4-Pro");
7632    }
7633
7634    #[test]
7635    fn fireworks_custom_base_url_preserves_provider_model() {
7636        let _lock = env_lock();
7637        let _env = EnvGuard::without_deepseek_runtime_overrides();
7638        let mut config = ConfigToml {
7639            provider: ProviderKind::Fireworks,
7640            ..ConfigToml::default()
7641        };
7642        config.providers.fireworks.base_url = Some("https://my-gateway.example/v1".to_string());
7643        config.providers.fireworks.model = Some("DeepSeek-V4-Pro".to_string());
7644
7645        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7646
7647        assert_eq!(resolved.provider, ProviderKind::Fireworks);
7648        assert_eq!(resolved.base_url, "https://my-gateway.example/v1");
7649        // Custom base URL skips provider-specific model prefixing.
7650        assert_eq!(resolved.model, "DeepSeek-V4-Pro");
7651    }
7652
7653    #[test]
7654    fn siliconflow_custom_base_url_preserves_provider_model() {
7655        let _lock = env_lock();
7656        let _env = EnvGuard::without_deepseek_runtime_overrides();
7657        let mut config = ConfigToml {
7658            provider: ProviderKind::Siliconflow,
7659            ..ConfigToml::default()
7660        };
7661        config.providers.siliconflow.base_url = Some("https://my-gateway.example/v1".to_string());
7662        config.providers.siliconflow.model = Some("DeepSeek-V4-Pro".to_string());
7663
7664        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7665
7666        assert_eq!(resolved.provider, ProviderKind::Siliconflow);
7667        assert_eq!(resolved.base_url, "https://my-gateway.example/v1");
7668        assert_eq!(resolved.model, "DeepSeek-V4-Pro");
7669    }
7670
7671    #[test]
7672    fn config_file_resolves_above_env_and_keyring() {
7673        use codewhale_secrets::KeyringStore;
7674        let _lock = env_lock();
7675        let _env = EnvGuard::without_deepseek_runtime_overrides();
7676        // Safety: env mutation guarded by env_lock().
7677        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
7678
7679        let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
7680        store.set("deepseek", "ring-key").unwrap();
7681        let secrets = Secrets::new(store);
7682
7683        let mut config = ConfigToml::default();
7684        config.providers.deepseek.api_key = Some("file-key".to_string());
7685
7686        let resolved =
7687            config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7688        assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
7689        assert_eq!(
7690            resolved.api_key_source,
7691            Some(RuntimeApiKeySource::ConfigFile)
7692        );
7693
7694        // Safety: env mutation guarded by env_lock().
7695        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
7696    }
7697
7698    #[test]
7699    fn env_resolves_when_config_file_and_keyring_empty() {
7700        let _lock = env_lock();
7701        let _env = EnvGuard::without_deepseek_runtime_overrides();
7702        // Safety: env mutation guarded by env_lock().
7703        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
7704
7705        let secrets = Secrets::new(std::sync::Arc::new(
7706            codewhale_secrets::InMemoryKeyringStore::new(),
7707        ));
7708        let config = ConfigToml::default();
7709
7710        let resolved =
7711            config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7712        assert_eq!(resolved.api_key.as_deref(), Some("env-key"));
7713        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
7714
7715        // Safety: env mutation guarded by env_lock().
7716        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
7717    }
7718
7719    #[test]
7720    fn config_file_resolves_when_keyring_and_env_empty() {
7721        let _lock = env_lock();
7722        let _env = EnvGuard::without_deepseek_runtime_overrides();
7723
7724        let secrets = Secrets::new(std::sync::Arc::new(
7725            codewhale_secrets::InMemoryKeyringStore::new(),
7726        ));
7727        let mut config = ConfigToml::default();
7728        config.providers.deepseek.api_key = Some("file-key".to_string());
7729
7730        let resolved =
7731            config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7732        assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
7733        assert_eq!(
7734            resolved.api_key_source,
7735            Some(RuntimeApiKeySource::ConfigFile)
7736        );
7737    }
7738
7739    #[test]
7740    fn keyring_resolves_when_config_file_empty_even_if_env_is_set() {
7741        use codewhale_secrets::KeyringStore;
7742        let _lock = env_lock();
7743        let _env = EnvGuard::without_deepseek_runtime_overrides();
7744        // Safety: env mutation guarded by env_lock().
7745        unsafe { std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key") };
7746
7747        let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
7748        store.set("deepseek", "ring-key").unwrap();
7749        let secrets = Secrets::new(store);
7750
7751        let resolved = ConfigToml::default()
7752            .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7753        assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
7754        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
7755
7756        // Safety: env mutation guarded by env_lock().
7757        unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
7758    }
7759
7760    #[test]
7761    fn cli_flag_still_overrides_keyring() {
7762        use codewhale_secrets::KeyringStore;
7763        let _lock = env_lock();
7764        let _env = EnvGuard::without_deepseek_runtime_overrides();
7765
7766        let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
7767        store.set("deepseek", "ring-key").unwrap();
7768        let secrets = Secrets::new(store);
7769
7770        let cli = CliRuntimeOverrides {
7771            api_key: Some("cli-key".to_string()),
7772            ..CliRuntimeOverrides::default()
7773        };
7774        let resolved = ConfigToml::default().resolve_runtime_options_with_secrets(&cli, &secrets);
7775        assert_eq!(resolved.api_key.as_deref(), Some("cli-key"));
7776        assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Cli));
7777    }
7778
7779    #[test]
7780    fn provider_chain_initial_current_is_active() {
7781        let chain = ProviderChain::new(
7782            ProviderKind::NvidiaNim,
7783            &[ProviderKind::Deepseek, ProviderKind::Openrouter],
7784        );
7785
7786        assert_eq!(chain.current(), ProviderKind::NvidiaNim);
7787        assert_eq!(chain.position(), 0);
7788        assert_eq!(
7789            chain.providers(),
7790            &[
7791                ProviderKind::NvidiaNim,
7792                ProviderKind::Deepseek,
7793                ProviderKind::Openrouter,
7794            ]
7795        );
7796        assert!(!chain.is_fallback_active());
7797    }
7798
7799    #[test]
7800    fn provider_chain_advance_switches_to_fallback() {
7801        let mut chain = ProviderChain::new(
7802            ProviderKind::NvidiaNim,
7803            &[ProviderKind::Deepseek, ProviderKind::Openrouter],
7804        );
7805
7806        assert!(chain.has_next());
7807        assert_eq!(chain.advance(), Some(ProviderKind::Deepseek));
7808        assert_eq!(chain.current(), ProviderKind::Deepseek);
7809        assert!(chain.is_fallback_active());
7810    }
7811
7812    #[test]
7813    fn provider_chain_exhausts_returns_none() {
7814        let mut chain = ProviderChain::new(ProviderKind::Deepseek, &[ProviderKind::Openrouter]);
7815
7816        assert_eq!(chain.advance(), Some(ProviderKind::Openrouter));
7817        assert!(!chain.has_next());
7818        assert_eq!(chain.advance(), None);
7819    }
7820
7821    #[test]
7822    fn provider_chain_skips_duplicates() {
7823        let chain = ProviderChain::new(
7824            ProviderKind::Deepseek,
7825            &[
7826                ProviderKind::Deepseek,
7827                ProviderKind::NvidiaNim,
7828                ProviderKind::Deepseek,
7829            ],
7830        );
7831
7832        assert_eq!(
7833            chain.providers(),
7834            &[ProviderKind::Deepseek, ProviderKind::NvidiaNim]
7835        );
7836    }
7837
7838    #[test]
7839    fn provider_chain_remaining_counts_current_and_untried_entries() {
7840        let mut chain = ProviderChain::new(
7841            ProviderKind::Deepseek,
7842            &[ProviderKind::NvidiaNim, ProviderKind::Openrouter],
7843        );
7844
7845        assert_eq!(chain.remaining(), 3);
7846        assert_eq!(chain.advance(), Some(ProviderKind::NvidiaNim));
7847        assert_eq!(chain.remaining(), 2);
7848    }
7849
7850    #[test]
7851    fn config_toml_parses_fallback_providers() {
7852        let config: ConfigToml = toml::from_str(
7853            r#"
7854provider = "nvidia-nim"
7855fallback_providers = ["deepseek", "openrouter"]
7856"#,
7857        )
7858        .expect("fallback providers config");
7859
7860        assert_eq!(config.provider, ProviderKind::NvidiaNim);
7861        assert_eq!(
7862            config.fallback_providers,
7863            [ProviderKind::Deepseek, ProviderKind::Openrouter]
7864        );
7865    }
7866
7867    #[test]
7868    fn empty_fallback_providers_do_not_serialize() {
7869        let serialized = toml::to_string_pretty(&ConfigToml::default()).expect("config serializes");
7870
7871        assert!(!serialized.contains("fallback_providers"));
7872    }
7873
7874    #[test]
7875    fn fleet_exec_config_default_matches_subagent_depth() {
7876        // Fleet workers and standalone sub-agents share one recursion axis:
7877        // the fleet default equals DEFAULT_SPAWN_DEPTH (3) and affords >=3
7878        // nested delegation levels out of the box.
7879        assert_eq!(
7880            FleetExecConfig::default().max_spawn_depth,
7881            DEFAULT_SPAWN_DEPTH
7882        );
7883        assert_eq!(FleetExecConfig::default().max_spawn_depth, 3);
7884        const { assert!(DEFAULT_SPAWN_DEPTH <= MAX_SPAWN_DEPTH_CEILING) };
7885    }
7886
7887    #[test]
7888    fn fleet_exec_config_parses_max_spawn_depth() {
7889        let config: ConfigToml = toml::from_str(
7890            r#"
7891[fleet.exec]
7892max_spawn_depth = 2
7893"#,
7894        )
7895        .expect("fleet exec config should parse");
7896
7897        assert_eq!(config.fleet.expect("fleet config").exec.max_spawn_depth, 2);
7898    }
7899
7900    #[test]
7901    fn fallback_providers_do_not_change_runtime_resolution() {
7902        let _lock = env_lock();
7903        let _env = EnvGuard::without_deepseek_runtime_overrides();
7904        let config = ConfigToml {
7905            provider: ProviderKind::NvidiaNim,
7906            fallback_providers: vec![ProviderKind::Deepseek],
7907            ..ConfigToml::default()
7908        };
7909
7910        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7911
7912        assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
7913    }
7914
7915    #[test]
7916    fn harness_posture_default_is_standard() {
7917        let posture = HarnessPosture::default();
7918
7919        assert_eq!(
7920            posture,
7921            HarnessPosture {
7922                kind: HarnessPostureKind::Standard,
7923                max_subagents: 0,
7924                prefer_codebase_search: false,
7925                compaction_strategy: HarnessCompactionStrategy::Default,
7926                tool_surface: HarnessToolSurface::Full,
7927                safety_posture: HarnessSafetyPosture::Standard,
7928            }
7929        );
7930    }
7931
7932    #[test]
7933    fn harness_posture_factories_are_typed() {
7934        assert_eq!(
7935            HarnessPosture::cache_heavy(),
7936            HarnessPosture {
7937                kind: HarnessPostureKind::CacheHeavy,
7938                max_subagents: 10,
7939                prefer_codebase_search: false,
7940                compaction_strategy: HarnessCompactionStrategy::PrefixCache,
7941                tool_surface: HarnessToolSurface::Full,
7942                safety_posture: HarnessSafetyPosture::Standard,
7943            }
7944        );
7945        assert_eq!(
7946            HarnessPosture::lean(),
7947            HarnessPosture {
7948                kind: HarnessPostureKind::Lean,
7949                max_subagents: 20,
7950                prefer_codebase_search: true,
7951                compaction_strategy: HarnessCompactionStrategy::Aggressive,
7952                tool_surface: HarnessToolSurface::Full,
7953                safety_posture: HarnessSafetyPosture::Standard,
7954            }
7955        );
7956    }
7957
7958    #[test]
7959    fn harness_profile_serde_round_trips_as_a_whole_struct() {
7960        let profile = HarnessProfile {
7961            provider_route: "deepseek".to_string(),
7962            model_pattern: "deepseek-v4.*".to_string(),
7963            posture: HarnessPosture::cache_heavy(),
7964        };
7965
7966        let json = serde_json::to_string(&profile).expect("serialize profile");
7967        let round_tripped: HarnessProfile =
7968            serde_json::from_str(&json).expect("deserialize profile");
7969
7970        assert_eq!(round_tripped, profile);
7971    }
7972
7973    #[test]
7974    fn config_toml_accepts_harness_profiles() {
7975        let config: ConfigToml = toml::from_str(
7976            r#"
7977provider = "deepseek"
7978model = "deepseek-v4-pro"
7979
7980[[harness_profiles]]
7981provider_route = "deepseek"
7982model_pattern = "deepseek-v4.*"
7983
7984[harness_profiles.posture]
7985kind = "cache-heavy"
7986max_subagents = 10
7987compaction_strategy = "prefix-cache"
7988tool_surface = "read-only"
7989safety_posture = "strict"
7990"#,
7991        )
7992        .expect("parse harness profiles");
7993
7994        assert_eq!(
7995            config.harness_profiles,
7996            vec![HarnessProfile {
7997                provider_route: "deepseek".to_string(),
7998                model_pattern: "deepseek-v4.*".to_string(),
7999                posture: HarnessPosture {
8000                    kind: HarnessPostureKind::CacheHeavy,
8001                    max_subagents: 10,
8002                    prefer_codebase_search: false,
8003                    compaction_strategy: HarnessCompactionStrategy::PrefixCache,
8004                    tool_surface: HarnessToolSurface::ReadOnly,
8005                    safety_posture: HarnessSafetyPosture::Strict,
8006                },
8007            }]
8008        );
8009    }
8010
8011    #[test]
8012    fn harness_profile_matches_provider_alias_and_model_wildcard() {
8013        let profile = HarnessProfile {
8014            provider_route: "xiaomi-mimo".to_string(),
8015            model_pattern: "mimo-v2.?-pro".to_string(),
8016            posture: HarnessPosture::cache_heavy(),
8017        };
8018
8019        assert!(profile.matches_route("mimo", "mimo-v2.5-pro"));
8020        assert!(!profile.matches_route("mimo", "mimo-v2.50-pro"));
8021        assert!(!profile.matches_route("deepseek", "mimo-v2.5-pro"));
8022    }
8023
8024    #[test]
8025    fn resolve_harness_profile_returns_first_matching_profile() {
8026        let config = ConfigToml {
8027            harness_profiles: vec![
8028                HarnessProfile {
8029                    provider_route: "deepseek".to_string(),
8030                    model_pattern: "deepseek-v4-flash".to_string(),
8031                    posture: HarnessPosture::lean(),
8032                },
8033                HarnessProfile {
8034                    provider_route: "deepseek".to_string(),
8035                    model_pattern: "deepseek-v4-*".to_string(),
8036                    posture: HarnessPosture::cache_heavy(),
8037                },
8038            ],
8039            ..ConfigToml::default()
8040        };
8041
8042        let flash = config
8043            .resolve_harness_profile("deepseek-cn", "deepseek-v4-flash")
8044            .expect("exact profile should match first");
8045        assert_eq!(flash.posture.kind, HarnessPostureKind::Lean);
8046
8047        let pro = config
8048            .resolve_harness_profile("deepseek", "deepseek-v4-pro")
8049            .expect("wildcard profile should match pro model");
8050        assert_eq!(pro.posture.kind, HarnessPostureKind::CacheHeavy);
8051    }
8052
8053    #[test]
8054    fn resolve_harness_profile_uses_built_in_seed_when_config_has_no_match() {
8055        let config = ConfigToml::default();
8056
8057        let xiaomi = config
8058            .resolve_harness_profile("xiaomi", "mimo-v2.5-pro")
8059            .expect("direct Xiaomi MiMo seed should resolve");
8060        assert_eq!(xiaomi.provider_route, "xiaomi-mimo");
8061        assert_eq!(xiaomi.posture.kind, HarnessPostureKind::CacheHeavy);
8062
8063        let arcee = config
8064            .resolve_harness_profile("arcee", "trinity-large-thinking")
8065            .expect("direct Arcee seed should resolve");
8066        assert_eq!(arcee.posture.kind, HarnessPostureKind::CacheHeavy);
8067
8068        let local = config
8069            .resolve_harness_profile("vllm", "Qwen/Qwen3.6-Coder")
8070            .expect("local seed should resolve");
8071        assert_eq!(local.posture.kind, HarnessPostureKind::Lean);
8072        assert!(local.posture.prefer_codebase_search);
8073    }
8074
8075    #[test]
8076    fn configured_harness_profile_overrides_built_in_seed() {
8077        let config = ConfigToml {
8078            harness_profiles: vec![HarnessProfile {
8079                provider_route: "xiaomi-mimo".to_string(),
8080                model_pattern: "mimo-v2.5-pro".to_string(),
8081                posture: HarnessPosture {
8082                    kind: HarnessPostureKind::Custom,
8083                    max_subagents: 3,
8084                    prefer_codebase_search: true,
8085                    compaction_strategy: HarnessCompactionStrategy::Default,
8086                    tool_surface: HarnessToolSurface::Auto,
8087                    safety_posture: HarnessSafetyPosture::Strict,
8088                },
8089            }],
8090            ..ConfigToml::default()
8091        };
8092
8093        let profile = config
8094            .resolve_harness_profile("xiaomi-mimo", "mimo-v2.5-pro")
8095            .expect("configured profile should match first");
8096
8097        assert_eq!(profile.posture.kind, HarnessPostureKind::Custom);
8098        assert_eq!(profile.posture.max_subagents, 3);
8099        assert_eq!(profile.posture.tool_surface, HarnessToolSurface::Auto);
8100        assert_eq!(profile.posture.safety_posture, HarnessSafetyPosture::Strict);
8101    }
8102
8103    #[test]
8104    fn resolve_harness_profile_returns_none_when_route_or_model_misses() {
8105        let config = ConfigToml {
8106            harness_profiles: vec![HarnessProfile {
8107                provider_route: "huggingface".to_string(),
8108                model_pattern: "deepseek-ai/*".to_string(),
8109                posture: HarnessPosture::lean(),
8110            }],
8111            ..ConfigToml::default()
8112        };
8113
8114        assert!(
8115            config
8116                .resolve_harness_profile("openrouter", "deepseek-ai/DeepSeek-V4-Pro")
8117                .is_none()
8118        );
8119        assert!(
8120            config
8121                .resolve_harness_profile("deepseek", "Qwen/Qwen3.6-Coder")
8122                .is_none()
8123        );
8124        assert!(
8125            config
8126                .resolve_harness_profile("openai", "mimo-v2.5-pro")
8127                .is_none()
8128        );
8129    }
8130
8131    #[test]
8132    fn resolving_harness_profile_does_not_change_runtime_options() {
8133        let _lock = env_lock();
8134        let _env = EnvGuard::without_deepseek_runtime_overrides();
8135        let config = ConfigToml {
8136            provider: ProviderKind::Deepseek,
8137            model: Some("deepseek-v4-pro".to_string()),
8138            harness_profiles: vec![HarnessProfile {
8139                provider_route: "deepseek".to_string(),
8140                model_pattern: "deepseek-v4-*".to_string(),
8141                posture: HarnessPosture::lean(),
8142            }],
8143            ..ConfigToml::default()
8144        };
8145
8146        let profile = config
8147            .resolve_harness_profile("deepseek", "deepseek-v4-pro")
8148            .expect("profile should resolve for display/future runtime");
8149        assert_eq!(profile.posture.kind, HarnessPostureKind::Lean);
8150
8151        let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8152        assert_eq!(resolved.provider, ProviderKind::Deepseek);
8153        assert_eq!(resolved.model, "deepseek-v4-pro");
8154    }
8155
8156    #[test]
8157    fn harness_posture_kind_rejects_unknown_values() {
8158        let err = toml::from_str::<ConfigToml>(
8159            r#"
8160[[harness_profiles]]
8161provider_route = "deepseek"
8162model_pattern = "deepseek-v4.*"
8163
8164[harness_profiles.posture]
8165kind = "cahce-heavy"
8166"#,
8167        )
8168        .expect_err("misspelled kind should not deserialize as custom");
8169
8170        assert!(err.to_string().contains("cahce-heavy"));
8171    }
8172
8173    #[test]
8174    fn harness_posture_rejects_unknown_policy_keys() {
8175        let err = toml::from_str::<ConfigToml>(
8176            r#"
8177[[harness_profiles]]
8178provider_route = "deepseek"
8179model_pattern = "deepseek-v4.*"
8180
8181[harness_profiles.posture]
8182kind = "custom"
8183unknown_policy = "surprise"
8184"#,
8185        )
8186        .expect_err("unknown posture keys should not be ignored");
8187
8188        assert!(err.to_string().contains("unknown_policy"));
8189    }
8190
8191    #[test]
8192    fn test_verbosity_resolution() {
8193        let _lock = env_lock();
8194        // Test TOML parsing
8195        let toml_str = r#"
8196            verbosity = "concise"
8197        "#;
8198        let config: ConfigToml = toml::from_str(toml_str).unwrap();
8199        assert_eq!(config.verbosity, Some("concise".to_string()));
8200
8201        // Test Env overrides
8202        let _env = EnvGuard::without_deepseek_runtime_overrides();
8203        unsafe {
8204            std::env::set_var("CODEWHALE_VERBOSITY", "normal");
8205        }
8206        let env_overrides = EnvRuntimeOverrides::load();
8207        assert_eq!(env_overrides.verbosity, Some("normal".to_string()));
8208        unsafe {
8209            std::env::remove_var("CODEWHALE_VERBOSITY");
8210        }
8211
8212        // Test fallback to DEEPSEEK_VERBOSITY
8213        unsafe {
8214            std::env::set_var("DEEPSEEK_VERBOSITY", "concise");
8215        }
8216        let env_overrides = EnvRuntimeOverrides::load();
8217        assert_eq!(env_overrides.verbosity, Some("concise".to_string()));
8218        unsafe {
8219            std::env::remove_var("DEEPSEEK_VERBOSITY");
8220        }
8221    }
8222}