Skip to main content

codewhale_config/
lib.rs

1pub mod provider;
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::ffi::{OsStr, OsString};
5use std::fmt;
6use std::fs;
7#[cfg(unix)]
8use std::io::Read;
9use std::io::Write;
10use std::path::{Component, Path, PathBuf};
11use std::sync::OnceLock;
12
13use anyhow::{Context, Result, bail};
14pub use codewhale_execpolicy::ToolAskRule;
15use codewhale_execpolicy::{ExecPolicyEngine, Ruleset};
16use codewhale_secrets::SecretSource;
17pub use codewhale_secrets::Secrets;
18use serde::{Deserialize, Serialize};
19
20#[cfg(unix)]
21use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
22
23pub const CONFIG_FILE_NAME: &str = "config.toml";
24pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml";
25const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro";
26const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
27const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
28const DEFAULT_OPENAI_MODEL: &str = "deepseek-v4-pro";
29const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta";
30const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
31const DEFAULT_OPENAI_CODEX_MODEL: &str = "gpt-5.5";
32const DEFAULT_ANTHROPIC_MODEL: &str = "claude-sonnet-4-6";
33const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com";
34const DEFAULT_OPENAI_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api";
35const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
36const DEFAULT_ATLASCLOUD_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
37const DEFAULT_ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1";
38const DEFAULT_WANJIE_ARK_MODEL: &str = "deepseek-reasoner";
39const DEFAULT_WANJIE_ARK_BASE_URL: &str = "https://maas-openapi.wanjiedata.com/api/v1";
40const DEFAULT_VOLCENGINE_MODEL: &str = "DeepSeek-V4-Pro";
41const DEFAULT_VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/coding/v3";
42const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro";
43const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
44const OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL: &str = "arcee-ai/trinity-large-thinking";
45const OPENROUTER_GEMMA_4_31B_MODEL: &str = "google/gemma-4-31b-it";
46const OPENROUTER_GEMMA_4_26B_A4B_MODEL: &str = "google/gemma-4-26b-a4b-it";
47const OPENROUTER_GLM_5_1_MODEL: &str = "z-ai/glm-5.1";
48const OPENROUTER_GLM_5_2_MODEL: &str = "z-ai/glm-5.2";
49const OPENROUTER_KIMI_K2_7_CODE_MODEL: &str = "moonshotai/kimi-k2.7-code";
50const OPENROUTER_KIMI_K2_6_MODEL: &str = "moonshotai/kimi-k2.6";
51const OPENROUTER_MINIMAX_M3_MODEL: &str = "minimax/minimax-m3";
52const OPENROUTER_MINIMAX_2_7_MODEL: &str = "minimax/minimax-2.7";
53const OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL: &str =
54    "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free";
55const OPENROUTER_QWEN_3_6_FLASH_MODEL: &str = "qwen/qwen3.6-flash";
56const OPENROUTER_QWEN_3_6_35B_A3B_MODEL: &str = "qwen/qwen3.6-35b-a3b";
57const OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL: &str = "qwen/qwen3.6-max-preview";
58const OPENROUTER_QWEN_3_6_27B_MODEL: &str = "qwen/qwen3.6-27b";
59const OPENROUTER_QWEN_3_6_PLUS_MODEL: &str = "qwen/qwen3.6-plus";
60const OPENROUTER_QWEN_3_7_MAX_MODEL: &str = "qwen/qwen3.7-max";
61const OPENROUTER_TENCENT_HY3_PREVIEW_MODEL: &str = "tencent/hy3-preview";
62const OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL: &str = "xiaomi/mimo-v2.5-pro";
63const OPENROUTER_XIAOMI_MIMO_V2_5_MODEL: &str = "xiaomi/mimo-v2.5";
64const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro";
65const XIAOMI_MIMO_V2_5_OMNI_MODEL: &str = "mimo-v2.5";
66const XIAOMI_MIMO_ASR_MODEL: &str = "mimo-v2.5-asr";
67const XIAOMI_MIMO_TTS_MODEL: &str = "mimo-v2.5-tts";
68const XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL: &str = "mimo-v2.5-tts-voicedesign";
69const XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL: &str = "mimo-v2.5-tts-voiceclone";
70const XIAOMI_MIMO_V2_TTS_MODEL: &str = "mimo-v2-tts";
71const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro";
72const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
73const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro";
74const DEFAULT_SILICONFLOW_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
75const DEFAULT_SILICONFLOW_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
76const DEFAULT_ARCEE_MODEL: &str = "trinity-large-thinking";
77const ARCEE_TRINITY_LARGE_PREVIEW_MODEL: &str = "trinity-large-preview";
78const ARCEE_TRINITY_MINI_MODEL: &str = "trinity-mini";
79const DEFAULT_MOONSHOT_MODEL: &str = "kimi-k2.7-code";
80const MOONSHOT_KIMI_K2_6_MODEL: &str = "kimi-k2.6";
81const DEFAULT_MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1";
82const DEFAULT_KIMI_CODE_MODEL: &str = "kimi-for-coding";
83const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding/v1";
84const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
85const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
86const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
87const XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL: &str = "https://api.xiaomimimo.com/v1";
88const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://token-plan-sgp.xiaomimimo.com/v1";
89const XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL: &str = "https://token-plan-cn.xiaomimimo.com/v1";
90const XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL: &str = DEFAULT_XIAOMI_MIMO_BASE_URL;
91const XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL: &str = "https://token-plan-ams.xiaomimimo.com/v1";
92const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/openai/v1";
93const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
94const DEFAULT_SILICONFLOW_BASE_URL: &str = "https://api.siliconflow.com/v1";
95const DEFAULT_SILICONFLOW_CN_BASE_URL: &str = "https://api.siliconflow.cn/v1";
96const DEFAULT_ARCEE_BASE_URL: &str = "https://api.arcee.ai/api/v1";
97const DEFAULT_HUGGINGFACE_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
98const DEFAULT_HUGGINGFACE_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
99const DEFAULT_HUGGINGFACE_BASE_URL: &str = "https://router.huggingface.co/v1";
100const DEFAULT_TOGETHER_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
101const DEFAULT_TOGETHER_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
102const DEFAULT_TOGETHER_BASE_URL: &str = "https://api.together.xyz/v1";
103const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1";
104const DEFAULT_VLLM_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
105const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
106const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1";
107const DEFAULT_OLLAMA_MODEL: &str = "deepseek-coder:1.3b";
108const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
109
110// Z.ai (GLM Coding Plan) defaults
111const DEFAULT_ZAI_MODEL: &str = "GLM-5.2";
112const ZAI_GLM_5_1_MODEL: &str = "GLM-5.1";
113// GLM-5.2 is both the default and a named tier; the alias arm resolves the
114// `glm-5.2` spelling to DEFAULT_ZAI_MODEL directly, so this constant is only
115// referenced by the invariant test below.
116#[allow(dead_code)]
117const ZAI_GLM_5_2_MODEL: &str = "GLM-5.2";
118const ZAI_GLM_5_TURBO_MODEL: &str = "GLM-5-Turbo";
119const DEFAULT_ZAI_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
120// StepFun / StepFlash defaults
121const DEFAULT_STEPFUN_MODEL: &str = "step-3.7-flash";
122const DEFAULT_STEPFUN_BASE_URL: &str = "https://api.stepfun.ai/v1";
123// MiniMax defaults
124const DEFAULT_MINIMAX_MODEL: &str = "MiniMax-M3";
125const MINIMAX_M2_7_MODEL: &str = "MiniMax-M2.7";
126const MINIMAX_M2_7_HIGHSPEED_MODEL: &str = "MiniMax-M2.7-highspeed";
127const MINIMAX_M2_5_MODEL: &str = "MiniMax-M2.5";
128const MINIMAX_M2_5_HIGHSPEED_MODEL: &str = "MiniMax-M2.5-highspeed";
129const MINIMAX_M2_1_MODEL: &str = "MiniMax-M2.1";
130const MINIMAX_M2_1_HIGHSPEED_MODEL: &str = "MiniMax-M2.1-highspeed";
131const MINIMAX_M2_MODEL: &str = "MiniMax-M2";
132const DEFAULT_MINIMAX_BASE_URL: &str = "https://api.minimax.io/v1";
133const DEFAULT_DEEPINFRA_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
134const DEFAULT_DEEPINFRA_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
135const DEFAULT_DEEPINFRA_BASE_URL: &str = "https://api.deepinfra.com/v1/openai";
136
137#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
138#[serde(rename_all = "kebab-case")]
139pub enum ProviderKind {
140    #[default]
141    #[serde(
142        alias = "deepseek-cn",
143        alias = "deepseek_china",
144        alias = "deepseekcn",
145        alias = "deepseek-china"
146    )]
147    Deepseek,
148    NvidiaNim,
149    #[serde(alias = "open-ai")]
150    Openai,
151    Atlascloud,
152    #[serde(
153        alias = "wanjie",
154        alias = "wanjie_ark",
155        alias = "ark-wanjie",
156        alias = "ark_wanjie",
157        alias = "wanjie-maas",
158        alias = "wanjie_maas"
159    )]
160    WanjieArk,
161    #[serde(alias = "volcengine-ark", alias = "volcengine_ark", alias = "ark")]
162    Volcengine,
163    Openrouter,
164    #[serde(alias = "mimo", alias = "xiaomi", alias = "xiaomi_mimo")]
165    XiaomiMimo,
166    Novita,
167    Fireworks,
168    #[serde(alias = "silicon-flow", alias = "silicon_flow")]
169    Siliconflow,
170    #[serde(alias = "arcee-ai", alias = "arcee_ai")]
171    Arcee,
172    #[serde(alias = "siliconflow-cn", alias = "siliconflow-CN")]
173    SiliconflowCN,
174    Moonshot,
175    Sglang,
176    Vllm,
177    Ollama,
178    #[serde(alias = "hugging-face", alias = "hugging_face", alias = "hf")]
179    Huggingface,
180    #[serde(alias = "together-ai", alias = "together_ai")]
181    Together,
182    #[serde(
183        alias = "openai-codex",
184        alias = "openai_codex",
185        alias = "codex",
186        alias = "chatgpt",
187        alias = "chatgpt-codex",
188        alias = "chatgpt_codex"
189    )]
190    OpenaiCodex,
191    #[serde(alias = "claude")]
192    Anthropic,
193    #[serde(alias = "z-ai", alias = "z_ai", alias = "z.ai")]
194    Zai,
195    #[serde(
196        alias = "step-fun",
197        alias = "step_fun",
198        alias = "stepfun",
199        alias = "stepflash",
200        alias = "step-flash",
201        alias = "step_flash"
202    )]
203    Stepfun,
204    #[serde(alias = "mini-max", alias = "mini_max", alias = "minimax")]
205    Minimax,
206    #[serde(alias = "deep-infra", alias = "deep_infra")]
207    Deepinfra,
208}
209
210impl ProviderKind {
211    pub const ALL: [Self; 25] = [
212        Self::Deepseek,
213        Self::NvidiaNim,
214        Self::Openai,
215        Self::Atlascloud,
216        Self::WanjieArk,
217        Self::Volcengine,
218        Self::Openrouter,
219        Self::XiaomiMimo,
220        Self::Novita,
221        Self::Fireworks,
222        Self::Siliconflow,
223        Self::Arcee,
224        Self::SiliconflowCN,
225        Self::Moonshot,
226        Self::Sglang,
227        Self::Vllm,
228        Self::Ollama,
229        Self::Huggingface,
230        Self::Together,
231        Self::OpenaiCodex,
232        Self::Anthropic,
233        Self::Zai,
234        Self::Stepfun,
235        Self::Minimax,
236        Self::Deepinfra,
237    ];
238
239    #[must_use]
240    pub fn all() -> &'static [Self] {
241        &Self::ALL
242    }
243
244    #[must_use]
245    pub fn names_hint() -> String {
246        Self::all()
247            .iter()
248            .map(|provider| provider.as_str())
249            .collect::<Vec<_>>()
250            .join(", ")
251    }
252
253    #[must_use]
254    pub fn as_str(self) -> &'static str {
255        self.provider().id()
256    }
257
258    #[must_use]
259    pub fn parse(value: &str) -> Option<Self> {
260        let trimmed = value.trim();
261        provider::all_providers()
262            .iter()
263            .find(|p| {
264                trimmed.eq_ignore_ascii_case(p.id())
265                    || p.aliases().iter().any(|a| trimmed.eq_ignore_ascii_case(a))
266            })
267            .map(|p| p.kind())
268    }
269
270    #[must_use]
271    pub fn is_siliconflow(self) -> bool {
272        matches!(self, Self::Siliconflow | Self::SiliconflowCN)
273    }
274
275    /// Return the built-in metadata entry for this provider.
276    ///
277    /// This is a metadata foundation only; runtime routing still resolves
278    /// through [`ConfigToml::resolve_runtime_options`].
279    #[must_use]
280    pub fn provider(self) -> &'static dyn provider::Provider {
281        provider::provider_for_kind(self)
282    }
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize, Default)]
286pub struct ProviderConfigToml {
287    pub api_key: Option<String>,
288    pub base_url: Option<String>,
289    pub model: Option<String>,
290    pub mode: Option<String>,
291    pub auth_mode: Option<String>,
292    pub insecure_skip_tls_verify: Option<bool>,
293    #[serde(default)]
294    pub http_headers: BTreeMap<String, String>,
295    pub path_suffix: Option<String>,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize, Default)]
299pub struct ProvidersToml {
300    #[serde(default)]
301    pub deepseek: ProviderConfigToml,
302    #[serde(default)]
303    pub nvidia_nim: ProviderConfigToml,
304    #[serde(default)]
305    pub openai: ProviderConfigToml,
306    #[serde(default)]
307    pub atlascloud: ProviderConfigToml,
308    #[serde(default)]
309    pub wanjie_ark: ProviderConfigToml,
310    #[serde(default)]
311    pub volcengine: ProviderConfigToml,
312    #[serde(default)]
313    pub openrouter: ProviderConfigToml,
314    #[serde(default, alias = "xiaomi", alias = "mimo", alias = "xiaomimimo")]
315    pub xiaomi_mimo: ProviderConfigToml,
316    #[serde(default)]
317    pub novita: ProviderConfigToml,
318    #[serde(default)]
319    pub fireworks: ProviderConfigToml,
320    #[serde(default)]
321    pub siliconflow: ProviderConfigToml,
322    #[serde(default, alias = "siliconflow-CN", alias = "siliconflow-cn")]
323    pub siliconflow_cn: ProviderConfigToml,
324    #[serde(default)]
325    pub arcee: ProviderConfigToml,
326    #[serde(default)]
327    pub moonshot: ProviderConfigToml,
328    #[serde(default)]
329    pub sglang: ProviderConfigToml,
330    #[serde(default)]
331    pub vllm: ProviderConfigToml,
332    #[serde(default)]
333    pub ollama: ProviderConfigToml,
334    #[serde(default)]
335    pub huggingface: ProviderConfigToml,
336    #[serde(default)]
337    pub together: ProviderConfigToml,
338    #[serde(
339        default,
340        alias = "openai-codex",
341        alias = "openai_codex",
342        alias = "codex",
343        alias = "chatgpt",
344        alias = "chatgpt-codex"
345    )]
346    pub openai_codex: ProviderConfigToml,
347    #[serde(default)]
348    pub anthropic: ProviderConfigToml,
349    #[serde(default, alias = "z-ai", alias = "z_ai", alias = "z.ai")]
350    pub zai: ProviderConfigToml,
351    #[serde(
352        default,
353        alias = "step-fun",
354        alias = "step_fun",
355        alias = "stepfun",
356        alias = "stepflash",
357        alias = "step-flash",
358        alias = "step_flash"
359    )]
360    pub stepfun: ProviderConfigToml,
361    #[serde(default, alias = "mini-max", alias = "mini_max", alias = "minimax")]
362    pub minimax: ProviderConfigToml,
363    #[serde(default, alias = "deep-infra", alias = "deep_infra")]
364    pub deepinfra: ProviderConfigToml,
365}
366
367/// Sibling `permissions.toml` schema.
368///
369/// This slice is intentionally ask-only: each rule is a typed condition that
370/// means "ask before this tool invocation." Typed allow/deny records and UI
371/// actions are expected to land in follow-up PRs.
372#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
373#[serde(deny_unknown_fields)]
374pub struct PermissionsToml {
375    #[serde(default, skip_serializing_if = "Vec::is_empty")]
376    pub rules: Vec<ToolAskRule>,
377}
378
379impl PermissionsToml {
380    #[must_use]
381    pub fn is_empty(&self) -> bool {
382        self.rules.is_empty()
383    }
384
385    #[must_use]
386    pub fn ruleset(&self) -> Ruleset {
387        Ruleset::user(Vec::new(), Vec::new()).with_ask_rules(self.rules.clone())
388    }
389}
390
391impl ProvidersToml {
392    #[must_use]
393    pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
394        match provider {
395            ProviderKind::Deepseek => &self.deepseek,
396            ProviderKind::NvidiaNim => &self.nvidia_nim,
397            ProviderKind::Openai => &self.openai,
398            ProviderKind::Atlascloud => &self.atlascloud,
399            ProviderKind::WanjieArk => &self.wanjie_ark,
400            ProviderKind::Volcengine => &self.volcengine,
401            ProviderKind::Openrouter => &self.openrouter,
402            ProviderKind::XiaomiMimo => &self.xiaomi_mimo,
403            ProviderKind::Novita => &self.novita,
404            ProviderKind::Fireworks => &self.fireworks,
405            ProviderKind::Siliconflow => &self.siliconflow,
406            ProviderKind::SiliconflowCN => &self.siliconflow_cn,
407            ProviderKind::Arcee => &self.arcee,
408            ProviderKind::Moonshot => &self.moonshot,
409            ProviderKind::Sglang => &self.sglang,
410            ProviderKind::Vllm => &self.vllm,
411            ProviderKind::Ollama => &self.ollama,
412            ProviderKind::Huggingface => &self.huggingface,
413            ProviderKind::Together => &self.together,
414            ProviderKind::OpenaiCodex => &self.openai_codex,
415            ProviderKind::Anthropic => &self.anthropic,
416            ProviderKind::Zai => &self.zai,
417            ProviderKind::Stepfun => &self.stepfun,
418            ProviderKind::Minimax => &self.minimax,
419            ProviderKind::Deepinfra => &self.deepinfra,
420        }
421    }
422
423    pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
424        match provider {
425            ProviderKind::Deepseek => &mut self.deepseek,
426            ProviderKind::NvidiaNim => &mut self.nvidia_nim,
427            ProviderKind::Openai => &mut self.openai,
428            ProviderKind::Atlascloud => &mut self.atlascloud,
429            ProviderKind::WanjieArk => &mut self.wanjie_ark,
430            ProviderKind::Volcengine => &mut self.volcengine,
431            ProviderKind::Openrouter => &mut self.openrouter,
432            ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo,
433            ProviderKind::Novita => &mut self.novita,
434            ProviderKind::Fireworks => &mut self.fireworks,
435            ProviderKind::Siliconflow => &mut self.siliconflow,
436            ProviderKind::SiliconflowCN => &mut self.siliconflow_cn,
437            ProviderKind::Arcee => &mut self.arcee,
438            ProviderKind::Moonshot => &mut self.moonshot,
439            ProviderKind::Sglang => &mut self.sglang,
440            ProviderKind::Vllm => &mut self.vllm,
441            ProviderKind::Ollama => &mut self.ollama,
442            ProviderKind::Huggingface => &mut self.huggingface,
443            ProviderKind::Together => &mut self.together,
444            ProviderKind::OpenaiCodex => &mut self.openai_codex,
445            ProviderKind::Anthropic => &mut self.anthropic,
446            ProviderKind::Zai => &mut self.zai,
447            ProviderKind::Stepfun => &mut self.stepfun,
448            ProviderKind::Minimax => &mut self.minimax,
449            ProviderKind::Deepinfra => &mut self.deepinfra,
450        }
451    }
452}
453
454/// Kinds of built-in harness postures.
455///
456/// A posture names the runtime strategy CodeWhale should use for a
457/// provider/model route: how much context to preload, how aggressively to lean
458/// on sub-agents, and how to balance prompt-cache stability against quick
459/// exploration. Runtime selection is wired in later v0.9 slices; this config
460/// model intentionally keeps the policy data explicit first.
461#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
462#[serde(rename_all = "kebab-case")]
463pub enum HarnessPostureKind {
464    /// Full-featured default: rich constitution, broad tool catalog, and normal
465    /// sub-agent posture.
466    #[default]
467    Standard,
468    /// Cache-heavy: deeper prompt layering and prefix-cache-oriented context.
469    CacheHeavy,
470    /// Lean: smaller starting context, faster compaction, and stronger
471    /// exploration/delegation bias.
472    Lean,
473    /// User-defined posture assembled from explicit knobs below.
474    Custom,
475}
476
477/// How this posture should approach compaction and prompt-cache stability.
478#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
479#[serde(rename_all = "kebab-case")]
480pub enum HarnessCompactionStrategy {
481    #[default]
482    Default,
483    PrefixCache,
484    Aggressive,
485}
486
487/// Which tool catalog shape this posture prefers.
488#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
489#[serde(rename_all = "kebab-case")]
490pub enum HarnessToolSurface {
491    #[default]
492    Full,
493    ReadOnly,
494    Auto,
495}
496
497/// Safety posture applied when the runtime consumes a harness profile.
498#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
499#[serde(rename_all = "kebab-case")]
500pub enum HarnessSafetyPosture {
501    #[default]
502    Standard,
503    Strict,
504    Permissive,
505}
506
507/// A concrete harness posture with policy knobs.
508#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
509#[serde(deny_unknown_fields)]
510pub struct HarnessPosture {
511    /// Named posture kind.
512    #[serde(default)]
513    pub kind: HarnessPostureKind,
514    /// Maximum number of concurrent sub-agents (0 = runtime default).
515    #[serde(default)]
516    pub max_subagents: usize,
517    /// Prefer search-based/on-demand context over always-on documentation.
518    #[serde(default)]
519    pub prefer_codebase_search: bool,
520    /// Compaction and prompt-cache strategy.
521    #[serde(default)]
522    pub compaction_strategy: HarnessCompactionStrategy,
523    /// Preferred tool catalog shape.
524    #[serde(default)]
525    pub tool_surface: HarnessToolSurface,
526    /// Safety posture for runtime consumers.
527    #[serde(default)]
528    pub safety_posture: HarnessSafetyPosture,
529}
530
531impl Default for HarnessPosture {
532    fn default() -> Self {
533        Self {
534            kind: HarnessPostureKind::Standard,
535            max_subagents: 0,
536            prefer_codebase_search: false,
537            compaction_strategy: HarnessCompactionStrategy::default(),
538            tool_surface: HarnessToolSurface::default(),
539            safety_posture: HarnessSafetyPosture::default(),
540        }
541    }
542}
543
544impl HarnessPosture {
545    /// A cache-heavy posture tuned for DeepSeek V4 / MiMo-style models.
546    #[must_use]
547    pub fn cache_heavy() -> Self {
548        Self {
549            kind: HarnessPostureKind::CacheHeavy,
550            max_subagents: 10,
551            prefer_codebase_search: false,
552            compaction_strategy: HarnessCompactionStrategy::PrefixCache,
553            tool_surface: HarnessToolSurface::Full,
554            safety_posture: HarnessSafetyPosture::Standard,
555        }
556    }
557
558    /// A lean posture for smaller-context or weaker tool-use models.
559    #[must_use]
560    pub fn lean() -> Self {
561        Self {
562            kind: HarnessPostureKind::Lean,
563            max_subagents: 20,
564            prefer_codebase_search: true,
565            compaction_strategy: HarnessCompactionStrategy::Aggressive,
566            tool_surface: HarnessToolSurface::Full,
567            safety_posture: HarnessSafetyPosture::Standard,
568        }
569    }
570}
571
572/// A harness profile binds a posture to a provider route and model pattern.
573#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
574#[serde(deny_unknown_fields)]
575pub struct HarnessProfile {
576    /// Provider route this profile applies to, e.g. "deepseek" or
577    /// "xiaomi-mimo".
578    pub provider_route: String,
579    /// Regex or glob pattern for model names, e.g. "deepseek-v4.*".
580    pub model_pattern: String,
581    /// The posture to apply.
582    #[serde(default)]
583    pub posture: HarnessPosture,
584}
585
586impl HarnessProfile {
587    /// Return true when this profile applies to the provider/model route.
588    ///
589    /// This is a pure config helper: matching a profile must not mutate runtime
590    /// provider selection, prompts, auth, tools, context, or persisted config.
591    #[must_use]
592    pub fn matches_route(&self, provider_route: &str, model: &str) -> bool {
593        provider_routes_equal(&self.provider_route, provider_route)
594            && wildcard_pattern_matches(&self.model_pattern, model)
595    }
596}
597
598#[derive(Debug, Clone, Serialize, Deserialize, Default)]
599pub struct ConfigToml {
600    /// TUI-compatible DeepSeek API key. Kept at the root so both `deepseek`
601    /// and `codewhale-tui` can share a single config file.
602    pub api_key: Option<String>,
603    /// TUI-compatible DeepSeek base URL.
604    pub base_url: Option<String>,
605    /// Optional extra HTTP headers forwarded to model API requests.
606    #[serde(default)]
607    pub http_headers: BTreeMap<String, String>,
608    /// TUI-compatible default DeepSeek model.
609    pub default_text_model: Option<String>,
610    #[serde(default)]
611    pub provider: ProviderKind,
612    pub model: Option<String>,
613    pub auth_mode: Option<String>,
614    pub output_mode: Option<String>,
615    pub verbosity: Option<String>,
616    pub log_level: Option<String>,
617    pub telemetry: Option<bool>,
618    pub approval_policy: Option<String>,
619    pub sandbox_mode: Option<String>,
620    /// Native tool catalog controls shared with `codewhale-tui`.
621    #[serde(default)]
622    pub tools: Option<ToolsToml>,
623    #[serde(default)]
624    pub providers: ProvidersToml,
625    /// Provider fallback chain (#2574). TUI runtime code may advance through
626    /// these providers after recoverable provider errors; config resolution
627    /// itself still reports the selected primary provider.
628    #[serde(default, skip_serializing_if = "Vec::is_empty")]
629    pub fallback_providers: Vec<ProviderKind>,
630    /// Per-domain network policy (#135). When absent, network tools fall back
631    /// to a permissive default that mirrors pre-v0.7.0 behavior.
632    #[serde(default)]
633    pub network: Option<NetworkPolicyToml>,
634    /// Community skill installer settings (#140). Mirrors
635    /// [`SkillsToml`] from the TUI side; the dispatcher consults
636    /// `registry_url` when running `deepseek skill install`.
637    #[serde(default)]
638    pub skills: Option<SkillsToml>,
639    /// Workspace side-git snapshots (#137). The live TUI defaults this to
640    /// enabled with 7-day retention when absent.
641    #[serde(default)]
642    pub snapshots: Option<SnapshotsToml>,
643    /// Post-edit LSP diagnostics injection (#136). When absent, the engine
644    /// applies the defaults documented in [`LspConfigToml`].
645    #[serde(default)]
646    pub lsp: Option<LspConfigToml>,
647    /// Per-model harness profiles (#2693). Runtime wiring lands in follow-up
648    /// v0.9 slices; this is the durable config data model.
649    #[serde(default)]
650    pub harness_profiles: Vec<HarnessProfile>,
651    /// Optional 1-8 hotbar slot bindings (#2064). When absent, the TUI falls
652    /// back to the built-in default slots.
653    #[serde(default, skip_serializing_if = "Option::is_none")]
654    pub hotbar: Option<Vec<HotbarBindingToml>>,
655    /// App-server hook sink configuration. Kept separate from the TUI
656    /// lifecycle `[hooks]` table so config rewrites preserve existing hooks.
657    #[serde(default)]
658    pub hook_sinks: Option<HookSinksToml>,
659    /// Agent Fleet trust and security policy (#3165). When absent, fleet
660    /// workers inherit conservative Sandbox defaults.
661    #[serde(default)]
662    pub fleet: Option<FleetConfigToml>,
663    #[serde(flatten)]
664    pub extras: BTreeMap<String, toml::Value>,
665}
666
667#[derive(Debug, Clone, Copy, PartialEq, Eq)]
668enum ProviderConfigField {
669    ApiKey,
670    BaseUrl,
671    Model,
672    Mode,
673    AuthMode,
674    InsecureSkipTlsVerify,
675    HttpHeaders,
676    PathSuffix,
677}
678
679impl ProviderConfigField {
680    fn parse(key: &str) -> Option<Self> {
681        Some(match key {
682            "api_key" => Self::ApiKey,
683            "base_url" => Self::BaseUrl,
684            "model" => Self::Model,
685            "mode" => Self::Mode,
686            "auth_mode" => Self::AuthMode,
687            "insecure_skip_tls_verify" => Self::InsecureSkipTlsVerify,
688            "http_headers" => Self::HttpHeaders,
689            "path_suffix" => Self::PathSuffix,
690            _ => return None,
691        })
692    }
693
694    fn key(self) -> &'static str {
695        match self {
696            Self::ApiKey => "api_key",
697            Self::BaseUrl => "base_url",
698            Self::Model => "model",
699            Self::Mode => "mode",
700            Self::AuthMode => "auth_mode",
701            Self::InsecureSkipTlsVerify => "insecure_skip_tls_verify",
702            Self::HttpHeaders => "http_headers",
703            Self::PathSuffix => "path_suffix",
704        }
705    }
706}
707
708fn parse_provider_config_key(key: &str) -> Option<(ProviderKind, ProviderConfigField)> {
709    let suffix = key.strip_prefix("providers.")?;
710    let (provider_key, field_key) = suffix.split_once('.')?;
711    let field = ProviderConfigField::parse(field_key)?;
712    let provider = ProviderKind::ALL
713        .iter()
714        .copied()
715        .find(|kind| kind.provider().provider_config_key() == provider_key)?;
716    Some((provider, field))
717}
718
719fn provider_config_key(provider: ProviderKind, field: ProviderConfigField) -> String {
720    format!(
721        "providers.{}.{}",
722        provider.provider().provider_config_key(),
723        field.key()
724    )
725}
726
727fn get_provider_config_value(
728    config: &ProviderConfigToml,
729    field: ProviderConfigField,
730) -> Option<String> {
731    match field {
732        ProviderConfigField::ApiKey => config.api_key.clone(),
733        ProviderConfigField::BaseUrl => config.base_url.clone(),
734        ProviderConfigField::Model => config.model.clone(),
735        ProviderConfigField::Mode => config.mode.clone(),
736        ProviderConfigField::AuthMode => config.auth_mode.clone(),
737        ProviderConfigField::InsecureSkipTlsVerify => config
738            .insecure_skip_tls_verify
739            .map(|value| value.to_string()),
740        ProviderConfigField::HttpHeaders => serialize_http_headers(&config.http_headers),
741        ProviderConfigField::PathSuffix => config.path_suffix.clone(),
742    }
743}
744
745fn get_provider_config_display_value(
746    config: &ProviderConfigToml,
747    field: ProviderConfigField,
748) -> Option<String> {
749    match field {
750        ProviderConfigField::ApiKey => config.api_key.as_deref().map(redact_secret),
751        ProviderConfigField::HttpHeaders => {
752            serialize_http_headers_for_display(&config.http_headers)
753        }
754        _ => get_provider_config_value(config, field),
755    }
756}
757
758fn set_provider_config_value(
759    config: &mut ConfigToml,
760    provider: ProviderKind,
761    field: ProviderConfigField,
762    value: &str,
763) -> Result<()> {
764    match field {
765        ProviderConfigField::ApiKey => {
766            let value = value.to_string();
767            config.providers.for_provider_mut(provider).api_key = Some(value.clone());
768            if provider == ProviderKind::Deepseek {
769                config.api_key = Some(value);
770            }
771        }
772        ProviderConfigField::BaseUrl => {
773            let value = value.to_string();
774            config.providers.for_provider_mut(provider).base_url = Some(value.clone());
775            if provider == ProviderKind::Deepseek {
776                config.base_url = Some(value);
777            }
778        }
779        ProviderConfigField::Model => {
780            let value = value.to_string();
781            config.providers.for_provider_mut(provider).model = Some(value.clone());
782            if provider == ProviderKind::Deepseek {
783                config.default_text_model = Some(value);
784            }
785        }
786        ProviderConfigField::Mode => {
787            config.providers.for_provider_mut(provider).mode = Some(value.to_string());
788        }
789        ProviderConfigField::AuthMode => {
790            config.providers.for_provider_mut(provider).auth_mode = Some(value.to_string());
791        }
792        ProviderConfigField::InsecureSkipTlsVerify => {
793            config
794                .providers
795                .for_provider_mut(provider)
796                .insecure_skip_tls_verify = Some(parse_bool(value)?);
797        }
798        ProviderConfigField::HttpHeaders => {
799            let headers = parse_http_headers(value)?;
800            config.providers.for_provider_mut(provider).http_headers = headers.clone();
801            if provider == ProviderKind::Deepseek {
802                config.http_headers = headers;
803            }
804        }
805        ProviderConfigField::PathSuffix => {
806            config.providers.for_provider_mut(provider).path_suffix = Some(value.to_string());
807        }
808    }
809    Ok(())
810}
811
812fn unset_provider_config_value(
813    config: &mut ConfigToml,
814    provider: ProviderKind,
815    field: ProviderConfigField,
816) {
817    match field {
818        ProviderConfigField::ApiKey => {
819            config.providers.for_provider_mut(provider).api_key = None;
820            if provider == ProviderKind::Deepseek {
821                config.api_key = None;
822            }
823        }
824        ProviderConfigField::BaseUrl => {
825            config.providers.for_provider_mut(provider).base_url = None;
826            if provider == ProviderKind::Deepseek {
827                config.base_url = None;
828            }
829        }
830        ProviderConfigField::Model => {
831            config.providers.for_provider_mut(provider).model = None;
832            if provider == ProviderKind::Deepseek {
833                config.default_text_model = None;
834            }
835        }
836        ProviderConfigField::Mode => {
837            config.providers.for_provider_mut(provider).mode = None;
838        }
839        ProviderConfigField::AuthMode => {
840            config.providers.for_provider_mut(provider).auth_mode = None;
841        }
842        ProviderConfigField::InsecureSkipTlsVerify => {
843            config
844                .providers
845                .for_provider_mut(provider)
846                .insecure_skip_tls_verify = None;
847        }
848        ProviderConfigField::HttpHeaders => {
849            config
850                .providers
851                .for_provider_mut(provider)
852                .http_headers
853                .clear();
854            if provider == ProviderKind::Deepseek {
855                config.http_headers.clear();
856            }
857        }
858        ProviderConfigField::PathSuffix => {
859            config.providers.for_provider_mut(provider).path_suffix = None;
860        }
861    }
862}
863
864fn insert_provider_config_values(
865    out: &mut BTreeMap<String, String>,
866    provider: ProviderKind,
867    config: &ProviderConfigToml,
868) {
869    if let Some(v) = config.api_key.as_ref() {
870        out.insert(
871            provider_config_key(provider, ProviderConfigField::ApiKey),
872            redact_secret(v),
873        );
874    }
875    if let Some(v) = config.base_url.as_ref() {
876        out.insert(
877            provider_config_key(provider, ProviderConfigField::BaseUrl),
878            v.clone(),
879        );
880    }
881    if let Some(v) = config.model.as_ref() {
882        out.insert(
883            provider_config_key(provider, ProviderConfigField::Model),
884            v.clone(),
885        );
886    }
887    if let Some(v) = config.mode.as_ref() {
888        out.insert(
889            provider_config_key(provider, ProviderConfigField::Mode),
890            v.clone(),
891        );
892    }
893    if let Some(v) = config.auth_mode.as_ref() {
894        out.insert(
895            provider_config_key(provider, ProviderConfigField::AuthMode),
896            v.clone(),
897        );
898    }
899    if let Some(v) = config.insecure_skip_tls_verify {
900        out.insert(
901            provider_config_key(provider, ProviderConfigField::InsecureSkipTlsVerify),
902            v.to_string(),
903        );
904    }
905    if let Some(v) = serialize_http_headers_for_display(&config.http_headers) {
906        out.insert(
907            provider_config_key(provider, ProviderConfigField::HttpHeaders),
908            v,
909        );
910    }
911    if let Some(v) = config.path_suffix.as_ref() {
912        out.insert(
913            provider_config_key(provider, ProviderConfigField::PathSuffix),
914            v.clone(),
915        );
916    }
917}
918
919impl ConfigToml {
920    /// Resolve the first configured harness profile for a provider/model route.
921    ///
922    /// This helper is deliberately dormant for v0.9: callers may display or
923    /// test the resolved profile, but runtime provider/model routing and prompt
924    /// shaping remain unchanged until a later, explicit integration slice.
925    #[must_use]
926    pub fn resolve_harness_profile(
927        &self,
928        provider_route: &str,
929        model: &str,
930    ) -> Option<&HarnessProfile> {
931        self.harness_profiles
932            .iter()
933            .chain(built_in_harness_profiles().iter())
934            .find(|profile| profile.matches_route(provider_route, model))
935    }
936
937    /// Resolve durable hotbar config into normalized 1-8 slot bindings.
938    ///
939    /// `known_action_ids` is supplied by the TUI action registry in later
940    /// slices. Unknown actions are preserved so the UI can render a disabled
941    /// `?` cell instead of silently deleting user config.
942    #[must_use]
943    pub fn resolve_hotbar_bindings(&self, known_action_ids: &[&str]) -> HotbarConfigResolution {
944        resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids)
945    }
946}
947
948/// Built-in profile seeds for common provider/model families.
949///
950/// User-configured profiles are always checked first; these seeds only provide
951/// a stable resolver result when config has no narrower match.
952#[must_use]
953pub fn built_in_harness_profiles() -> &'static [HarnessProfile] {
954    static PROFILES: OnceLock<Vec<HarnessProfile>> = OnceLock::new();
955    PROFILES.get_or_init(|| {
956        vec![
957            HarnessProfile {
958                provider_route: "deepseek".to_string(),
959                model_pattern: "deepseek-v4*".to_string(),
960                posture: HarnessPosture::cache_heavy(),
961            },
962            HarnessProfile {
963                provider_route: "xiaomi-mimo".to_string(),
964                model_pattern: "mimo-v2.5*".to_string(),
965                posture: HarnessPosture::cache_heavy(),
966            },
967            HarnessProfile {
968                provider_route: "arcee".to_string(),
969                model_pattern: "trinity-large-thinking".to_string(),
970                posture: HarnessPosture::cache_heavy(),
971            },
972            HarnessProfile {
973                provider_route: "huggingface".to_string(),
974                model_pattern: "*".to_string(),
975                posture: HarnessPosture::lean(),
976            },
977            HarnessProfile {
978                provider_route: "sglang".to_string(),
979                model_pattern: "*".to_string(),
980                posture: HarnessPosture::lean(),
981            },
982            HarnessProfile {
983                provider_route: "vllm".to_string(),
984                model_pattern: "*".to_string(),
985                posture: HarnessPosture::lean(),
986            },
987            HarnessProfile {
988                provider_route: "ollama".to_string(),
989                model_pattern: "*".to_string(),
990                posture: HarnessPosture::lean(),
991            },
992        ]
993    })
994}
995
996fn provider_routes_equal(expected: &str, actual: &str) -> bool {
997    match (ProviderKind::parse(expected), ProviderKind::parse(actual)) {
998        (Some(expected), Some(actual)) => expected == actual,
999        _ => expected.trim().eq_ignore_ascii_case(actual.trim()),
1000    }
1001}
1002
1003fn wildcard_pattern_matches(pattern: &str, value: &str) -> bool {
1004    wildcard_chars_match(
1005        &pattern.chars().collect::<Vec<_>>(),
1006        &value.chars().collect::<Vec<_>>(),
1007    )
1008}
1009
1010fn wildcard_chars_match(pattern: &[char], value: &[char]) -> bool {
1011    let (mut pattern_idx, mut value_idx) = (0, 0);
1012    let mut star_idx: Option<usize> = None;
1013    let mut star_value_idx = 0;
1014
1015    while value_idx < value.len() {
1016        if pattern_idx < pattern.len()
1017            && (pattern[pattern_idx] == '?' || pattern[pattern_idx] == value[value_idx])
1018        {
1019            pattern_idx += 1;
1020            value_idx += 1;
1021        } else if pattern_idx < pattern.len() && pattern[pattern_idx] == '*' {
1022            star_idx = Some(pattern_idx);
1023            pattern_idx += 1;
1024            star_value_idx = value_idx;
1025        } else if let Some(star) = star_idx {
1026            pattern_idx = star + 1;
1027            star_value_idx += 1;
1028            value_idx = star_value_idx;
1029        } else {
1030            return false;
1031        }
1032    }
1033
1034    pattern[pattern_idx..].iter().all(|ch| *ch == '*')
1035}
1036
1037/// Ordered primary-plus-fallback provider list for future provider routing.
1038///
1039/// The helper is intentionally dormant: constructing or parsing a chain does
1040/// not change [`ConfigToml::resolve_runtime_options`].
1041#[derive(Debug, Clone, PartialEq, Eq)]
1042pub struct ProviderChain {
1043    providers: Vec<ProviderKind>,
1044    position: usize,
1045}
1046
1047pub const HOTBAR_SLOT_COUNT: u8 = 8;
1048
1049pub const DEFAULT_HOTBAR_ACTIONS: [&str; HOTBAR_SLOT_COUNT as usize] = [
1050    "voice.toggle",
1051    "session.compact",
1052    "mode.plan",
1053    "mode.agent",
1054    "mode.yolo",
1055    "palette.open",
1056    "sidebar.toggle",
1057    "trust.toggle",
1058];
1059
1060/// On-disk schema for one `[[hotbar]]` table.
1061#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1062#[serde(deny_unknown_fields)]
1063pub struct HotbarBindingToml {
1064    pub slot: u8,
1065    pub action: String,
1066    #[serde(default)]
1067    pub label: Option<String>,
1068}
1069
1070/// Validated hotbar binding used by future render/dispatch layers.
1071#[derive(Debug, Clone, PartialEq, Eq)]
1072pub struct HotbarBinding {
1073    pub slot: u8,
1074    pub action: String,
1075    pub label: Option<String>,
1076}
1077
1078/// Non-fatal hotbar config issue. Invalid slots are skipped; duplicate slots
1079/// use the last binding; unknown actions are kept for UI feedback.
1080#[derive(Debug, Clone, PartialEq, Eq)]
1081pub enum HotbarConfigWarning {
1082    SlotOutOfRange {
1083        slot: u8,
1084        action: String,
1085    },
1086    DuplicateSlot {
1087        slot: u8,
1088        previous_action: String,
1089        replacement_action: String,
1090    },
1091    UnknownAction {
1092        slot: u8,
1093        action: String,
1094    },
1095}
1096
1097impl fmt::Display for HotbarConfigWarning {
1098    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1099        match self {
1100            Self::SlotOutOfRange { slot, action } => write!(
1101                f,
1102                "hotbar slot {slot} for action '{action}' is outside 1-{HOTBAR_SLOT_COUNT}; skipped"
1103            ),
1104            Self::DuplicateSlot {
1105                slot,
1106                previous_action,
1107                replacement_action,
1108            } => write!(
1109                f,
1110                "hotbar slot {slot} was bound to '{previous_action}' more than once; using '{replacement_action}'"
1111            ),
1112            Self::UnknownAction { slot, action } => write!(
1113                f,
1114                "hotbar slot {slot} references unknown action '{action}'; keeping binding"
1115            ),
1116        }
1117    }
1118}
1119
1120#[derive(Debug, Clone, PartialEq, Eq)]
1121pub struct HotbarConfigResolution {
1122    pub bindings: Vec<HotbarBinding>,
1123    pub warnings: Vec<HotbarConfigWarning>,
1124}
1125
1126#[must_use]
1127pub fn default_hotbar_bindings() -> Vec<HotbarBinding> {
1128    DEFAULT_HOTBAR_ACTIONS
1129        .iter()
1130        .enumerate()
1131        .map(|(idx, action)| HotbarBinding {
1132            slot: u8::try_from(idx + 1).expect("default hotbar slot fits in u8"),
1133            action: (*action).to_string(),
1134            label: None,
1135        })
1136        .collect()
1137}
1138
1139#[must_use]
1140pub fn resolve_hotbar_bindings(
1141    configured: Option<&[HotbarBindingToml]>,
1142    known_action_ids: &[&str],
1143) -> HotbarConfigResolution {
1144    let known = known_action_ids.iter().copied().collect::<BTreeSet<&str>>();
1145    let mut warnings = Vec::new();
1146
1147    let source = match configured {
1148        Some(bindings) => bindings
1149            .iter()
1150            .map(|binding| HotbarBinding {
1151                slot: binding.slot,
1152                action: binding.action.clone(),
1153                label: binding.label.clone(),
1154            })
1155            .collect::<Vec<_>>(),
1156        None => default_hotbar_bindings(),
1157    };
1158
1159    let mut by_slot: BTreeMap<u8, HotbarBinding> = BTreeMap::new();
1160    for binding in source {
1161        if !(1..=HOTBAR_SLOT_COUNT).contains(&binding.slot) {
1162            warnings.push(HotbarConfigWarning::SlotOutOfRange {
1163                slot: binding.slot,
1164                action: binding.action,
1165            });
1166            continue;
1167        }
1168        if !known.is_empty() && !known.contains(binding.action.as_str()) {
1169            warnings.push(HotbarConfigWarning::UnknownAction {
1170                slot: binding.slot,
1171                action: binding.action.clone(),
1172            });
1173        }
1174        if let Some(previous) = by_slot.insert(binding.slot, binding.clone()) {
1175            warnings.push(HotbarConfigWarning::DuplicateSlot {
1176                slot: binding.slot,
1177                previous_action: previous.action,
1178                replacement_action: binding.action,
1179            });
1180        }
1181    }
1182
1183    HotbarConfigResolution {
1184        bindings: by_slot.into_values().collect(),
1185        warnings,
1186    }
1187}
1188
1189impl ProviderChain {
1190    #[must_use]
1191    pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self {
1192        let mut providers = vec![active];
1193        for fallback in fallbacks {
1194            if *fallback != active && !providers.contains(fallback) {
1195                providers.push(*fallback);
1196            }
1197        }
1198        Self {
1199            providers,
1200            position: 0,
1201        }
1202    }
1203
1204    #[must_use]
1205    pub fn providers(&self) -> &[ProviderKind] {
1206        &self.providers
1207    }
1208
1209    #[must_use]
1210    pub fn position(&self) -> usize {
1211        self.position
1212    }
1213
1214    #[must_use]
1215    pub fn current(&self) -> ProviderKind {
1216        self.providers
1217            .get(self.position)
1218            .copied()
1219            .unwrap_or(self.providers[0])
1220    }
1221
1222    #[must_use]
1223    pub fn has_next(&self) -> bool {
1224        self.position + 1 < self.providers.len()
1225    }
1226
1227    pub fn advance(&mut self) -> Option<ProviderKind> {
1228        if !self.has_next() {
1229            return None;
1230        }
1231        self.position += 1;
1232        Some(self.current())
1233    }
1234
1235    pub fn reset(&mut self) {
1236        self.position = 0;
1237    }
1238
1239    #[must_use]
1240    pub fn is_fallback_active(&self) -> bool {
1241        self.position > 0
1242    }
1243
1244    /// Count the current provider plus untried chain entries.
1245    #[must_use]
1246    pub fn remaining(&self) -> usize {
1247        self.providers.len() - self.position
1248    }
1249}
1250
1251/// On-disk schema for the `[hook_sinks]` table.
1252#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1253pub struct HookSinksToml {
1254    /// Unix domain socket path used by the app-server event sink.
1255    ///
1256    /// When unset, no Unix socket sink is registered. There is deliberately no
1257    /// shared `/tmp` default because socket ownership should be explicit.
1258    #[serde(default)]
1259    pub unix_socket_path: Option<PathBuf>,
1260}
1261
1262/// On-disk schema for the `[skills]` table (#140). See `config.example.toml`
1263/// for documentation.
1264#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1265pub struct SkillsToml {
1266    /// Curated registry index URL. When unset, the TUI falls back to the
1267    /// bundled default (community-curated GitHub raw).
1268    #[serde(default)]
1269    pub registry_url: Option<String>,
1270    /// Per-skill maximum *uncompressed* size in bytes. When unset, the TUI
1271    /// uses 5 MiB.
1272    #[serde(default)]
1273    pub max_install_size_bytes: Option<u64>,
1274}
1275
1276/// On-disk schema for the `[tools]` table (#2076).
1277#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1278pub struct ToolsToml {
1279    /// Native tool names to keep loaded outside the default core catalog.
1280    #[serde(default)]
1281    pub always_load: Vec<String>,
1282}
1283
1284/// On-disk schema for the `[snapshots]` table (#137). See
1285/// `config.example.toml` for documentation.
1286#[derive(Debug, Clone, Serialize, Deserialize)]
1287pub struct SnapshotsToml {
1288    #[serde(default = "default_snapshots_enabled")]
1289    pub enabled: bool,
1290    #[serde(default = "default_snapshot_max_age_days")]
1291    pub max_age_days: u64,
1292}
1293
1294fn default_snapshots_enabled() -> bool {
1295    true
1296}
1297
1298fn default_snapshot_max_age_days() -> u64 {
1299    7
1300}
1301
1302impl Default for SnapshotsToml {
1303    fn default() -> Self {
1304        Self {
1305            enabled: default_snapshots_enabled(),
1306            max_age_days: default_snapshot_max_age_days(),
1307        }
1308    }
1309}
1310
1311/// On-disk schema for the `[fleet]` table (#3165). See `config.example.toml`
1312/// and `docs/FLEET.md` for documentation.
1313#[derive(Debug, Clone, Serialize, Deserialize)]
1314pub struct FleetConfigToml {
1315    /// Default trust level for fleet workers. One of `"sandbox"`, `"local"`,
1316    /// `"remote-verified"`, or `"operator"`. Defaults to `"sandbox"`.
1317    #[serde(default = "default_fleet_trust_level_str")]
1318    pub default_trust_level: String,
1319    /// Require identity verification for remote (SSH) workers before
1320    /// granting them `remote-verified` trust. Defaults to true.
1321    #[serde(default = "default_fleet_require_identity")]
1322    pub require_identity_verification: bool,
1323    /// Maximum trust level any worker may have (`"sandbox"`, `"local"`,
1324    /// `"remote-verified"`, or `"operator"`). Defaults to `"operator"`.
1325    #[serde(default = "default_fleet_max_trust_level_str")]
1326    pub max_trust_level: String,
1327    /// User-defined and built-in role presets.
1328    ///
1329    /// Each role defines default tool profiles, capabilities, budgets, and
1330    /// trust settings that task specs can reference by name. Built-in roles
1331    /// (`smoke-runner`, `reviewer`, `builder`, `read-only`) are always
1332    /// available; user-defined roles in config override or extend them.
1333    #[serde(default)]
1334    pub roles: BTreeMap<String, FleetRolePreset>,
1335    /// Headless worker execution hardening (#3027).
1336    #[serde(default)]
1337    pub exec: FleetExecConfig,
1338}
1339
1340/// Canonical recursion-depth policy for the headless worker runtime.
1341///
1342/// Single source of truth shared by BOTH standalone sub-agents and fleet
1343/// workers so the two cannot drift into "two moving targets":
1344/// - [`DEFAULT_SPAWN_DEPTH`] is the default recursion budget (the sub-agent
1345///   runtime's `DEFAULT_MAX_SPAWN_DEPTH` is defined as this value).
1346/// - [`MAX_SPAWN_DEPTH_CEILING`] is the opt-in safety cap; every configured
1347///   value (fleet `max_spawn_depth`, the `agent` tool's `max_depth`) clamps to it.
1348///
1349/// A worker runs at `spawn_depth = 0` and may spawn while
1350/// `spawn_depth + 1 <= max_spawn_depth`, so a depth of N affords N nested
1351/// delegation levels below the root worker. The default of 3 affords at least
1352/// three recursion levels out of the box; the root worker still runs at
1353/// depth 0 even when the budget is 0.
1354pub const DEFAULT_SPAWN_DEPTH: u32 = 3;
1355
1356/// Hard ceiling on recursion depth for any worker/sub-agent. The default stays
1357/// conservative at [`DEFAULT_SPAWN_DEPTH`], while explicit config can opt into
1358/// deeper trees for direct-API providers that can tolerate the fanout.
1359/// Raising this single constant lifts the limit everywhere (the fleet clamp
1360/// and `agent` validation both read it).
1361pub const MAX_SPAWN_DEPTH_CEILING: u32 = 8;
1362
1363/// Headless worker execution constraints (#3027).
1364///
1365/// These limits apply to all fleet workers and sub-agents spawned through
1366/// the headless worker runtime. Task specs can tighten but not loosen them.
1367#[derive(Debug, Clone, Serialize, Deserialize)]
1368pub struct FleetExecConfig {
1369    /// Tools that are always allowed regardless of role or task spec.
1370    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1371    pub allowed_tools: Vec<String>,
1372    /// Tools that are always disallowed, overriding role and task spec.
1373    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1374    pub disallowed_tools: Vec<String>,
1375    /// Hard ceiling on sub-agent steps (tool calls + model turns).
1376    /// Workers that exceed this are terminated. Default: unbounded (u32::MAX).
1377    #[serde(default = "default_fleet_max_turns")]
1378    pub max_turns: u32,
1379    /// Recursive child-agent budget for headless fleet workers.
1380    /// Defaults to [`DEFAULT_SPAWN_DEPTH`] (3) so a fleet worker has the SAME
1381    /// recursion budget as a standalone sub-agent — fleet and sub-agents are one
1382    /// substrate, not two. Set 0 to block child `agent` calls (the root worker
1383    /// still runs); the value is clamped to [`MAX_SPAWN_DEPTH_CEILING`].
1384    #[serde(default = "default_fleet_max_spawn_depth")]
1385    pub max_spawn_depth: u32,
1386    /// Extra system prompt text appended to every headless worker.
1387    /// Useful for injecting org-wide policy or behavior constraints.
1388    #[serde(default, skip_serializing_if = "String::is_empty")]
1389    pub append_system_prompt: String,
1390    /// Output format for fleet worker results.
1391    /// `"text"` (default) or `"stream-json"` for newline-delimited JSON events.
1392    #[serde(default = "default_fleet_output_format")]
1393    pub output_format: String,
1394}
1395
1396fn default_fleet_max_turns() -> u32 {
1397    u32::MAX
1398}
1399
1400fn default_fleet_max_spawn_depth() -> u32 {
1401    DEFAULT_SPAWN_DEPTH
1402}
1403
1404fn default_fleet_output_format() -> String {
1405    "text".to_string()
1406}
1407
1408impl Default for FleetExecConfig {
1409    fn default() -> Self {
1410        Self {
1411            allowed_tools: Vec::new(),
1412            disallowed_tools: Vec::new(),
1413            max_turns: default_fleet_max_turns(),
1414            max_spawn_depth: default_fleet_max_spawn_depth(),
1415            append_system_prompt: String::new(),
1416            output_format: default_fleet_output_format(),
1417        }
1418    }
1419}
1420
1421/// A named role preset that bundles common worker settings.
1422///
1423/// Task specs reference a role name (e.g. `"role": "reviewer"`), and the
1424/// fleet manager fills in any missing fields from the preset. User-defined
1425/// roles in `[fleet.roles]` override built-in defaults with the same name.
1426///
1427/// Token budgets and tool-call limits are task-level decisions — they don't
1428/// belong on role presets. Use `timeout_seconds` as the safety bound.
1429#[derive(Debug, Clone, Serialize, Deserialize)]
1430pub struct FleetRolePreset {
1431    /// Short description of what this role is for.
1432    #[serde(skip_serializing_if = "Option::is_none")]
1433    pub description: Option<String>,
1434    /// Default tool profile (`"read-only"`, `"read-write"`, or `"custom"`).
1435    #[serde(skip_serializing_if = "Option::is_none")]
1436    pub tool_profile: Option<String>,
1437    /// Default set of tool names available to this role.
1438    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1439    pub tools: Vec<String>,
1440    /// Default capability tags (e.g. `"rust"`, `"git"`, `"gh"`).
1441    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1442    pub capabilities: Vec<String>,
1443    /// Default timeout in seconds for tasks using this role.
1444    #[serde(skip_serializing_if = "Option::is_none")]
1445    pub timeout_seconds: Option<u64>,
1446    /// Default trust level override for this role.
1447    #[serde(skip_serializing_if = "Option::is_none")]
1448    pub trust_level: Option<String>,
1449}
1450
1451fn default_fleet_trust_level_str() -> String {
1452    "sandbox".to_string()
1453}
1454
1455fn default_fleet_require_identity() -> bool {
1456    true
1457}
1458
1459fn default_fleet_max_trust_level_str() -> String {
1460    "operator".to_string()
1461}
1462
1463impl Default for FleetConfigToml {
1464    fn default() -> Self {
1465        Self {
1466            default_trust_level: default_fleet_trust_level_str(),
1467            require_identity_verification: default_fleet_require_identity(),
1468            max_trust_level: default_fleet_max_trust_level_str(),
1469            roles: BTreeMap::new(),
1470            exec: FleetExecConfig::default(),
1471        }
1472    }
1473}
1474
1475impl FleetConfigToml {
1476    /// Resolve a role preset by name. Checks user-defined roles first,
1477    /// then falls back to built-in role defaults.
1478    #[must_use]
1479    pub fn resolve_role(&self, name: &str) -> Option<FleetRolePreset> {
1480        self.roles
1481            .get(name)
1482            .cloned()
1483            .or_else(|| built_in_role_presets().get(name).cloned())
1484    }
1485}
1486
1487/// Built-in role presets that are always available without config.
1488#[must_use]
1489pub fn built_in_role_presets() -> BTreeMap<String, FleetRolePreset> {
1490    [
1491        (
1492            "smoke-runner".to_string(),
1493            FleetRolePreset {
1494                description: Some("Lightweight read-only smoke check worker".to_string()),
1495                tool_profile: Some("read-only".to_string()),
1496                tools: vec![],
1497                capabilities: vec![],
1498                timeout_seconds: Some(300),
1499                trust_level: Some("local".to_string()),
1500            },
1501        ),
1502        (
1503            "reviewer".to_string(),
1504            FleetRolePreset {
1505                description: Some("Read-only code and documentation review".to_string()),
1506                tool_profile: Some("read-only".to_string()),
1507                tools: vec![],
1508                capabilities: vec![],
1509                timeout_seconds: Some(600),
1510                trust_level: None,
1511            },
1512        ),
1513        (
1514            "builder".to_string(),
1515            FleetRolePreset {
1516                description: Some(
1517                    "Read-write builder with compilation and test access".to_string(),
1518                ),
1519                tool_profile: Some("read-write".to_string()),
1520                tools: vec![],
1521                capabilities: vec![],
1522                timeout_seconds: Some(1800),
1523                trust_level: Some("local".to_string()),
1524            },
1525        ),
1526        (
1527            "read-only".to_string(),
1528            FleetRolePreset {
1529                description: Some(
1530                    "Minimal read-only observer with no writes or secrets".to_string(),
1531                ),
1532                tool_profile: Some("read-only".to_string()),
1533                tools: vec![],
1534                capabilities: vec![],
1535                timeout_seconds: Some(300),
1536                trust_level: Some("sandbox".to_string()),
1537            },
1538        ),
1539    ]
1540    .into()
1541}
1542
1543/// On-disk schema for the `[network]` table (#135). See `config.example.toml`
1544/// for documentation.
1545#[derive(Debug, Clone, Serialize, Deserialize)]
1546pub struct NetworkPolicyToml {
1547    /// Decision for hosts that are not in `allow` or `deny`. One of
1548    /// `"allow" | "deny" | "prompt"`. Defaults to `"prompt"`.
1549    #[serde(default = "default_network_decision")]
1550    pub default: String,
1551    /// Hosts that are always allowed. Subdomain rules: a leading dot
1552    /// (`.example.com`) matches subdomains but not the apex.
1553    #[serde(default)]
1554    pub allow: Vec<String>,
1555    /// Hosts that are always denied. Deny entries win over allow entries.
1556    #[serde(default)]
1557    pub deny: Vec<String>,
1558    /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an
1559    /// explicitly trusted proxy setup. Literal IP URLs remain blocked.
1560    #[serde(default)]
1561    pub proxy: Vec<String>,
1562    /// Whether to record one audit-log line per outbound network call.
1563    #[serde(default = "default_network_audit")]
1564    pub audit: bool,
1565}
1566
1567fn default_network_decision() -> String {
1568    "prompt".to_string()
1569}
1570
1571fn default_network_audit() -> bool {
1572    true
1573}
1574
1575impl Default for NetworkPolicyToml {
1576    fn default() -> Self {
1577        Self {
1578            default: default_network_decision(),
1579            allow: Vec::new(),
1580            deny: Vec::new(),
1581            proxy: Vec::new(),
1582            audit: default_network_audit(),
1583        }
1584    }
1585}
1586
1587/// On-disk schema for the `[lsp]` table (#136). See `config.example.toml`
1588/// for documentation. All fields are optional so the TUI runtime can fall
1589/// back to its own defaults when keys are absent.
1590#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1591pub struct LspConfigToml {
1592    /// Master switch.
1593    pub enabled: Option<bool>,
1594    /// Maximum time to wait for diagnostics after an edit, in milliseconds.
1595    pub poll_after_edit_ms: Option<u64>,
1596    /// Cap on diagnostics surfaced per file.
1597    pub max_diagnostics_per_file: Option<usize>,
1598    /// When `true`, warnings (severity 2) are surfaced in addition to errors.
1599    pub include_warnings: Option<bool>,
1600    /// Optional override for the `language -> [cmd, ...args]` table.
1601    pub servers: Option<BTreeMap<String, Vec<String>>>,
1602}
1603
1604impl ConfigToml {
1605    /// Merge safe project-level overrides from `$WORKSPACE/.codewhale/config.toml`
1606    /// or legacy `$WORKSPACE/.deepseek/config.toml`.
1607    ///
1608    /// Repo-local config is untrusted input. This helper intentionally ignores
1609    /// credentials, endpoints, provider selection, auth/session values, telemetry,
1610    /// network policy, skill registry, LSP command tables, and unknown extras.
1611    /// Approval and sandbox values may only tighten the existing user/global
1612    /// posture.
1613    pub fn merge_project_overrides(&mut self, project: ConfigToml) {
1614        if project.default_text_model.is_some() {
1615            self.default_text_model = project.default_text_model;
1616        }
1617        if project.model.is_some() {
1618            self.model = project.model;
1619        }
1620        if project.output_mode.is_some() {
1621            self.output_mode = project.output_mode;
1622        }
1623        if project.verbosity.is_some() {
1624            self.verbosity = project.verbosity;
1625        }
1626        if project.log_level.is_some() {
1627            self.log_level = project.log_level;
1628        }
1629        if let Some(policy) = project.approval_policy
1630            && project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy)
1631        {
1632            self.approval_policy = Some(policy);
1633        }
1634        if let Some(mode) = project.sandbox_mode
1635            && project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode)
1636        {
1637            self.sandbox_mode = Some(mode);
1638        }
1639        if project.tools.is_some() {
1640            self.tools = project.tools;
1641        }
1642        for provider in ProviderKind::ALL {
1643            merge_project_provider_config(
1644                self.providers.for_provider_mut(provider),
1645                project.providers.for_provider(provider),
1646            );
1647        }
1648    }
1649
1650    #[must_use]
1651    pub fn get_value(&self, key: &str) -> Option<String> {
1652        if let Some((provider, field)) = parse_provider_config_key(key) {
1653            return get_provider_config_value(self.providers.for_provider(provider), field);
1654        }
1655
1656        match key {
1657            "provider" => Some(self.provider.as_str().to_string()),
1658            "api_key" => self.api_key.clone(),
1659            "base_url" => self.base_url.clone(),
1660            "http_headers" => serialize_http_headers(&self.http_headers),
1661            "default_text_model" => self.default_text_model.clone(),
1662            "model" => self.model.clone(),
1663            "auth.mode" => self.auth_mode.clone(),
1664            "output_mode" => self.output_mode.clone(),
1665            "verbosity" => self.verbosity.clone(),
1666            "log_level" => self.log_level.clone(),
1667            "telemetry" => self.telemetry.map(|v| v.to_string()),
1668            "approval_policy" => self.approval_policy.clone(),
1669            "sandbox_mode" => self.sandbox_mode.clone(),
1670            "tools.always_load" => self.tools.as_ref().map(|tools| tools.always_load.join(",")),
1671            "hook_sinks.unix_socket_path" => self
1672                .hook_sinks
1673                .as_ref()
1674                .and_then(|sinks| sinks.unix_socket_path.as_ref())
1675                .map(|path| path.display().to_string()),
1676            _ => self.extras.get(key).map(toml::Value::to_string),
1677        }
1678    }
1679
1680    #[must_use]
1681    pub fn get_display_value(&self, key: &str) -> Option<String> {
1682        if let Some((provider, field)) = parse_provider_config_key(key) {
1683            return get_provider_config_display_value(self.providers.for_provider(provider), field);
1684        }
1685
1686        if key == "http_headers" {
1687            return serialize_http_headers_for_display(&self.http_headers);
1688        }
1689
1690        if let Some(value) = self.extras.get(key) {
1691            return Some(redact_toml_value_for_display(key, value));
1692        }
1693
1694        self.get_value(key).map(|value| {
1695            if is_sensitive_config_key(key) {
1696                redact_secret(&value)
1697            } else {
1698                value
1699            }
1700        })
1701    }
1702
1703    pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
1704        if let Some((provider, field)) = parse_provider_config_key(key) {
1705            return set_provider_config_value(self, provider, field, value);
1706        }
1707
1708        match key {
1709            "provider" => {
1710                self.provider = ProviderKind::parse(value).with_context(|| {
1711                    format!(
1712                        "unknown provider '{value}': expected {}",
1713                        ProviderKind::names_hint()
1714                    )
1715                })?;
1716            }
1717            "api_key" => self.api_key = Some(value.to_string()),
1718            "base_url" => self.base_url = Some(value.to_string()),
1719            "http_headers" => self.http_headers = parse_http_headers(value)?,
1720            "default_text_model" => self.default_text_model = Some(value.to_string()),
1721            "model" => self.model = Some(value.to_string()),
1722            "auth.mode" => self.auth_mode = Some(value.to_string()),
1723            "output_mode" => self.output_mode = Some(value.to_string()),
1724            "verbosity" => self.verbosity = Some(value.to_string()),
1725            "log_level" => self.log_level = Some(value.to_string()),
1726            "telemetry" => {
1727                self.telemetry = Some(parse_bool(value)?);
1728            }
1729            "approval_policy" => self.approval_policy = Some(value.to_string()),
1730            "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
1731            "hook_sinks.unix_socket_path" => {
1732                self.hook_sinks
1733                    .get_or_insert_with(HookSinksToml::default)
1734                    .unix_socket_path = Some(PathBuf::from(value));
1735            }
1736            _ => {
1737                self.extras
1738                    .insert(key.to_string(), toml::Value::String(value.to_string()));
1739            }
1740        }
1741        Ok(())
1742    }
1743
1744    pub fn unset_value(&mut self, key: &str) -> Result<()> {
1745        if let Some((provider, field)) = parse_provider_config_key(key) {
1746            unset_provider_config_value(self, provider, field);
1747            return Ok(());
1748        }
1749
1750        match key {
1751            "provider" => self.provider = ProviderKind::Deepseek,
1752            "api_key" => self.api_key = None,
1753            "base_url" => self.base_url = None,
1754            "http_headers" => self.http_headers.clear(),
1755            "default_text_model" => self.default_text_model = None,
1756            "model" => self.model = None,
1757            "auth.mode" => self.auth_mode = None,
1758            "output_mode" => self.output_mode = None,
1759            "verbosity" => self.verbosity = None,
1760            "log_level" => self.log_level = None,
1761            "telemetry" => self.telemetry = None,
1762            "approval_policy" => self.approval_policy = None,
1763            "sandbox_mode" => self.sandbox_mode = None,
1764            "hook_sinks.unix_socket_path" => {
1765                if let Some(sinks) = self.hook_sinks.as_mut() {
1766                    sinks.unix_socket_path = None;
1767                }
1768            }
1769            _ => {
1770                self.extras.remove(key);
1771            }
1772        }
1773        Ok(())
1774    }
1775
1776    #[must_use]
1777    pub fn list_values(&self) -> BTreeMap<String, String> {
1778        let mut out = BTreeMap::new();
1779        out.insert("provider".to_string(), self.provider.as_str().to_string());
1780
1781        if let Some(v) = self.api_key.as_ref() {
1782            out.insert("api_key".to_string(), redact_secret(v));
1783        }
1784        if let Some(v) = self.base_url.as_ref() {
1785            out.insert("base_url".to_string(), v.clone());
1786        }
1787        if let Some(v) = serialize_http_headers_for_display(&self.http_headers) {
1788            out.insert("http_headers".to_string(), v);
1789        }
1790        if let Some(v) = self.default_text_model.as_ref() {
1791            out.insert("default_text_model".to_string(), v.clone());
1792        }
1793        if let Some(v) = self.model.as_ref() {
1794            out.insert("model".to_string(), v.clone());
1795        }
1796        if let Some(v) = self.auth_mode.as_ref() {
1797            out.insert("auth.mode".to_string(), v.clone());
1798        }
1799        if let Some(v) = self.output_mode.as_ref() {
1800            out.insert("output_mode".to_string(), v.clone());
1801        }
1802        if let Some(v) = self.verbosity.as_ref() {
1803            out.insert("verbosity".to_string(), v.clone());
1804        }
1805        if let Some(v) = self.log_level.as_ref() {
1806            out.insert("log_level".to_string(), v.clone());
1807        }
1808        if let Some(v) = self.telemetry {
1809            out.insert("telemetry".to_string(), v.to_string());
1810        }
1811        if let Some(v) = self.approval_policy.as_ref() {
1812            out.insert("approval_policy".to_string(), v.clone());
1813        }
1814        if let Some(v) = self.sandbox_mode.as_ref() {
1815            out.insert("sandbox_mode".to_string(), v.clone());
1816        }
1817        if let Some(v) = self
1818            .hook_sinks
1819            .as_ref()
1820            .and_then(|sinks| sinks.unix_socket_path.as_ref())
1821        {
1822            out.insert(
1823                "hook_sinks.unix_socket_path".to_string(),
1824                v.display().to_string(),
1825            );
1826        }
1827
1828        for provider in ProviderKind::ALL {
1829            insert_provider_config_values(
1830                &mut out,
1831                provider,
1832                self.providers.for_provider(provider),
1833            );
1834        }
1835
1836        for (k, v) in &self.extras {
1837            out.insert(k.clone(), redact_toml_value_for_display(k, v));
1838        }
1839        out
1840    }
1841
1842    /// Resolve runtime options without touching platform credential stores.
1843    ///
1844    /// This method keeps library callers prompt-free: CLI flag → config file
1845    /// → environment. Call `resolve_runtime_options_with_secrets` when a
1846    /// user-facing dispatcher should recover credentials from the configured
1847    /// secret store.
1848    #[must_use]
1849    pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
1850        let no_keyring = Secrets::new(std::sync::Arc::new(
1851            codewhale_secrets::InMemoryKeyringStore::new(),
1852        ));
1853        self.resolve_runtime_options_with_secrets(cli, &no_keyring)
1854    }
1855
1856    /// Resolve runtime options using an explicit secrets façade.
1857    ///
1858    /// API-key precedence is **CLI flag → config-file → secret store → environment**.
1859    #[must_use]
1860    pub fn resolve_runtime_options_with_secrets(
1861        &self,
1862        cli: &CliRuntimeOverrides,
1863        secrets: &Secrets,
1864    ) -> ResolvedRuntimeOptions {
1865        let env = EnvRuntimeOverrides::load();
1866        let (provider, provider_source) = if let Some(provider) = cli.provider {
1867            (provider, ProviderSource::Cli)
1868        } else if let Some(provider) = env.provider {
1869            (
1870                provider,
1871                ProviderSource::Env(env.provider_source.unwrap_or("CODEWHALE_PROVIDER")),
1872            )
1873        } else {
1874            (self.provider, ProviderSource::Config)
1875        };
1876
1877        let mut provider_cfg = self.providers.for_provider(provider).clone();
1878        if provider == ProviderKind::SiliconflowCN {
1879            let fb = &self.providers.siliconflow;
1880            if provider_cfg.api_key.is_none() {
1881                provider_cfg.api_key = fb.api_key.clone();
1882            }
1883            if provider_cfg.base_url.is_none() {
1884                provider_cfg.base_url = fb.base_url.clone();
1885            }
1886            if provider_cfg.model.is_none() {
1887                provider_cfg.model = fb.model.clone();
1888            }
1889        }
1890        let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
1891            .then(|| self.api_key.clone())
1892            .flatten();
1893        let root_deepseek_base_url = (provider == ProviderKind::Deepseek)
1894            .then(|| self.base_url.clone())
1895            .flatten();
1896        let root_deepseek_model = (provider == ProviderKind::Deepseek)
1897            .then(|| self.default_text_model.clone())
1898            .flatten();
1899        let auth_mode = cli
1900            .auth_mode
1901            .clone()
1902            .or_else(|| env.auth_mode.clone())
1903            .or_else(|| provider_cfg.auth_mode.clone())
1904            .or_else(|| self.auth_mode.clone());
1905        let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
1906        let configured_base_url = cli
1907            .base_url
1908            .clone()
1909            .or_else(|| env.base_url_for(provider))
1910            .or_else(|| provider_cfg.base_url.clone())
1911            .or(root_deepseek_base_url);
1912        let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo {
1913            env.xiaomi_mimo_mode
1914                .clone()
1915                .or_else(|| provider_cfg.mode.clone())
1916        } else {
1917            None
1918        };
1919        let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo {
1920            xiaomi_mimo_env_api_key_for_runtime(
1921                xiaomi_mimo_mode.as_deref(),
1922                configured_base_url.as_deref(),
1923            )
1924        } else {
1925            None
1926        };
1927        let explicit_api_key_for_endpoint = cli
1928            .api_key
1929            .as_deref()
1930            .or(from_file.as_deref())
1931            .or(xiaomi_mimo_env_api_key.as_deref());
1932        let base_url = if provider == ProviderKind::XiaomiMimo {
1933            resolve_xiaomi_mimo_base_url(
1934                configured_base_url,
1935                explicit_api_key_for_endpoint,
1936                xiaomi_mimo_mode.as_deref(),
1937            )
1938        } else {
1939            configured_base_url.unwrap_or_else(|| match provider {
1940                ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
1941                ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
1942                ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
1943                ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
1944                ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
1945                ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
1946                ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
1947                ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
1948                ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
1949                ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
1950                ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
1951                ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL.to_string(),
1952                ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL.to_string(),
1953                ProviderKind::Moonshot => {
1954                    if auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) {
1955                        DEFAULT_KIMI_CODE_BASE_URL.to_string()
1956                    } else {
1957                        DEFAULT_MOONSHOT_BASE_URL.to_string()
1958                    }
1959                }
1960                ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
1961                ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
1962                ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
1963                ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(),
1964                ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL.to_string(),
1965                ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
1966                ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(),
1967                ProviderKind::Zai => DEFAULT_ZAI_BASE_URL.to_string(),
1968                ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL.to_string(),
1969                ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL.to_string(),
1970                ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL.to_string(),
1971            })
1972        };
1973        // CLI flag wins outright. Otherwise: config-file → injected secrets/env.
1974        // This makes `deepseek auth set` a reliable fix even when the user's
1975        // shell still exports an old key. When the file is empty, the injected
1976        // secrets façade recovers configured secret-store credentials before
1977        // falling back to ambient env.
1978        let uses_kimi_oauth = provider == ProviderKind::Moonshot
1979            && auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth);
1980        let (api_key, api_key_source) = if let Some(value) = cli.api_key.clone() {
1981            (Some(value), Some(RuntimeApiKeySource::Cli))
1982        } else if uses_kimi_oauth {
1983            (None, None)
1984        } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) {
1985            (Some(value), Some(RuntimeApiKeySource::ConfigFile))
1986        } else if let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty()) {
1987            (Some(value), Some(RuntimeApiKeySource::Env))
1988        } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
1989            match env_api_key_for_provider(provider) {
1990                Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
1991                None => (None, None),
1992            }
1993        } else {
1994            match secrets.resolve_with_source(provider.as_str()) {
1995                Some((value, source)) => {
1996                    let source = match source {
1997                        SecretSource::Keyring => RuntimeApiKeySource::Keyring,
1998                        SecretSource::Env => RuntimeApiKeySource::Env,
1999                    };
2000                    (Some(value), Some(source))
2001                }
2002                None => match env_api_key_for_provider(provider) {
2003                    Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
2004                    None => (None, None),
2005                },
2006            }
2007        };
2008
2009        let env_provider_model = env.model_for(provider, &base_url);
2010        let explicit_model = cli.model.is_some()
2011            || env.model.is_some()
2012            || env_provider_model.is_some()
2013            || provider_cfg.model.is_some()
2014            || root_deepseek_model.is_some()
2015            || self.model.is_some();
2016        let model = cli
2017            .model
2018            .clone()
2019            .or_else(|| env.model.clone())
2020            .or(env_provider_model)
2021            .or_else(|| provider_cfg.model.clone())
2022            .or(root_deepseek_model)
2023            .or_else(|| self.model.clone())
2024            .unwrap_or_else(|| {
2025                if provider == ProviderKind::Moonshot
2026                    && (auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth)
2027                        || moonshot_base_url_uses_kimi_code(&base_url))
2028                {
2029                    DEFAULT_KIMI_CODE_MODEL.to_string()
2030                } else {
2031                    default_model_for_provider(provider).to_string()
2032                }
2033            });
2034        let model =
2035            if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
2036                model.trim().to_string()
2037            } else {
2038                normalize_model_for_provider(provider, &model)
2039            };
2040
2041        let mut http_headers = self.http_headers.clone();
2042        http_headers.extend(provider_cfg.http_headers.clone());
2043        if let Some(env_headers) = env.http_headers {
2044            http_headers.extend(env_headers);
2045        }
2046        http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
2047
2048        let output_mode = cli
2049            .output_mode
2050            .clone()
2051            .or_else(|| env.output_mode.clone())
2052            .or_else(|| self.output_mode.clone());
2053        let log_level = cli
2054            .log_level
2055            .clone()
2056            .or_else(|| env.log_level.clone())
2057            .or_else(|| self.log_level.clone());
2058        let telemetry = cli
2059            .telemetry
2060            .or(env.telemetry)
2061            .or(self.telemetry)
2062            .unwrap_or(false);
2063        let approval_policy = cli
2064            .approval_policy
2065            .clone()
2066            .or_else(|| env.approval_policy.clone())
2067            .or_else(|| self.approval_policy.clone());
2068        let sandbox_mode = cli
2069            .sandbox_mode
2070            .clone()
2071            .or_else(|| env.sandbox_mode.clone())
2072            .or_else(|| self.sandbox_mode.clone());
2073        let yolo = cli.yolo.or(env.yolo);
2074        let verbosity = cli
2075            .verbosity
2076            .clone()
2077            .or_else(|| env.verbosity.clone())
2078            .or_else(|| self.verbosity.clone());
2079
2080        ResolvedRuntimeOptions {
2081            provider,
2082            provider_source,
2083            model,
2084            api_key,
2085            api_key_source,
2086            base_url,
2087            auth_mode,
2088            insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false),
2089            output_mode,
2090            log_level,
2091            telemetry,
2092            approval_policy,
2093            sandbox_mode,
2094            yolo,
2095            verbosity,
2096            http_headers,
2097        }
2098    }
2099}
2100
2101fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
2102    if source.model.is_some() {
2103        target.model = source.model.clone();
2104    }
2105}
2106
2107#[must_use]
2108pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
2109    let Some(project_rank) = approval_policy_rank(project) else {
2110        return false;
2111    };
2112    match current.and_then(approval_policy_rank) {
2113        Some(current_rank) => project_rank >= current_rank,
2114        None => project_rank >= 2,
2115    }
2116}
2117
2118#[must_use]
2119pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
2120    let normalized_project = project.trim().to_ascii_lowercase();
2121    if normalized_project == "external-sandbox" {
2122        return current
2123            .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
2124            .unwrap_or(false);
2125    }
2126
2127    let Some(project_rank) = sandbox_mode_rank(project) else {
2128        return false;
2129    };
2130    match current.and_then(sandbox_mode_rank) {
2131        Some(current_rank) => project_rank >= current_rank,
2132        None => project_rank >= 2,
2133    }
2134}
2135
2136fn approval_policy_rank(value: &str) -> Option<u8> {
2137    match value.trim().to_ascii_lowercase().as_str() {
2138        "auto" => Some(0),
2139        "suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
2140        "never" | "deny" | "denied" => Some(2),
2141        _ => None,
2142    }
2143}
2144
2145fn sandbox_mode_rank(value: &str) -> Option<u8> {
2146    match value.trim().to_ascii_lowercase().as_str() {
2147        "danger-full-access" => Some(0),
2148        "external-sandbox" => Some(0),
2149        "workspace-write" => Some(1),
2150        "read-only" => Some(2),
2151        _ => None,
2152    }
2153}
2154
2155/// Load a project-level config from the workspace.
2156///
2157/// Checks `$WORKSPACE/.codewhale/config.toml` first, falling back to
2158/// `$WORKSPACE/.deepseek/config.toml` for backward compatibility.
2159/// Returns `None` if neither file exists or can't be parsed.
2160pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
2161    for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
2162        let path = workspace.join(dir).join(CONFIG_FILE_NAME);
2163        if !project_config_candidate_exists(&path) {
2164            continue;
2165        }
2166        let raw = match read_checked_config_file(&path) {
2167            Ok(raw) => raw,
2168            Err(e) => {
2169                tracing::warn!("Failed to read project config {}: {e:#}", path.display());
2170                return None;
2171            }
2172        };
2173        match toml::from_str(&raw) {
2174            Ok(config) => return Some(config),
2175            Err(e) => {
2176                tracing::warn!("Failed to parse project config {}: {e}", path.display());
2177                return None;
2178            }
2179        }
2180    }
2181    None
2182}
2183
2184fn project_config_candidate_exists(path: &Path) -> bool {
2185    fs::symlink_metadata(path).is_ok_and(|metadata| {
2186        let file_type = metadata.file_type();
2187        file_type.is_file() || file_type.is_symlink()
2188    })
2189}
2190
2191fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
2192    if matches!(provider, ProviderKind::XiaomiMimo)
2193        && let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
2194    {
2195        return canonical.to_string();
2196    }
2197    if matches!(provider, ProviderKind::Minimax)
2198        && let Some(canonical) = canonical_minimax_model_id(model)
2199    {
2200        return canonical.to_string();
2201    }
2202    if matches!(provider, ProviderKind::Zai)
2203        && let Some(canonical) = canonical_zai_model_id(model)
2204    {
2205        return canonical.to_string();
2206    }
2207
2208    if matches!(
2209        provider,
2210        ProviderKind::Atlascloud
2211            | ProviderKind::WanjieArk
2212            | ProviderKind::Volcengine
2213            | ProviderKind::XiaomiMimo
2214            | ProviderKind::Zai
2215            | ProviderKind::Stepfun
2216            | ProviderKind::Minimax
2217            | ProviderKind::Ollama
2218    ) {
2219        return model.to_string();
2220    }
2221
2222    let normalized = model.trim().to_ascii_lowercase();
2223    if provider == ProviderKind::Openrouter
2224        && let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
2225    {
2226        return canonical.to_string();
2227    }
2228    match (provider, normalized.as_str()) {
2229        (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
2230            DEFAULT_NVIDIA_NIM_MODEL.to_string()
2231        }
2232        (
2233            ProviderKind::NvidiaNim,
2234            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2235            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2236        ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
2237        (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
2238            DEFAULT_OPENROUTER_MODEL.to_string()
2239        }
2240        (
2241            ProviderKind::Openrouter,
2242            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2243            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2244        ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
2245        (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
2246            DEFAULT_NOVITA_MODEL.to_string()
2247        }
2248        (
2249            ProviderKind::Novita,
2250            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2251            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2252        ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
2253        (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
2254            DEFAULT_FIREWORKS_MODEL.to_string()
2255        }
2256        (
2257            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2258            "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
2259        ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
2260        (
2261            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2262            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
2263        ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
2264        (
2265            ProviderKind::Arcee,
2266            "trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking",
2267        ) => DEFAULT_ARCEE_MODEL.to_string(),
2268        (ProviderKind::Arcee, "trinity-mini" | "arcee-trinity-mini") => {
2269            ARCEE_TRINITY_MINI_MODEL.to_string()
2270        }
2271        (ProviderKind::Arcee, "arcee-trinity-large-preview") => {
2272            ARCEE_TRINITY_LARGE_PREVIEW_MODEL.to_string()
2273        }
2274        (
2275            ProviderKind::Moonshot,
2276            "kimi"
2277            | "kimi-k2"
2278            | "kimi-k2.7"
2279            | "kimi-k2-7"
2280            | "kimi-k2.7-code"
2281            | "kimi-k2-7-code"
2282            | "kimi-code"
2283            | "moonshot-kimi-k2.7-code",
2284        ) => DEFAULT_MOONSHOT_MODEL.to_string(),
2285        (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
2286            MOONSHOT_KIMI_K2_6_MODEL.to_string()
2287        }
2288        (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
2289            DEFAULT_SGLANG_MODEL.to_string()
2290        }
2291        (
2292            ProviderKind::Sglang,
2293            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2294            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2295        ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
2296        (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
2297            DEFAULT_VLLM_MODEL.to_string()
2298        }
2299        (
2300            ProviderKind::Vllm,
2301            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2302            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2303        ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
2304        (ProviderKind::Huggingface, "deepseek-v4-pro" | "deepseek-v4pro") => {
2305            DEFAULT_HUGGINGFACE_MODEL.to_string()
2306        }
2307        (
2308            ProviderKind::Huggingface,
2309            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2310            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2311        ) => DEFAULT_HUGGINGFACE_FLASH_MODEL.to_string(),
2312        (ProviderKind::Together, "deepseek-v4-pro" | "deepseek-v4pro") => {
2313            DEFAULT_TOGETHER_MODEL.to_string()
2314        }
2315        (
2316            ProviderKind::Together,
2317            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2318            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2319        ) => DEFAULT_TOGETHER_FLASH_MODEL.to_string(),
2320        (ProviderKind::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
2321            DEFAULT_DEEPINFRA_MODEL.to_string()
2322        }
2323        (
2324            ProviderKind::Deepinfra,
2325            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2326            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2327        ) => DEFAULT_DEEPINFRA_FLASH_MODEL.to_string(),
2328        _ => model.to_string(),
2329    }
2330}
2331
2332fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> {
2333    let normalized = model.trim().to_ascii_lowercase();
2334    let normalized = normalized.replace(['_', ' '], "-");
2335    match normalized.as_str() {
2336        "mimo"
2337        | DEFAULT_XIAOMI_MIMO_MODEL
2338        | "mimo-v2-5-pro"
2339        | "xiaomi-mimo-v2.5-pro"
2340        | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL),
2341        "omni"
2342        | "mimo-omni"
2343        | "v2.5-omni"
2344        | "v25-omni"
2345        | "mimo-v2.5"
2346        | "mimo-v25"
2347        | "mimo-v2-5"
2348        | "mimo-v2.5-omni"
2349        | "mimo-v25-omni"
2350        | "mimo-v2-5-omni"
2351        | "xiaomi-mimo-v2.5"
2352        | "xiaomi-mimo-v2-5"
2353        | "xiaomi-mimo-v2.5-omni"
2354        | "xiaomi-mimo-v2-5-omni" => Some(XIAOMI_MIMO_V2_5_OMNI_MODEL),
2355        "asr" | "mimo-asr" | "mimo-v2.5-asr" | "speech-to-text" | "transcribe" => {
2356            Some(XIAOMI_MIMO_ASR_MODEL)
2357        }
2358        "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => {
2359            Some(XIAOMI_MIMO_TTS_MODEL)
2360        }
2361        "mimo-tts-voicedesign"
2362        | "mimo-voice-design"
2363        | "mimo-v25-tts-voicedesign"
2364        | "mimo-v2.5-tts-voicedesign"
2365        | "voicedesign"
2366        | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL),
2367        "mimo-tts-voiceclone"
2368        | "mimo-voice-clone"
2369        | "mimo-v25-tts-voiceclone"
2370        | "mimo-v2.5-tts-voiceclone"
2371        | "voiceclone"
2372        | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL),
2373        "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL),
2374        _ => None,
2375    }
2376}
2377
2378fn canonical_minimax_model_id(model: &str) -> Option<&'static str> {
2379    let normalized = model.trim().to_ascii_lowercase();
2380    let normalized = normalized.replace(['_', ' '], "-");
2381    match normalized.as_str() {
2382        "minimax" | "minimax-m3" | "minimax-m-3" | "minimax-m-3-thinking" => {
2383            Some(DEFAULT_MINIMAX_MODEL)
2384        }
2385        "minimax-m2.7" | "minimax-m2-7" | "minimax-m-2.7" | "minimax-m-2-7" => {
2386            Some(MINIMAX_M2_7_MODEL)
2387        }
2388        "minimax-m2.7-highspeed"
2389        | "minimax-m2-7-highspeed"
2390        | "minimax-m-2.7-highspeed"
2391        | "minimax-m-2-7-highspeed" => Some(MINIMAX_M2_7_HIGHSPEED_MODEL),
2392        "minimax-m2.5" | "minimax-m2-5" | "minimax-m-2.5" | "minimax-m-2-5" => {
2393            Some(MINIMAX_M2_5_MODEL)
2394        }
2395        "minimax-m2.5-highspeed"
2396        | "minimax-m2-5-highspeed"
2397        | "minimax-m-2.5-highspeed"
2398        | "minimax-m-2-5-highspeed" => Some(MINIMAX_M2_5_HIGHSPEED_MODEL),
2399        "minimax-m2.1" | "minimax-m2-1" | "minimax-m-2.1" | "minimax-m-2-1" => {
2400            Some(MINIMAX_M2_1_MODEL)
2401        }
2402        "minimax-m2.1-highspeed"
2403        | "minimax-m2-1-highspeed"
2404        | "minimax-m-2.1-highspeed"
2405        | "minimax-m-2-1-highspeed" => Some(MINIMAX_M2_1_HIGHSPEED_MODEL),
2406        "minimax-m2" | "minimax-m-2" => Some(MINIMAX_M2_MODEL),
2407        _ => None,
2408    }
2409}
2410
2411fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
2412    let normalized = model.trim().to_ascii_lowercase();
2413    let normalized = normalized.replace(['_', ' '], "-");
2414    match normalized.as_str() {
2415        "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
2416        "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(DEFAULT_ZAI_MODEL),
2417        "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
2418        _ => None,
2419    }
2420}
2421
2422fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
2423    let normalized = model.trim().to_ascii_lowercase();
2424    let normalized = normalized.replace(['_', ' '], "-");
2425    match normalized.as_str() {
2426        OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
2427        | "trinity"
2428        | "trinity-large-thinking"
2429        | "arcee-trinity"
2430        | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
2431        OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
2432            Some(OPENROUTER_GEMMA_4_31B_MODEL)
2433        }
2434        OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
2435            Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
2436        }
2437        OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
2438            Some(OPENROUTER_GLM_5_1_MODEL)
2439        }
2440        OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
2441            Some(OPENROUTER_GLM_5_2_MODEL)
2442        }
2443        OPENROUTER_KIMI_K2_7_CODE_MODEL
2444        | "kimi"
2445        | "kimi-k2"
2446        | "kimi-k2.7"
2447        | "kimi-k2-7"
2448        | "kimi-k2.7-code"
2449        | "kimi-k2-7-code"
2450        | "kimi-code"
2451        | "moonshot-kimi-k2.7-code"
2452        | "openrouter-kimi-k2.7-code" => Some(OPENROUTER_KIMI_K2_7_CODE_MODEL),
2453        OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
2454            Some(OPENROUTER_KIMI_K2_6_MODEL)
2455        }
2456        OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => {
2457            Some(OPENROUTER_MINIMAX_M3_MODEL)
2458        }
2459        OPENROUTER_MINIMAX_2_7_MODEL
2460        | "minimax-2.7"
2461        | "minimax-2-7"
2462        | "minimax-m2.7"
2463        | "minimax-m2-7"
2464        | "minimax-m-2.7"
2465        | "minimax-m-2-7" => Some(OPENROUTER_MINIMAX_2_7_MODEL),
2466        OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
2467        | "nemotron-3-nano-omni"
2468        | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
2469        OPENROUTER_QWEN_3_6_35B_A3B_MODEL
2470        | "qwen3.6-35b-a3b"
2471        | "qwen-3.6-35b-a3b"
2472        | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
2473        OPENROUTER_QWEN_3_6_FLASH_MODEL | "qwen3.6-flash" | "qwen-3.6-flash" => {
2474            Some(OPENROUTER_QWEN_3_6_FLASH_MODEL)
2475        }
2476        OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL
2477        | "qwen3.6-max-preview"
2478        | "qwen-3.6-max-preview"
2479        | "qwen-max-preview" => Some(OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
2480        OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
2481            Some(OPENROUTER_QWEN_3_6_27B_MODEL)
2482        }
2483        OPENROUTER_QWEN_3_6_PLUS_MODEL | "qwen3.6-plus" | "qwen-3.6-plus" => {
2484            Some(OPENROUTER_QWEN_3_6_PLUS_MODEL)
2485        }
2486        OPENROUTER_QWEN_3_7_MAX_MODEL | "qwen3.7-max" | "qwen-3.7-max" => {
2487            Some(OPENROUTER_QWEN_3_7_MAX_MODEL)
2488        }
2489        OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
2490            Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
2491        }
2492        OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
2493        | "mimo-v2.5-pro"
2494        | "mimo-v2-5-pro"
2495        | "xiaomi-mimo-v2.5-pro"
2496        | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
2497        OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
2498        | "mimo-v2.5"
2499        | "mimo-v2-5"
2500        | "xiaomi-mimo-v2.5"
2501        | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
2502        _ => None,
2503    }
2504}
2505
2506fn default_model_for_provider(provider: ProviderKind) -> &'static str {
2507    match provider {
2508        ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
2509        ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
2510        ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
2511        ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
2512        ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
2513        ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
2514        ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
2515        ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
2516        ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
2517        ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
2518        ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_MODEL,
2519        ProviderKind::Arcee => DEFAULT_ARCEE_MODEL,
2520        ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
2521        ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
2522        ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
2523        ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
2524        ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
2525        ProviderKind::Together => DEFAULT_TOGETHER_MODEL,
2526        ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL,
2527        ProviderKind::Anthropic => DEFAULT_ANTHROPIC_MODEL,
2528        ProviderKind::Zai => DEFAULT_ZAI_MODEL,
2529        ProviderKind::Stepfun => DEFAULT_STEPFUN_MODEL,
2530        ProviderKind::Minimax => DEFAULT_MINIMAX_MODEL,
2531        ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
2532    }
2533}
2534
2535fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
2536    match provider {
2537        ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
2538        ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
2539        ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
2540        ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
2541        ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
2542        ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
2543        ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
2544        ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
2545        ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
2546        ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
2547        ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
2548        ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL,
2549        ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL,
2550        ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
2551        ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
2552        ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
2553        ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
2554        ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
2555        ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL,
2556        ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
2557        ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
2558        ProviderKind::Zai => DEFAULT_ZAI_BASE_URL,
2559        ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL,
2560        ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL,
2561        ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
2562    }
2563}
2564
2565fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
2566    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
2567    normalized == DEFAULT_KIMI_CODE_BASE_URL
2568        || normalized == "https://api.kimi.com/coding"
2569        || normalized.starts_with("https://api.kimi.com/coding/")
2570}
2571
2572fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
2573    let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
2574    if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
2575        return None;
2576    }
2577    Some(match normalized.as_str() {
2578        "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => {
2579            DEFAULT_XIAOMI_MIMO_BASE_URL
2580        }
2581        "token-plan-cn"
2582        | "token-plan-china"
2583        | "token-plan-mainland"
2584        | "token-plan-mainland-china"
2585        | "cn"
2586        | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
2587        "token-plan-sgp"
2588        | "token-plan-sg"
2589        | "token-plan-singapore"
2590        | "sgp"
2591        | "sg"
2592        | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
2593        "token-plan-ams"
2594        | "token-plan-eu"
2595        | "token-plan-europe"
2596        | "token-plan-amsterdam"
2597        | "ams"
2598        | "eu"
2599        | "europe"
2600        | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
2601        _ => DEFAULT_XIAOMI_MIMO_BASE_URL,
2602    })
2603}
2604
2605fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
2606    matches!(
2607        normalized_mode,
2608        "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
2609    )
2610}
2611
2612fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
2613    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
2614    normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
2615        || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL
2616        || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
2617}
2618
2619fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option<String> {
2620    candidates.iter().find_map(|name| {
2621        std::env::var(name)
2622            .ok()
2623            .filter(|value| !value.trim().is_empty())
2624    })
2625}
2626
2627fn xiaomi_mimo_env_api_key_for_runtime(
2628    mode: Option<&str>,
2629    base_url: Option<&str>,
2630) -> Option<String> {
2631    const TOKEN_PLAN_ENV_VARS: &[&str] =
2632        &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"];
2633    const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"];
2634
2635    let normalized_mode =
2636        mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
2637    let standard_selected = normalized_mode
2638        .as_deref()
2639        .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint)
2640        || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go);
2641    if standard_selected {
2642        return xiaomi_mimo_env_var(STANDARD_ENV_VARS);
2643    }
2644
2645    let token_plan_selected = normalized_mode
2646        .as_deref()
2647        .and_then(xiaomi_mimo_base_url_for_mode)
2648        .is_some()
2649        || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan);
2650    if token_plan_selected {
2651        return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS);
2652    }
2653
2654    xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS))
2655}
2656
2657fn resolve_xiaomi_mimo_base_url(
2658    configured: Option<String>,
2659    api_key: Option<&str>,
2660    mode: Option<&str>,
2661) -> String {
2662    let normalized_mode =
2663        mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
2664    let uses_standard_mode = normalized_mode
2665        .as_deref()
2666        .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint);
2667    let mode_base_url = normalized_mode
2668        .as_deref()
2669        .and_then(xiaomi_mimo_base_url_for_mode);
2670    let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key);
2671    match configured {
2672        Some(base_url) if uses_standard_mode => base_url,
2673        Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => {
2674            mode_base_url
2675                .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL)
2676                .to_string()
2677        }
2678        Some(base_url) => base_url,
2679        None => {
2680            if let Some(base_url) = mode_base_url {
2681                base_url.to_string()
2682            } else if uses_standard_mode {
2683                XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
2684            } else if uses_token_plan || api_key.is_none() {
2685                DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()
2686            } else {
2687                XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
2688            }
2689        }
2690    }
2691}
2692
2693fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
2694    api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
2695}
2696
2697fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool {
2698    matches!(
2699        base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
2700        "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
2701    )
2702}
2703
2704fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
2705    if provider.is_siliconflow() && siliconflow_base_url_is_official(base_url) {
2706        return false;
2707    }
2708    if provider == ProviderKind::XiaomiMimo
2709        && (xiaomi_mimo_base_url_uses_token_plan(base_url)
2710            || xiaomi_mimo_base_url_is_pay_as_you_go(base_url))
2711    {
2712        return false;
2713    }
2714    let actual = base_url.trim_end_matches('/');
2715    let default = default_base_url_for_provider(provider).trim_end_matches('/');
2716    actual != default
2717}
2718
2719fn siliconflow_base_url_is_official(base_url: &str) -> bool {
2720    matches!(
2721        base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
2722        "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
2723    )
2724}
2725
2726fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
2727    base_url_is_custom_for_provider(provider, base_url)
2728}
2729
2730fn should_skip_secret_store_for_provider(
2731    provider: ProviderKind,
2732    base_url: &str,
2733    auth_mode: Option<&str>,
2734) -> bool {
2735    if auth_mode_requires_api_key(auth_mode) {
2736        return false;
2737    }
2738    if auth_mode_disables_api_key(auth_mode) {
2739        return true;
2740    }
2741
2742    matches!(
2743        provider,
2744        ProviderKind::Sglang | ProviderKind::Vllm | ProviderKind::Ollama
2745    ) || base_url_uses_local_host(base_url)
2746}
2747
2748fn env_api_key_for_provider(provider: ProviderKind) -> Option<String> {
2749    if provider == ProviderKind::Huggingface {
2750        return std::env::var("HUGGINGFACE_API_KEY")
2751            .ok()
2752            .filter(|value| !value.trim().is_empty())
2753            .or_else(|| {
2754                std::env::var("HF_TOKEN")
2755                    .ok()
2756                    .filter(|value| !value.trim().is_empty())
2757            });
2758    }
2759
2760    codewhale_secrets::env_for(provider.as_str())
2761}
2762
2763fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
2764    matches!(
2765        auth_mode
2766            .map(str::trim)
2767            .filter(|value| !value.is_empty())
2768            .map(|value| value.to_ascii_lowercase()),
2769        Some(value)
2770            if matches!(
2771                value.as_str(),
2772                "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
2773            )
2774    )
2775}
2776
2777fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
2778    matches!(
2779        auth_mode
2780            .map(str::trim)
2781            .filter(|value| !value.is_empty())
2782            .map(|value| value.to_ascii_lowercase()),
2783        Some(value)
2784            if matches!(
2785                value.as_str(),
2786                "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
2787            )
2788    )
2789}
2790
2791fn auth_mode_uses_kimi_oauth(auth_mode: &str) -> bool {
2792    matches!(
2793        auth_mode
2794            .trim()
2795            .to_ascii_lowercase()
2796            .replace('-', "_")
2797            .as_str(),
2798        "kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
2799    )
2800}
2801
2802fn base_url_uses_local_host(base_url: &str) -> bool {
2803    let Some(host) = base_url_host(base_url) else {
2804        return false;
2805    };
2806    let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
2807    if matches!(host.as_str(), "localhost" | "0.0.0.0") {
2808        return true;
2809    }
2810    host.parse::<std::net::IpAddr>()
2811        .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
2812}
2813
2814fn base_url_host(base_url: &str) -> Option<&str> {
2815    let without_scheme = base_url
2816        .split_once("://")
2817        .map_or(base_url, |(_, rest)| rest);
2818    let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
2819    if let Some(rest) = authority.strip_prefix('[') {
2820        return rest.split_once(']').map(|(host, _)| host);
2821    }
2822    authority.split(':').next().filter(|host| !host.is_empty())
2823}
2824
2825#[derive(Debug, Clone, Default)]
2826pub struct CliRuntimeOverrides {
2827    pub provider: Option<ProviderKind>,
2828    pub model: Option<String>,
2829    pub api_key: Option<String>,
2830    pub base_url: Option<String>,
2831    pub auth_mode: Option<String>,
2832    pub output_mode: Option<String>,
2833    pub log_level: Option<String>,
2834    pub telemetry: Option<bool>,
2835    pub approval_policy: Option<String>,
2836    pub sandbox_mode: Option<String>,
2837    pub yolo: Option<bool>,
2838    pub verbosity: Option<String>,
2839}
2840
2841#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2842pub enum RuntimeApiKeySource {
2843    Cli,
2844    ConfigFile,
2845    Keyring,
2846    Env,
2847}
2848
2849impl RuntimeApiKeySource {
2850    #[must_use]
2851    pub fn as_env_value(self) -> &'static str {
2852        match self {
2853            Self::Cli => "cli",
2854            Self::ConfigFile => "config",
2855            Self::Keyring => "keyring",
2856            Self::Env => "env",
2857        }
2858    }
2859}
2860
2861#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2862pub enum ProviderSource {
2863    Cli,
2864    Env(&'static str),
2865    Config,
2866}
2867
2868#[derive(Debug, Clone)]
2869pub struct ResolvedRuntimeOptions {
2870    pub provider: ProviderKind,
2871    pub provider_source: ProviderSource,
2872    pub model: String,
2873    pub api_key: Option<String>,
2874    pub api_key_source: Option<RuntimeApiKeySource>,
2875    pub base_url: String,
2876    pub auth_mode: Option<String>,
2877    pub insecure_skip_tls_verify: bool,
2878    pub output_mode: Option<String>,
2879    pub log_level: Option<String>,
2880    pub telemetry: bool,
2881    pub approval_policy: Option<String>,
2882    pub sandbox_mode: Option<String>,
2883    pub yolo: Option<bool>,
2884    pub verbosity: Option<String>,
2885    pub http_headers: BTreeMap<String, String>,
2886}
2887
2888#[derive(Debug, Clone)]
2889pub struct ConfigStore {
2890    path: PathBuf,
2891    pub config: ConfigToml,
2892    permissions: PermissionsToml,
2893    /// Original file text, retained so [`save`](Self::save) can merge
2894    /// comments back after serialisation.
2895    original_raw: Option<String>,
2896}
2897
2898impl ConfigStore {
2899    pub fn load(path: Option<PathBuf>) -> Result<Self> {
2900        let path = resolve_config_path(path)?;
2901        let (config, original_raw) = if checked_path_exists(&path)? {
2902            let raw = read_checked_config_file(&path)?;
2903            let parsed: ConfigToml = toml::from_str(&raw)
2904                .with_context(|| format!("failed to parse config at {}", path.display()))?;
2905            (parsed, Some(raw))
2906        } else {
2907            (ConfigToml::default(), None)
2908        };
2909        let permissions = load_sibling_permissions(&path)?;
2910
2911        Ok(Self {
2912            path,
2913            config,
2914            permissions,
2915            original_raw,
2916        })
2917    }
2918
2919    pub fn save(&self) -> Result<()> {
2920        let path = normalize_config_file_path(self.path.clone())?;
2921        if let Some(parent) = path.parent() {
2922            fs::create_dir_all(parent).with_context(|| {
2923                format!("failed to create config directory {}", parent.display())
2924            })?;
2925        }
2926        let body = if let Some(ref original_raw) = self.original_raw {
2927            let serialized =
2928                toml::to_string_pretty(&self.config).context("failed to serialize config")?;
2929            merge_and_preserve_comments(&serialized, original_raw).unwrap_or_else(|e| {
2930                tracing::warn!("failed to merge config comments, saving without them: {e:#}");
2931                serialized
2932            })
2933        } else {
2934            toml::to_string_pretty(&self.config).context("failed to serialize config")?
2935        };
2936        if checked_path_exists(&path)? {
2937            let existing = read_checked_config_file(&path)?;
2938            if existing == body {
2939                return Ok(());
2940            }
2941            write_one_time_config_backup(&path)?;
2942        }
2943        #[cfg(unix)]
2944        {
2945            let mut file = fs::OpenOptions::new()
2946                .write(true)
2947                .create(true)
2948                .truncate(true)
2949                .mode(0o600)
2950                .open(&path)
2951                .with_context(|| format!("failed to write config at {}", path.display()))?;
2952            file.write_all(body.as_bytes())
2953                .with_context(|| format!("failed to write config at {}", path.display()))?;
2954            file.set_permissions(fs::Permissions::from_mode(0o600))
2955                .with_context(|| {
2956                    format!("failed to set config permissions at {}", path.display())
2957                })?;
2958        }
2959        #[cfg(not(unix))]
2960        {
2961            fs::write(&path, body)
2962                .with_context(|| format!("failed to write config at {}", path.display()))?;
2963        }
2964        Ok(())
2965    }
2966
2967    #[must_use]
2968    pub fn path(&self) -> &Path {
2969        &self.path
2970    }
2971
2972    #[must_use]
2973    pub fn permissions(&self) -> &PermissionsToml {
2974        &self.permissions
2975    }
2976
2977    #[must_use]
2978    pub fn permissions_path(&self) -> PathBuf {
2979        checked_permissions_path_for_config_path(&self.path)
2980            .expect("ConfigStore path is validated before construction")
2981    }
2982
2983    #[must_use]
2984    pub fn exec_policy_engine(&self) -> ExecPolicyEngine {
2985        if self.permissions.is_empty() {
2986            ExecPolicyEngine::new(Vec::new(), Vec::new())
2987        } else {
2988            ExecPolicyEngine::with_rulesets(vec![self.permissions.ruleset()])
2989        }
2990    }
2991
2992    /// Atomically append ask-only permission rules to the sibling
2993    /// `permissions.toml` file.
2994    ///
2995    /// Existing comments and formatting are preserved. Exact duplicate rules
2996    /// are ignored, and the in-memory permissions snapshot is refreshed after
2997    /// a successful write.
2998    pub fn append_ask_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
2999        if rules.is_empty() {
3000            return Ok(0);
3001        }
3002
3003        let path = checked_permissions_path_for_config_path(&self.path)?;
3004        let raw = if checked_path_exists(&path)? {
3005            read_checked_permissions_file(&path)?
3006        } else {
3007            String::new()
3008        };
3009        let mut permissions = if raw.trim().is_empty() {
3010            PermissionsToml::default()
3011        } else {
3012            toml::from_str(&raw)
3013                .with_context(|| format!("failed to parse permissions at {}", path.display()))?
3014        };
3015        let mut document = if raw.trim().is_empty() {
3016            toml_edit::DocumentMut::new()
3017        } else {
3018            raw.parse::<toml_edit::DocumentMut>()
3019                .with_context(|| format!("failed to edit permissions at {}", path.display()))?
3020        };
3021
3022        if !document.contains_key("rules") {
3023            document["rules"] = toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
3024        }
3025        let rules_item = document
3026            .get_mut("rules")
3027            .expect("rules entry was inserted above");
3028
3029        let mut added = 0;
3030        for rule in rules {
3031            if permissions.rules.contains(rule) {
3032                continue;
3033            }
3034            append_ask_rule(rules_item, rule)?;
3035            permissions.rules.push(rule.clone());
3036            added += 1;
3037        }
3038        if added == 0 {
3039            self.permissions = permissions;
3040            return Ok(0);
3041        }
3042
3043        let body = document.to_string();
3044        let persisted: PermissionsToml = toml::from_str(&body).with_context(|| {
3045            format!(
3046                "generated invalid permissions document for {}",
3047                path.display()
3048            )
3049        })?;
3050        write_permissions_atomic(&path, body.as_bytes())?;
3051        self.permissions = persisted;
3052        Ok(added)
3053    }
3054}
3055
3056fn config_backup_file_name(path: &Path) -> OsString {
3057    let mut file_name = path
3058        .file_name()
3059        .map(OsString::from)
3060        .unwrap_or_else(|| OsString::from(CONFIG_FILE_NAME));
3061    file_name.push(".bak");
3062    file_name
3063}
3064
3065fn config_sibling_path_unchecked(config_path: &Path, file_name: &OsStr) -> PathBuf {
3066    config_path
3067        .parent()
3068        .unwrap_or_else(|| Path::new("."))
3069        .join(file_name)
3070}
3071
3072fn checked_config_sibling_path(config_path: &Path, file_name: &OsStr) -> Result<PathBuf> {
3073    let config_path = normalize_config_file_path(config_path.to_path_buf())?;
3074    let parent = config_path
3075        .parent()
3076        .context("config path must include a parent directory")?;
3077    let path = parent.join(file_name);
3078    reject_path_symlink(&path)?;
3079    Ok(path)
3080}
3081
3082#[cfg(test)]
3083fn config_backup_path(path: &Path) -> PathBuf {
3084    config_sibling_path_unchecked(path, &config_backup_file_name(path))
3085}
3086
3087fn checked_config_backup_path(path: &Path) -> Result<PathBuf> {
3088    checked_config_sibling_path(path, &config_backup_file_name(path))
3089}
3090
3091fn write_one_time_config_backup(path: &Path) -> Result<()> {
3092    let backup = checked_config_backup_path(path)?;
3093    if backup.exists() {
3094        return Ok(());
3095    }
3096    fs::copy(path, &backup).with_context(|| {
3097        format!(
3098            "failed to create config backup {} from {}",
3099            backup.display(),
3100            path.display()
3101        )
3102    })?;
3103    #[cfg(unix)]
3104    {
3105        fs::set_permissions(&backup, fs::Permissions::from_mode(0o600)).with_context(|| {
3106            format!(
3107                "failed to set config backup permissions at {}",
3108                backup.display()
3109            )
3110        })?;
3111    }
3112    Ok(())
3113}
3114
3115/// Merge comments and formatting from an original TOML file into a
3116/// freshly serialized document so user annotations (comments, whitespace,
3117/// disabled keys) survive config rewrites.
3118///
3119/// `original_raw` is the raw text of the file before the change; the
3120/// function parses it internally with [`toml_edit`] so callers stay free
3121/// of that dependency.
3122pub fn merge_and_preserve_comments(serialized: &str, original_raw: &str) -> Result<String> {
3123    let original = original_raw
3124        .parse::<toml_edit::DocumentMut>()
3125        .context("failed to parse original config for comment merge")?;
3126
3127    let mut new_doc = serialized
3128        .parse::<toml_edit::DocumentMut>()
3129        .context("failed to parse serialized config for comment merge")?;
3130
3131    // Reuse the original document’s trailing text (file-footer comments /
3132    // disabled keys) so they survive the rewrite.
3133    new_doc.set_trailing(original.trailing().clone());
3134
3135    // Copy the top-level table's decor (document-header comments, whitespace
3136    // before the first key) which `toml_edit` stores on the root `Table` itself.
3137    *new_doc.as_table_mut().decor_mut() = original.as_table().decor().clone();
3138
3139    merge_decor_table(new_doc.as_table_mut(), original.as_table());
3140
3141    Ok(new_doc.to_string())
3142}
3143
3144/// Recursively copy `decor` (prefix/suffix comments and whitespace) from
3145/// every key in `source` that also exists in `target`.
3146fn merge_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
3147    // Collect keys first — the borrow checker won't let us hold
3148    // `get_key_value_mut` while iterating.
3149    let keys: Vec<String> = source.iter().map(|(k, _)| k.to_owned()).collect();
3150    for key in &keys {
3151        let Some((source_key, source_item)) = source.get_key_value(key) else {
3152            continue;
3153        };
3154        let Some((mut target_key_mut, target_item)) = target.get_key_value_mut(key) else {
3155            continue;
3156        };
3157
3158        // Copy the key-level decor (comments before the key itself)
3159        *target_key_mut.leaf_decor_mut() = source_key.leaf_decor().clone();
3160
3161        copy_item_decor(target_item, source_item);
3162
3163        if let (Some(tt), Some(st)) = (target_item.as_table_mut(), source_item.as_table()) {
3164            merge_decor_table(tt, st);
3165        }
3166
3167        if let (Some(ta), Some(sa)) = (
3168            target_item.as_array_of_tables_mut(),
3169            source_item.as_array_of_tables(),
3170        ) {
3171            for (i, source_table) in sa.iter().enumerate() {
3172                if let Some(target_table) = ta.get_mut(i) {
3173                    copy_item_decor_table(target_table, source_table);
3174                    merge_decor_table(target_table, source_table);
3175                }
3176            }
3177        }
3178    }
3179}
3180
3181/// Copy the decor (comments and surrounding whitespace) from `source` to `target`,
3182/// respecting the concrete item type since [`toml_edit::Item`] has no uniform
3183/// `decor` accessor.
3184fn copy_item_decor(target: &mut toml_edit::Item, source: &toml_edit::Item) {
3185    match (target, source) {
3186        (toml_edit::Item::Table(tt), toml_edit::Item::Table(st)) => {
3187            *tt.decor_mut() = st.decor().clone();
3188        }
3189        (toml_edit::Item::Value(tv), toml_edit::Item::Value(sv)) => {
3190            *tv.decor_mut() = sv.decor().clone();
3191        }
3192        _ => {}
3193    }
3194}
3195
3196fn copy_item_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
3197    *target.decor_mut() = source.decor().clone();
3198}
3199
3200/// Process-wide default [`Secrets`] façade. The first caller wins; the
3201/// lock is exposed so test or CLI code can install an explicit
3202/// backend (e.g. an [`codewhale_secrets::InMemoryKeyringStore`]) before
3203/// any resolver runs.
3204pub fn default_secrets() -> &'static Secrets {
3205    static SECRETS: OnceLock<Secrets> = OnceLock::new();
3206    SECRETS.get_or_init(|| {
3207        // Tests should never poke real platform credential stores. Cargo sets the
3208        // `RUST_TEST_*` family of env vars (and `CARGO_PKG_NAME` is
3209        // always populated), but the `cfg(test)` flag is the canonical
3210        // signal here. See `install_test_secrets` for explicit installs.
3211        #[cfg(test)]
3212        {
3213            Secrets::new(std::sync::Arc::new(
3214                codewhale_secrets::InMemoryKeyringStore::new(),
3215            ))
3216        }
3217        #[cfg(not(test))]
3218        {
3219            Secrets::auto_detect()
3220        }
3221    })
3222}
3223
3224// ── CodeWhale state root (v0.8.44) ──────────────────────────────────
3225//
3226// v0.8.44 migrates product-owned app state from ~/.deepseek/ to
3227// ~/.codewhale/ while keeping ~/.deepseek/ as a compatibility fallback.
3228// New installs write to ~/.codewhale/. Existing installs with only
3229// ~/.deepseek/ continue working without data loss.
3230
3231/// Canonical CodeWhale app directory name under $HOME.
3232pub const CODEWHALE_APP_DIR: &str = ".codewhale";
3233
3234/// Legacy DeepSeek-branded app directory name (compatibility fallback).
3235pub const LEGACY_APP_DIR: &str = ".deepseek";
3236
3237/// Resolve the primary CodeWhale home directory.
3238///
3239/// `$CODEWHALE_HOME` takes precedence when set. Otherwise defaults to
3240/// `$HOME/.codewhale`. This is the write target for new product state.
3241pub fn codewhale_home() -> Result<PathBuf> {
3242    if let Ok(val) = std::env::var("CODEWHALE_HOME") {
3243        let trimmed = val.trim();
3244        if !trimmed.is_empty() {
3245            return Ok(PathBuf::from(trimmed));
3246        }
3247    }
3248    let home = effective_home_dir().context("failed to resolve home directory")?;
3249    Ok(home.join(CODEWHALE_APP_DIR))
3250}
3251
3252/// Resolve the legacy DeepSeek home directory (`$HOME/.deepseek`).
3253///
3254/// Always returns the legacy path regardless of whether it exists.
3255pub fn legacy_deepseek_home() -> Result<PathBuf> {
3256    let home = effective_home_dir().context("failed to resolve home directory")?;
3257    Ok(home.join(LEGACY_APP_DIR))
3258}
3259
3260fn effective_home_dir() -> Option<PathBuf> {
3261    std::env::var_os("HOME")
3262        .filter(|value| !value.is_empty())
3263        .map(PathBuf::from)
3264        .or_else(dirs::home_dir)
3265}
3266
3267/// Reject state subdirs that could escape the state root via path injection.
3268///
3269/// `ensure_state_dir` / `resolve_state_dir` are public APIs taking an arbitrary
3270/// subdir string; every in-tree caller passes a hardcoded single component
3271/// (e.g. `"sessions"`, `"."`). This validates defensively so a future caller
3272/// can never traverse out of the state root via `..` components or an absolute
3273/// path. Nested relative paths such as `"a/b"` are permitted.
3274fn ensure_safe_state_subdir(subdir: &str) -> Result<()> {
3275    if subdir.is_empty() {
3276        bail!("state subdir must not be empty");
3277    }
3278    let path = std::path::Path::new(subdir);
3279    if path.is_absolute() {
3280        bail!("state subdir must not be an absolute path: {subdir}");
3281    }
3282    if path.components().any(|c| {
3283        matches!(
3284            c,
3285            std::path::Component::RootDir | std::path::Component::Prefix(_)
3286        )
3287    }) {
3288        bail!("state subdir must not contain a root or prefix: {subdir}");
3289    }
3290    if path
3291        .components()
3292        .any(|c| matches!(c, std::path::Component::ParentDir))
3293    {
3294        bail!("state subdir must not contain parent-dir (..) components: {subdir}");
3295    }
3296    Ok(())
3297}
3298
3299/// Resolve a state subdirectory, preferring the CodeWhale root if
3300/// it already exists, otherwise falling back to the legacy root.
3301///
3302/// This is the read-path resolver: it returns the primary path when
3303/// migration has occurred or on a fresh install, but keeps reading
3304/// from the legacy path for users who haven't migrated yet.
3305pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
3306    ensure_safe_state_subdir(subdir)?;
3307    let primary = codewhale_home()?.join(subdir);
3308    if primary.exists() {
3309        return Ok(primary);
3310    }
3311    let legacy = legacy_deepseek_home()?.join(subdir);
3312    if legacy.exists() {
3313        return Ok(legacy);
3314    }
3315    // Neither exists — return primary for first-write creation.
3316    Ok(primary)
3317}
3318
3319/// Ensure a state subdirectory exists under the primary CodeWhale root,
3320/// creating it if necessary. This is the write-path resolver.
3321///
3322/// On the first creation of a real subdirectory (not the root sentinel `"."`),
3323/// if a legacy `~/.deepseek/<subdir>` exists but the primary
3324/// `~/.codewhale/<subdir>` does not, the legacy directory is relocated into
3325/// the primary location so the user keeps their data and the legacy tree
3326/// stops growing (#3240). After migration, [`resolve_state_dir`] finds the
3327/// data in the primary location; the read resolver itself is unchanged.
3328pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
3329    ensure_safe_state_subdir(subdir)?;
3330    let dir = codewhale_home()?.join(subdir);
3331    migrate_legacy_state_dir(&dir, subdir)?;
3332    std::fs::create_dir_all(&dir)
3333        .with_context(|| format!("failed to create {}/", dir.display()))?;
3334    Ok(dir)
3335}
3336
3337/// One-time relocation of a legacy `~/.deepseek/<subdir>` state directory into
3338/// the primary `~/.codewhale/<subdir>` location (#3240). No-op once the primary
3339/// exists, for the root sentinel `"."` (a whole-tree move is owned by the
3340/// config-file migration), or when no legacy directory is present.
3341fn migrate_legacy_state_dir(primary: &Path, subdir: &str) -> Result<()> {
3342    if primary.exists() || subdir == "." || subdir.is_empty() {
3343        return Ok(());
3344    }
3345    let legacy = match legacy_deepseek_home() {
3346        Ok(home) => home.join(subdir),
3347        Err(_) => return Ok(()),
3348    };
3349    if !legacy.exists() {
3350        return Ok(());
3351    }
3352    // The primary's parent (the ~/.codewhale root) must exist for the rename.
3353    if let Some(parent) = primary.parent() {
3354        if let Err(err) = std::fs::create_dir_all(parent) {
3355            tracing::warn!(
3356                target: "config::migration",
3357                "Could not create {} for state migration ({}); writing to primary anyway",
3358                parent.display(),
3359                err
3360            );
3361        }
3362    }
3363    match std::fs::rename(&legacy, primary) {
3364        Ok(()) => {
3365            tracing::info!(
3366                target: "config::migration",
3367                "Migrated legacy state directory {} -> {} (relocated). The .deepseek copy was removed.",
3368                legacy.display(),
3369                primary.display()
3370            );
3371        }
3372        Err(err) => {
3373            // Cross-device rename or permission issue: fall back to a
3374            // recursive copy so the user keeps their data. The legacy tree is
3375            // left in place; it stops growing because writes now target the
3376            // primary path.
3377            match copy_dir_recursive(&legacy, primary) {
3378                Ok(()) => {
3379                    tracing::info!(
3380                        target: "config::migration",
3381                        "Migrated legacy state directory {} -> {} (copied; rename failed: {err}). \
3382                         The legacy .deepseek copy was left in place.",
3383                        legacy.display(),
3384                        primary.display()
3385                    );
3386                }
3387                Err(copy_err) => {
3388                    tracing::warn!(
3389                        target: "config::migration",
3390                        "Could not migrate legacy state {} -> {} (rename: {err}; copy: {copy_err}). \
3391                         New data is written to the primary path; the legacy tree remains untouched.",
3392                        legacy.display(),
3393                        primary.display()
3394                    );
3395                }
3396            }
3397        }
3398    }
3399    Ok(())
3400}
3401
3402/// Recursively copy a directory tree from `src` to `dst`, creating `dst`.
3403/// Symlinks and other non-file/non-dir entries are skipped (rare in state dirs).
3404fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
3405    std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
3406    for entry in
3407        std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
3408    {
3409        let entry = entry.with_context(|| format!("failed to read entry in {}", src.display()))?;
3410        let path = entry.path();
3411        let target = dst.join(entry.file_name());
3412        let file_type = entry
3413            .file_type()
3414            .with_context(|| format!("failed to read file type for {}", path.display()))?;
3415        if file_type.is_dir() {
3416            copy_dir_recursive(&path, &target)?;
3417        } else if file_type.is_file() {
3418            std::fs::copy(&path, &target).with_context(|| {
3419                format!("failed to copy {} -> {}", path.display(), target.display())
3420            })?;
3421        }
3422    }
3423    Ok(())
3424}
3425
3426/// Resolve a project-local state subdirectory, preferring `.codewhale/`
3427/// when it exists, falling back to `.deepseek/` for legacy projects.
3428///
3429/// Returns `(true, path)` when the primary `.codewhale/` path is used,
3430/// `(false, path)` for the legacy fallback. The boolean helps callers
3431/// emit a deprecation notice on legacy paths.
3432pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> Result<(bool, PathBuf)> {
3433    ensure_safe_state_subdir(subdir)?;
3434    let workspace = normalize_project_workspace(workspace)?;
3435    let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
3436    if primary.exists() {
3437        return Ok((true, primary));
3438    }
3439    let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
3440    Ok((false, legacy))
3441}
3442
3443/// Ensure a project-local state subdirectory exists under `.codewhale/`,
3444/// creating it if necessary. Returns the directory path.
3445pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
3446    ensure_safe_state_subdir(subdir)?;
3447    let workspace = normalize_project_workspace(workspace)?;
3448    let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
3449    std::fs::create_dir_all(&dir)
3450        .with_context(|| format!("failed to create {}/", dir.display()))?;
3451    Ok(dir)
3452}
3453
3454pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
3455    if let Some(path) = explicit {
3456        return normalize_config_file_path(path);
3457    }
3458    if let Ok(path) = std::env::var("CODEWHALE_CONFIG_PATH") {
3459        if let Some(path) = config_path_from_env_value(&path)? {
3460            return Ok(path);
3461        }
3462        return default_config_path();
3463    }
3464    if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
3465        if let Some(path) = config_path_from_env_value(&path)? {
3466            return Ok(path);
3467        }
3468        return default_config_path();
3469    }
3470    default_config_path()
3471}
3472
3473fn config_path_from_env_value(path: &str) -> Result<Option<PathBuf>> {
3474    let trimmed = path.trim();
3475    if trimmed.is_empty() {
3476        Ok(None)
3477    } else {
3478        normalize_config_file_path(PathBuf::from(trimmed)).map(Some)
3479    }
3480}
3481
3482#[must_use]
3483pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
3484    config_sibling_path_unchecked(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
3485}
3486
3487fn checked_permissions_path_for_config_path(config_path: &Path) -> Result<PathBuf> {
3488    checked_config_sibling_path(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
3489}
3490
3491pub fn resolve_permissions_path(config_path: Option<PathBuf>) -> Result<PathBuf> {
3492    checked_permissions_path_for_config_path(&resolve_config_path(config_path)?)
3493}
3494
3495fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
3496    let permissions_path = checked_permissions_path_for_config_path(config_path)?;
3497    if !checked_path_exists(&permissions_path)? {
3498        return Ok(PermissionsToml::default());
3499    }
3500
3501    let raw = read_checked_permissions_file(&permissions_path)?;
3502    toml::from_str(&raw).with_context(|| {
3503        format!(
3504            "failed to parse permissions at {}",
3505            permissions_path.display()
3506        )
3507    })
3508}
3509
3510fn append_ask_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
3511    match item {
3512        toml_edit::Item::ArrayOfTables(rules) => {
3513            rules.push(ask_rule_table(rule));
3514            Ok(())
3515        }
3516        toml_edit::Item::Value(value) => {
3517            let Some(rules) = value.as_array_mut() else {
3518                bail!("`rules` in permissions.toml must be an array");
3519            };
3520            rules.push(toml_edit::Value::InlineTable(ask_rule_inline_table(rule)));
3521            Ok(())
3522        }
3523        _ => bail!("`rules` in permissions.toml must be an array"),
3524    }
3525}
3526
3527fn ask_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
3528    let mut table = toml_edit::Table::new();
3529    table["tool"] = toml_edit::value(rule.tool.clone());
3530    if let Some(command) = rule.command.as_deref() {
3531        table["command"] = toml_edit::value(command);
3532    }
3533    if let Some(path) = rule.path.as_deref() {
3534        table["path"] = toml_edit::value(path);
3535    }
3536    table
3537}
3538
3539fn ask_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
3540    let mut table = toml_edit::InlineTable::new();
3541    table.insert("tool", toml_edit::Value::from(rule.tool.clone()));
3542    if let Some(command) = rule.command.as_deref() {
3543        table.insert("command", toml_edit::Value::from(command));
3544    }
3545    if let Some(path) = rule.path.as_deref() {
3546        table.insert("path", toml_edit::Value::from(path));
3547    }
3548    table
3549}
3550
3551fn write_permissions_atomic(path: &Path, body: &[u8]) -> Result<()> {
3552    let parent = path.parent().with_context(|| {
3553        format!(
3554            "permissions path has no parent directory: {}",
3555            path.display()
3556        )
3557    })?;
3558    fs::create_dir_all(parent).with_context(|| {
3559        format!(
3560            "failed to create permissions directory {}",
3561            parent.display()
3562        )
3563    })?;
3564
3565    let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
3566        format!(
3567            "failed to create temporary permissions file in {}",
3568            parent.display()
3569        )
3570    })?;
3571    #[cfg(unix)]
3572    temporary
3573        .as_file()
3574        .set_permissions(fs::Permissions::from_mode(0o600))
3575        .with_context(|| {
3576            format!(
3577                "failed to secure temporary permissions file for {}",
3578                path.display()
3579            )
3580        })?;
3581    temporary
3582        .write_all(body)
3583        .with_context(|| format!("failed to write permissions at {}", path.display()))?;
3584    temporary
3585        .as_file()
3586        .sync_all()
3587        .with_context(|| format!("failed to sync permissions at {}", path.display()))?;
3588    temporary
3589        .persist(path)
3590        .map_err(|error| error.error)
3591        .with_context(|| format!("failed to replace permissions at {}", path.display()))?;
3592    Ok(())
3593}
3594
3595pub fn default_config_path() -> Result<PathBuf> {
3596    // Prefer ~/.codewhale/config.toml when it exists (fresh install or
3597    // migrated), otherwise fall back to ~/.deepseek/config.toml.
3598    let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
3599    if primary.exists() {
3600        return Ok(primary);
3601    }
3602    let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
3603    if legacy.exists() {
3604        return Ok(legacy);
3605    }
3606    // Neither exists — return primary so first write creates it there.
3607    Ok(primary)
3608}
3609
3610#[derive(Debug, Clone, PartialEq, Eq)]
3611pub struct ConfigMigration {
3612    pub legacy_path: PathBuf,
3613    pub primary_path: PathBuf,
3614}
3615
3616impl ConfigMigration {
3617    pub fn user_notice(&self) -> String {
3618        format!(
3619            "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.",
3620            self.legacy_path.display(),
3621            self.primary_path.display()
3622        )
3623    }
3624}
3625
3626/// v0.8.44: one-time migration from `~/.deepseek/config.toml` to
3627/// `~/.codewhale/config.toml`. Called on first launch after the config
3628/// is loaded; copies the legacy file if the primary doesn't exist yet.
3629/// Never overwrites an existing primary config.
3630pub fn migrate_config_if_needed() -> Result<Option<ConfigMigration>> {
3631    let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
3632    if primary.exists() {
3633        return Ok(None);
3634    }
3635    let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
3636    if !legacy.exists() {
3637        return Ok(None);
3638    }
3639    // Copy the config to the new home.
3640    if let Some(parent) = primary.parent() {
3641        std::fs::create_dir_all(parent).context("failed to create codewhale config directory")?;
3642    }
3643    std::fs::copy(&legacy, &primary)
3644        .context("failed to migrate config from deepseek to codewhale home")?;
3645    tracing::info!(
3646        "Migrated config from {} to {}",
3647        legacy.display(),
3648        primary.display()
3649    );
3650    Ok(Some(ConfigMigration {
3651        legacy_path: legacy,
3652        primary_path: primary,
3653    }))
3654}
3655
3656fn parse_bool(raw: &str) -> Result<bool> {
3657    match raw.trim().to_ascii_lowercase().as_str() {
3658        "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
3659        "0" | "false" | "no" | "off" | "disabled" => Ok(false),
3660        _ => bail!("invalid boolean '{raw}'"),
3661    }
3662}
3663
3664fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
3665    let mut headers = BTreeMap::new();
3666    for pair in raw.trim().split(',') {
3667        let pair = pair.trim();
3668        if pair.is_empty() {
3669            continue;
3670        }
3671        let Some((name, value)) = pair.split_once('=') else {
3672            bail!("invalid header pair '{pair}', expected name=value");
3673        };
3674        let name = name.trim();
3675        let value = value.trim();
3676        if name.is_empty() {
3677            bail!("header name cannot be empty");
3678        }
3679        if value.is_empty() {
3680            continue;
3681        }
3682        headers.insert(name.to_string(), value.to_string());
3683    }
3684    Ok(headers)
3685}
3686
3687fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
3688    if headers.is_empty() {
3689        return None;
3690    }
3691    Some(
3692        headers
3693            .iter()
3694            .map(|(name, value)| format!("{name}={value}"))
3695            .collect::<Vec<_>>()
3696            .join(","),
3697    )
3698}
3699
3700fn serialize_http_headers_for_display(headers: &BTreeMap<String, String>) -> Option<String> {
3701    if headers.is_empty() {
3702        return None;
3703    }
3704    Some(
3705        headers
3706            .iter()
3707            .map(|(name, value)| {
3708                let display_value = if is_sensitive_config_key(name) {
3709                    redact_secret(value)
3710                } else {
3711                    value.clone()
3712                };
3713                format!("{name}={display_value}")
3714            })
3715            .collect::<Vec<_>>()
3716            .join(","),
3717    )
3718}
3719
3720fn redact_secret(secret: &str) -> String {
3721    let chars: Vec<char> = secret.chars().collect();
3722    if chars.len() <= 16 {
3723        return "********".to_string();
3724    }
3725    let prefix: String = chars.iter().take(4).collect();
3726    let suffix: String = chars
3727        .iter()
3728        .rev()
3729        .take(4)
3730        .collect::<Vec<_>>()
3731        .into_iter()
3732        .rev()
3733        .collect();
3734    format!("{prefix}***{suffix}")
3735}
3736
3737#[must_use]
3738pub fn is_sensitive_config_key(key: &str) -> bool {
3739    let Some(segment) = key.rsplit('.').next() else {
3740        return false;
3741    };
3742    let normalized = segment
3743        .trim()
3744        .trim_matches('"')
3745        .replace('-', "_")
3746        .to_ascii_lowercase();
3747
3748    matches!(
3749        normalized.as_str(),
3750        "api_key"
3751            | "apikey"
3752            | "api_keys"
3753            | "authorization"
3754            | "bearer"
3755            | "client_secret"
3756            | "credential"
3757            | "credentials"
3758            | "id_token"
3759            | "password"
3760            | "passwords"
3761            | "passwd"
3762            | "proxy_authorization"
3763            | "refresh_token"
3764            | "secret"
3765            | "secrets"
3766            | "token"
3767            | "tokens"
3768    ) || normalized.ends_with("_api_key")
3769        || normalized.ends_with("_authorization")
3770        || normalized.ends_with("_password")
3771        || normalized.ends_with("_secret")
3772        || normalized.ends_with("_token")
3773}
3774
3775fn redact_toml_value_for_display(key: &str, value: &toml::Value) -> String {
3776    redact_toml_value_for_display_inner(key, false, value).to_string()
3777}
3778
3779fn redact_toml_value_for_display_inner(
3780    key: &str,
3781    sensitive_ancestor: bool,
3782    value: &toml::Value,
3783) -> toml::Value {
3784    let sensitive = sensitive_ancestor || is_sensitive_config_key(key);
3785    match value {
3786        toml::Value::String(value) if sensitive => toml::Value::String(redact_secret(value)),
3787        toml::Value::Array(values) => toml::Value::Array(
3788            values
3789                .iter()
3790                .map(|value| redact_toml_value_for_display_inner(key, sensitive, value))
3791                .collect(),
3792        ),
3793        toml::Value::Table(table) => {
3794            let mut redacted = toml::map::Map::new();
3795            for (child_key, child_value) in table {
3796                let path = if key.is_empty() {
3797                    child_key.clone()
3798                } else {
3799                    format!("{key}.{child_key}")
3800                };
3801                redacted.insert(
3802                    child_key.clone(),
3803                    redact_toml_value_for_display_inner(&path, sensitive, child_value),
3804                );
3805            }
3806            toml::Value::Table(redacted)
3807        }
3808        _ if sensitive => toml::Value::String("********".to_string()),
3809        _ => value.clone(),
3810    }
3811}
3812
3813fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
3814    if path.as_os_str().is_empty() {
3815        bail!("config path cannot be empty");
3816    }
3817    if path
3818        .components()
3819        .any(|component| matches!(component, Component::ParentDir))
3820    {
3821        bail!("config path cannot contain '..' components");
3822    }
3823    if path.file_name().is_none() {
3824        bail!("config path must include a file name");
3825    }
3826    let absolute = if path.is_absolute() {
3827        path
3828    } else {
3829        std::env::current_dir()
3830            .context("failed to resolve current directory for config path")?
3831            .join(path)
3832    };
3833    let file_name = absolute
3834        .file_name()
3835        .map(OsString::from)
3836        .context("config path must include a file name")?;
3837    let parent = absolute
3838        .parent()
3839        .context("config path must include a parent directory")?;
3840    let parent = match parent.canonicalize() {
3841        Ok(parent) => parent,
3842        Err(err) if err.kind() == std::io::ErrorKind::NotFound => parent.to_path_buf(),
3843        Err(err) => {
3844            return Err(err).with_context(|| {
3845                format!("failed to resolve config directory {}", parent.display())
3846            });
3847        }
3848    };
3849    let normalized = parent.join(file_name);
3850    reject_path_symlink(&normalized)?;
3851    Ok(normalized)
3852}
3853
3854fn normalize_project_workspace(workspace: &Path) -> Result<PathBuf> {
3855    if workspace.as_os_str().is_empty() {
3856        bail!("project workspace path cannot be empty");
3857    }
3858    if workspace
3859        .components()
3860        .any(|component| matches!(component, Component::ParentDir))
3861    {
3862        bail!("project workspace path cannot contain '..' components");
3863    }
3864    let absolute = if workspace.is_absolute() {
3865        workspace.to_path_buf()
3866    } else {
3867        std::env::current_dir()
3868            .context("failed to resolve current directory for project workspace")?
3869            .join(workspace)
3870    };
3871    match absolute.canonicalize() {
3872        Ok(path) => Ok(path),
3873        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
3874            Ok(normalize_path_components(&absolute))
3875        }
3876        Err(err) => Err(err).with_context(|| {
3877            format!(
3878                "failed to resolve project workspace {}",
3879                workspace.display()
3880            )
3881        }),
3882    }
3883}
3884
3885fn normalize_path_components(path: &Path) -> PathBuf {
3886    let mut normalized = PathBuf::new();
3887    for component in path.components() {
3888        match component {
3889            Component::Prefix(_) | Component::RootDir => normalized.push(component.as_os_str()),
3890            Component::CurDir => {}
3891            Component::ParentDir => {
3892                normalized.pop();
3893            }
3894            Component::Normal(part) => normalized.push(part),
3895        }
3896    }
3897    if normalized.as_os_str().is_empty() {
3898        PathBuf::from(".")
3899    } else {
3900        normalized
3901    }
3902}
3903
3904fn checked_path_exists(path: &Path) -> Result<bool> {
3905    let path = normalize_config_file_path(path.to_path_buf())?;
3906    path.try_exists()
3907        .with_context(|| format!("failed to inspect config path {}", path.display()))
3908}
3909
3910fn read_checked_config_file(path: &Path) -> Result<String> {
3911    read_checked_toml_file(path, "config")
3912}
3913
3914fn read_checked_permissions_file(path: &Path) -> Result<String> {
3915    read_checked_toml_file(path, "permissions")
3916}
3917
3918fn read_checked_toml_file(path: &Path, label: &str) -> Result<String> {
3919    let path = normalize_config_file_path(path.to_path_buf())?;
3920    read_string_no_follow(&path)
3921        .with_context(|| format!("failed to read {label} at {}", path.display()))
3922}
3923
3924#[cfg(unix)]
3925fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
3926    let mut file = fs::OpenOptions::new()
3927        .read(true)
3928        .custom_flags(libc::O_NOFOLLOW)
3929        .open(path)?;
3930    let mut raw = String::new();
3931    file.read_to_string(&mut raw)?;
3932    Ok(raw)
3933}
3934
3935#[cfg(not(unix))]
3936fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
3937    fs::read_to_string(path)
3938}
3939
3940fn reject_path_symlink(path: &Path) -> Result<()> {
3941    let Ok(metadata) = fs::symlink_metadata(path) else {
3942        return Ok(());
3943    };
3944    if metadata.file_type().is_symlink() {
3945        bail!("config path must not be a symlink: {}", path.display());
3946    }
3947    Ok(())
3948}
3949
3950#[derive(Debug, Clone, Default)]
3951struct EnvRuntimeOverrides {
3952    provider: Option<ProviderKind>,
3953    provider_source: Option<&'static str>,
3954    model: Option<String>,
3955    volcengine_model: Option<String>,
3956    wanjie_ark_model: Option<String>,
3957    openrouter_model: Option<String>,
3958    moonshot_model: Option<String>,
3959    xiaomi_mimo_model: Option<String>,
3960    xiaomi_mimo_mode: Option<String>,
3961    novita_model: Option<String>,
3962    fireworks_model: Option<String>,
3963    arcee_model: Option<String>,
3964    output_mode: Option<String>,
3965    auth_mode: Option<String>,
3966    log_level: Option<String>,
3967    telemetry: Option<bool>,
3968    approval_policy: Option<String>,
3969    sandbox_mode: Option<String>,
3970    yolo: Option<bool>,
3971    verbosity: Option<String>,
3972    http_headers: Option<BTreeMap<String, String>>,
3973    deepseek_base_url: Option<String>,
3974    nvidia_base_url: Option<String>,
3975    openai_base_url: Option<String>,
3976    atlascloud_base_url: Option<String>,
3977    volcengine_base_url: Option<String>,
3978    wanjie_ark_base_url: Option<String>,
3979    openrouter_base_url: Option<String>,
3980    xiaomi_mimo_base_url: Option<String>,
3981    novita_base_url: Option<String>,
3982    fireworks_base_url: Option<String>,
3983    siliconflow_base_url: Option<String>,
3984    siliconflow_model: Option<String>,
3985    arcee_base_url: Option<String>,
3986    moonshot_base_url: Option<String>,
3987    sglang_base_url: Option<String>,
3988    vllm_base_url: Option<String>,
3989    ollama_base_url: Option<String>,
3990    huggingface_base_url: Option<String>,
3991    huggingface_model: Option<String>,
3992    together_base_url: Option<String>,
3993    together_model: Option<String>,
3994    openai_codex_base_url: Option<String>,
3995    openai_codex_model: Option<String>,
3996    anthropic_base_url: Option<String>,
3997    anthropic_model: Option<String>,
3998    zai_base_url: Option<String>,
3999    zai_model: Option<String>,
4000    stepfun_base_url: Option<String>,
4001    stepfun_model: Option<String>,
4002    minimax_base_url: Option<String>,
4003    minimax_model: Option<String>,
4004    deepinfra_base_url: Option<String>,
4005    deepinfra_model: Option<String>,
4006}
4007
4008impl EnvRuntimeOverrides {
4009    fn load() -> Self {
4010        let (provider, provider_source) = Self::load_provider();
4011        Self {
4012            provider,
4013            provider_source,
4014            model: std::env::var("CODEWHALE_MODEL")
4015                .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
4016                .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
4017                .ok()
4018                .filter(|v| !v.trim().is_empty()),
4019            volcengine_model: std::env::var("VOLCENGINE_MODEL")
4020                .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
4021                .ok()
4022                .filter(|v| !v.trim().is_empty()),
4023            wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
4024                .or_else(|_| std::env::var("WANJIE_MODEL"))
4025                .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
4026                .ok()
4027                .filter(|v| !v.trim().is_empty()),
4028            openrouter_model: std::env::var("OPENROUTER_MODEL")
4029                .ok()
4030                .filter(|v| !v.trim().is_empty()),
4031            moonshot_model: std::env::var("MOONSHOT_MODEL")
4032                .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
4033                .or_else(|_| std::env::var("KIMI_MODEL"))
4034                .ok()
4035                .filter(|v| !v.trim().is_empty()),
4036            xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
4037                .or_else(|_| std::env::var("MIMO_MODEL"))
4038                .ok()
4039                .filter(|v| !v.trim().is_empty()),
4040            xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
4041                .or_else(|_| std::env::var("MIMO_MODE"))
4042                .ok()
4043                .filter(|v| !v.trim().is_empty()),
4044            novita_model: std::env::var("NOVITA_MODEL")
4045                .ok()
4046                .filter(|v| !v.trim().is_empty()),
4047            fireworks_model: std::env::var("FIREWORKS_MODEL")
4048                .ok()
4049                .filter(|v| !v.trim().is_empty()),
4050            arcee_model: std::env::var("ARCEE_MODEL")
4051                .ok()
4052                .filter(|v| !v.trim().is_empty()),
4053            verbosity: std::env::var("CODEWHALE_VERBOSITY")
4054                .or_else(|_| std::env::var("DEEPSEEK_VERBOSITY"))
4055                .ok(),
4056            output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(),
4057            auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(),
4058            log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(),
4059            telemetry: std::env::var("DEEPSEEK_TELEMETRY")
4060                .ok()
4061                .and_then(|v| match parse_bool(&v) {
4062                    Ok(b) => Some(b),
4063                    Err(_) => {
4064                        tracing::warn!("Invalid DEEPSEEK_TELEMETRY value '{v}', expected true/false");
4065                        None
4066                    }
4067                }),
4068            approval_policy: std::env::var("DEEPSEEK_APPROVAL_POLICY").ok(),
4069            sandbox_mode: std::env::var("DEEPSEEK_SANDBOX_MODE").ok(),
4070            yolo: std::env::var("DEEPSEEK_YOLO")
4071                .ok()
4072                .and_then(|v| match parse_bool(&v) {
4073                    Ok(b) => Some(b),
4074                    Err(_) => {
4075                        tracing::warn!("Invalid DEEPSEEK_YOLO value '{v}', expected true/false");
4076                        None
4077                    }
4078                }),
4079            http_headers: std::env::var("DEEPSEEK_HTTP_HEADERS")
4080                .ok()
4081                .and_then(|value| match parse_http_headers(&value) {
4082                    Ok(h) => Some(h),
4083                    Err(_) => {
4084                        tracing::warn!("Invalid DEEPSEEK_HTTP_HEADERS value, expected format: header1=val1,header2=val2");
4085                        None
4086                    }
4087                })
4088                .filter(|headers| !headers.is_empty()),
4089            deepseek_base_url: std::env::var("CODEWHALE_BASE_URL")
4090                .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
4091                .ok()
4092                .filter(|v| !v.trim().is_empty()),
4093            nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
4094                .or_else(|_| std::env::var("NIM_BASE_URL"))
4095                .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
4096                .ok()
4097                .filter(|v| !v.trim().is_empty()),
4098            openai_base_url: std::env::var("OPENAI_BASE_URL")
4099                .ok()
4100                .filter(|v| !v.trim().is_empty()),
4101            atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
4102                .ok()
4103                .filter(|v| !v.trim().is_empty()),
4104            volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
4105                .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
4106                .or_else(|_| std::env::var("ARK_BASE_URL"))
4107                .ok()
4108                .filter(|v| !v.trim().is_empty()),
4109            wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
4110                .or_else(|_| std::env::var("WANJIE_BASE_URL"))
4111                .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
4112                .ok()
4113                .filter(|v| !v.trim().is_empty()),
4114            openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
4115                .ok()
4116                .filter(|v| !v.trim().is_empty()),
4117            xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
4118                .or_else(|_| std::env::var("MIMO_BASE_URL"))
4119                .ok()
4120                .filter(|v| !v.trim().is_empty()),
4121            novita_base_url: std::env::var("NOVITA_BASE_URL")
4122                .ok()
4123                .filter(|v| !v.trim().is_empty()),
4124            fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
4125                .ok()
4126                .filter(|v| !v.trim().is_empty()),
4127            siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
4128                .ok()
4129                .filter(|v| !v.trim().is_empty()),
4130            siliconflow_model: std::env::var("SILICONFLOW_MODEL")
4131                .ok()
4132                .filter(|v| !v.trim().is_empty()),
4133            arcee_base_url: std::env::var("ARCEE_BASE_URL")
4134                .ok()
4135                .filter(|v| !v.trim().is_empty()),
4136            moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
4137                .or_else(|_| std::env::var("KIMI_BASE_URL"))
4138                .ok()
4139                .filter(|v| !v.trim().is_empty()),
4140            sglang_base_url: std::env::var("SGLANG_BASE_URL")
4141                .ok()
4142                .filter(|v| !v.trim().is_empty()),
4143            vllm_base_url: std::env::var("VLLM_BASE_URL")
4144                .ok()
4145                .filter(|v| !v.trim().is_empty()),
4146            ollama_base_url: std::env::var("OLLAMA_BASE_URL")
4147                .ok()
4148                .filter(|v| !v.trim().is_empty()),
4149            huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
4150                .or_else(|_| std::env::var("HF_BASE_URL"))
4151                .ok()
4152                .filter(|v| !v.trim().is_empty()),
4153            huggingface_model: std::env::var("HUGGINGFACE_MODEL")
4154                .or_else(|_| std::env::var("HF_MODEL"))
4155                .ok()
4156                .filter(|v| !v.trim().is_empty()),
4157            together_base_url: std::env::var("TOGETHER_BASE_URL")
4158                .ok()
4159                .filter(|v| !v.trim().is_empty()),
4160            together_model: std::env::var("TOGETHER_MODEL")
4161                .ok()
4162                .filter(|v| !v.trim().is_empty()),
4163            openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
4164                .or_else(|_| std::env::var("CODEX_BASE_URL"))
4165                .ok()
4166                .filter(|v| !v.trim().is_empty()),
4167            openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
4168                .or_else(|_| std::env::var("CODEX_MODEL"))
4169                .ok()
4170                .filter(|v| !v.trim().is_empty()),
4171            anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
4172                .ok()
4173                .filter(|v| !v.trim().is_empty()),
4174            anthropic_model: std::env::var("ANTHROPIC_MODEL")
4175                .ok()
4176                .filter(|v| !v.trim().is_empty()),
4177            zai_base_url: std::env::var("ZAI_BASE_URL")
4178                .or_else(|_| std::env::var("Z_AI_BASE_URL"))
4179                .ok()
4180                .filter(|v| !v.trim().is_empty()),
4181            zai_model: std::env::var("ZAI_MODEL")
4182                .or_else(|_| std::env::var("Z_AI_MODEL"))
4183                .ok()
4184                .filter(|v| !v.trim().is_empty()),
4185            stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
4186                .or_else(|_| std::env::var("STEP_BASE_URL"))
4187                .ok()
4188                .filter(|v| !v.trim().is_empty()),
4189            stepfun_model: std::env::var("STEPFUN_MODEL")
4190                .or_else(|_| std::env::var("STEP_MODEL"))
4191                .ok()
4192                .filter(|v| !v.trim().is_empty()),
4193            minimax_base_url: std::env::var("MINIMAX_BASE_URL")
4194                .ok()
4195                .filter(|v| !v.trim().is_empty()),
4196            minimax_model: std::env::var("MINIMAX_MODEL")
4197                .ok()
4198                .filter(|v| !v.trim().is_empty()),
4199            deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
4200                .ok()
4201                .filter(|v| !v.trim().is_empty()),
4202            deepinfra_model: std::env::var("DEEPINFRA_MODEL")
4203                .ok()
4204                .filter(|v| !v.trim().is_empty()),
4205        }
4206    }
4207
4208    fn load_provider() -> (Option<ProviderKind>, Option<&'static str>) {
4209        if let Ok(value) = std::env::var("CODEWHALE_PROVIDER") {
4210            let parsed = ProviderKind::parse(&value);
4211            return (parsed, parsed.map(|_| "CODEWHALE_PROVIDER"));
4212        }
4213
4214        if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
4215            let parsed = ProviderKind::parse(&value);
4216            return (parsed, parsed.map(|_| "DEEPSEEK_PROVIDER"));
4217        }
4218
4219        (None, None)
4220    }
4221
4222    fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
4223        // Defaults belong in the resolver's final fallback so config-file
4224        // values (`providers.<name>.base_url`) still win when env is unset.
4225        match provider {
4226            ProviderKind::Deepseek => self.deepseek_base_url.clone(),
4227            ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
4228            ProviderKind::Openai => self.openai_base_url.clone(),
4229            ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
4230            ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
4231            ProviderKind::Volcengine => self.volcengine_base_url.clone(),
4232            ProviderKind::Openrouter => self.openrouter_base_url.clone(),
4233            ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
4234            ProviderKind::Novita => self.novita_base_url.clone(),
4235            ProviderKind::Fireworks => self.fireworks_base_url.clone(),
4236            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
4237                self.siliconflow_base_url.clone()
4238            }
4239            ProviderKind::Arcee => self.arcee_base_url.clone(),
4240            ProviderKind::Moonshot => self.moonshot_base_url.clone(),
4241            ProviderKind::Sglang => self.sglang_base_url.clone(),
4242            ProviderKind::Vllm => self.vllm_base_url.clone(),
4243            ProviderKind::Ollama => self.ollama_base_url.clone(),
4244            ProviderKind::Huggingface => self.huggingface_base_url.clone(),
4245            ProviderKind::Together => self.together_base_url.clone(),
4246            ProviderKind::OpenaiCodex => self.openai_codex_base_url.clone(),
4247            ProviderKind::Anthropic => self.anthropic_base_url.clone(),
4248            ProviderKind::Zai => self.zai_base_url.clone(),
4249            ProviderKind::Stepfun => self.stepfun_base_url.clone(),
4250            ProviderKind::Minimax => self.minimax_base_url.clone(),
4251            ProviderKind::Deepinfra => self.deepinfra_base_url.clone(),
4252        }
4253    }
4254
4255    fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
4256        let model = match provider {
4257            ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
4258            ProviderKind::Volcengine => self.volcengine_model.clone(),
4259            ProviderKind::Openrouter => self.openrouter_model.clone(),
4260            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
4261                self.siliconflow_model.clone()
4262            }
4263            ProviderKind::Arcee => self.arcee_model.clone(),
4264            ProviderKind::Moonshot => self.moonshot_model.clone(),
4265            ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
4266            ProviderKind::Novita => self.novita_model.clone(),
4267            ProviderKind::Fireworks => self.fireworks_model.clone(),
4268            ProviderKind::Huggingface => self.huggingface_model.clone(),
4269            ProviderKind::Together => self.together_model.clone(),
4270            ProviderKind::OpenaiCodex => self.openai_codex_model.clone(),
4271            ProviderKind::Anthropic => self.anthropic_model.clone(),
4272            ProviderKind::Zai => self.zai_model.clone(),
4273            ProviderKind::Stepfun => self.stepfun_model.clone(),
4274            ProviderKind::Minimax => self.minimax_model.clone(),
4275            ProviderKind::Deepinfra => self.deepinfra_model.clone(),
4276            _ => None,
4277        }?;
4278
4279        if provider_preserves_custom_base_url_model(provider, base_url) {
4280            Some(model.trim().to_string())
4281        } else {
4282            Some(normalize_model_for_provider(provider, &model))
4283        }
4284    }
4285}
4286
4287#[cfg(test)]
4288mod tests;