Skip to main content

codewhale_config/
lib.rs

1pub mod auth_source;
2pub mod catalog;
3mod config_document;
4pub mod external_credentials;
5mod harness;
6pub mod model_reference;
7pub mod models_dev;
8pub mod persistence;
9pub mod pricing;
10pub mod provider;
11mod provider_defaults;
12mod provider_kind;
13pub mod route;
14pub mod setup_state;
15pub mod user_constitution;
16mod xai_credentials;
17pub use config_document::{
18    create_config_document, mutate_config_document, replace_config_document_if_unchanged,
19    set_config_document_value, unset_config_document_value,
20};
21pub use harness::{
22    HarnessCompactionStrategy, HarnessPosture, HarnessPostureKind, HarnessProfile,
23    HarnessSafetyPosture, HarnessToolSurface, built_in_harness_profiles,
24};
25pub use model_reference::{Modality, ModelReferenceCard, ModelReferenceDatabase};
26pub(crate) use provider_defaults::*;
27pub use provider_kind::ProviderKind;
28pub use setup_state::{
29    ConstitutionAuthoring, ConstitutionChoice, ConstitutionSource, ConstitutionValidity,
30    InheritedConfigFacts, RuntimePostureSource, SetupState, SetupStep, StepEntry, StepStatus,
31};
32pub use user_constitution::{
33    AutonomyPreference, UntrustedDraftParse, UserConstitution, UserConstitutionLoad,
34};
35pub use xai_credentials::{
36    LEGACY_XAI_OAUTH_FILE_NAME, XAI_OAUTH_GENERATION_PREFIX, XAI_OAUTH_GENERATION_SUFFIX,
37    XaiOAuthCredentialStore, XaiOAuthRevocation, clear_all_xai_oauth_credentials,
38    is_valid_xai_oauth_generation, legacy_xai_oauth_path, remove_xai_oauth_generation,
39    validate_xai_oauth_generation, with_xai_oauth_lifecycle_lock,
40    with_xai_oauth_revocation_transaction, xai_oauth_credentials_dir, xai_oauth_generation_path,
41};
42
43use std::collections::{BTreeMap, BTreeSet};
44use std::ffi::{OsStr, OsString};
45use std::fmt;
46use std::fs;
47#[cfg(unix)]
48use std::io::Read;
49use std::io::Write;
50use std::path::{Component, Path, PathBuf};
51use std::sync::OnceLock;
52
53use anyhow::{Context, Result, bail};
54pub use auth_source::{AuthSourceKind, ProviderAuthSourceToml};
55pub use codewhale_execpolicy::ToolAskRule;
56use codewhale_execpolicy::{ExecPolicyEngine, Ruleset};
57use codewhale_secrets::SecretSource;
58pub use codewhale_secrets::Secrets;
59pub use external_credentials::{
60    EXTERNAL_CREDENTIAL_CONSENT_VERSION, EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS,
61    ExternalCredentialAccess, ExternalCredentialConsentStatus, ExternalCredentialConsentToml,
62    ExternalCredentialReadGrant, ExternalCredentialSource, external_credential_consent_status,
63    quote_os_path, resolve_external_credential_path,
64};
65use serde::{Deserialize, Serialize};
66
67#[cfg(unix)]
68use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
69
70pub const CONFIG_FILE_NAME: &str = "config.toml";
71pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml";
72
73fn http_headers_are_effectively_empty(headers: &BTreeMap<String, String>) -> bool {
74    !headers
75        .iter()
76        .any(|(name, value)| !name.trim().is_empty() && !value.trim().is_empty())
77}
78
79/// Whether an HTTP header can carry the model provider's primary credential.
80///
81/// Header names are case-insensitive. Keeping this classifier in shared config
82/// prevents `auth_mode = "none"` from disabling a generated bearer token while
83/// still leaking the same credential through a configured alternate dialect.
84#[must_use]
85pub fn is_upstream_auth_header(name: &str) -> bool {
86    let name = name.trim();
87    // Configured gateways use more credential dialects than the three headers
88    // generated by Codewhale itself. `auth_mode = "none"` is an endpoint
89    // contract, so suppress every credential-shaped request header instead of
90    // allowing the same secret through Proxy-Authorization, X-Auth-Token,
91    // X-Access-Token, X-Goog-Api-Key, or another *-token/*-api-key spelling.
92    is_sensitive_config_key(name) || name.eq_ignore_ascii_case("cookie")
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, Default)]
96pub struct ProviderConfigToml {
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub api_key: Option<String>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub base_url: Option<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub model: Option<String>,
103    #[serde(
104        default,
105        skip_serializing_if = "Option::is_none",
106        alias = "contextWindow",
107        alias = "context_window_tokens",
108        alias = "contextWindowTokens",
109        alias = "context_length",
110        alias = "contextLength"
111    )]
112    pub context_window: Option<u32>,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub mode: Option<String>,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub auth_mode: Option<String>,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub insecure_skip_tls_verify: Option<bool>,
119    #[serde(default, skip_serializing_if = "http_headers_are_effectively_empty")]
120    pub http_headers: BTreeMap<String, String>,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub path_suffix: Option<String>,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub auth: Option<ProviderAuthSourceToml>,
125    /// Explicit consent for reading one exact credential file owned by
126    /// another CLI. Absence means disabled and must not trigger discovery.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub external_credentials: Option<ExternalCredentialConsentToml>,
129    /// Codewhale-owned xAI OAuth generation selected by config. The value is a
130    /// validated basename under `$CODEWHALE_HOME/credentials`, never an
131    /// arbitrary path.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub oauth_credential_generation: Option<String>,
134    /// Preserve provider fields introduced by newer Codewhale versions and by
135    /// custom provider adapters when an older typed writer saves this file.
136    #[serde(flatten)]
137    pub extras: BTreeMap<String, toml::Value>,
138}
139
140impl ProviderConfigToml {
141    #[must_use]
142    pub fn is_empty(&self) -> bool {
143        let blank = |value: Option<&String>| value.is_none_or(|value| value.trim().is_empty());
144
145        blank(self.api_key.as_ref())
146            && blank(self.base_url.as_ref())
147            && blank(self.model.as_ref())
148            && self.context_window.is_none()
149            && blank(self.mode.as_ref())
150            && blank(self.auth_mode.as_ref())
151            && self.insecure_skip_tls_verify.is_none()
152            && http_headers_are_effectively_empty(&self.http_headers)
153            && blank(self.path_suffix.as_ref())
154            && self.auth.is_none()
155            && self.external_credentials.is_none()
156            && self.oauth_credential_generation.is_none()
157            && self.extras.is_empty()
158    }
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, Default)]
162pub struct ProvidersToml {
163    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
164    pub deepseek: ProviderConfigToml,
165    #[serde(
166        default,
167        skip_serializing_if = "ProviderConfigToml::is_empty",
168        alias = "deepseek-anthropic",
169        alias = "deepseekAnthropic",
170        alias = "deepseek-claude",
171        alias = "deepseek_claude"
172    )]
173    pub deepseek_anthropic: ProviderConfigToml,
174    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
175    pub nvidia_nim: ProviderConfigToml,
176    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
177    pub openai: ProviderConfigToml,
178    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
179    pub atlascloud: ProviderConfigToml,
180    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
181    pub wanjie_ark: ProviderConfigToml,
182    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
183    pub volcengine: ProviderConfigToml,
184    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
185    pub openrouter: ProviderConfigToml,
186    #[serde(
187        default,
188        skip_serializing_if = "ProviderConfigToml::is_empty",
189        alias = "xiaomi",
190        alias = "mimo",
191        alias = "xiaomimimo"
192    )]
193    pub xiaomi_mimo: ProviderConfigToml,
194    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
195    pub novita: ProviderConfigToml,
196    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
197    pub fireworks: ProviderConfigToml,
198    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
199    pub siliconflow: ProviderConfigToml,
200    #[serde(
201        default,
202        skip_serializing_if = "ProviderConfigToml::is_empty",
203        alias = "siliconflow-CN",
204        alias = "siliconflow-cn"
205    )]
206    pub siliconflow_cn: ProviderConfigToml,
207    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
208    pub arcee: ProviderConfigToml,
209    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
210    pub moonshot: ProviderConfigToml,
211    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
212    pub sglang: ProviderConfigToml,
213    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
214    pub vllm: ProviderConfigToml,
215    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
216    pub ollama: ProviderConfigToml,
217    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
218    pub huggingface: ProviderConfigToml,
219    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
220    pub together: ProviderConfigToml,
221    #[serde(
222        default,
223        skip_serializing_if = "ProviderConfigToml::is_empty",
224        alias = "baidu-qianfan",
225        alias = "baidu_qianfan",
226        alias = "baidu"
227    )]
228    pub qianfan: ProviderConfigToml,
229    #[serde(
230        default,
231        skip_serializing_if = "ProviderConfigToml::is_empty",
232        alias = "openai-codex",
233        alias = "openai_codex",
234        alias = "codex",
235        alias = "chatgpt",
236        alias = "chatgpt-codex"
237    )]
238    pub openai_codex: ProviderConfigToml,
239    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
240    pub anthropic: ProviderConfigToml,
241    #[serde(
242        default,
243        skip_serializing_if = "ProviderConfigToml::is_empty",
244        alias = "open-model",
245        alias = "open_model"
246    )]
247    pub openmodel: ProviderConfigToml,
248    #[serde(
249        default,
250        skip_serializing_if = "ProviderConfigToml::is_empty",
251        alias = "z-ai",
252        alias = "z_ai",
253        alias = "z.ai",
254        alias = "zhipu",
255        alias = "zhipuai",
256        alias = "bigmodel",
257        alias = "big-model"
258    )]
259    pub zai: ProviderConfigToml,
260    #[serde(
261        default,
262        skip_serializing_if = "ProviderConfigToml::is_empty",
263        alias = "step-fun",
264        alias = "step_fun",
265        alias = "stepfun",
266        alias = "stepflash",
267        alias = "step-flash",
268        alias = "step_flash"
269    )]
270    pub stepfun: ProviderConfigToml,
271    #[serde(
272        default,
273        skip_serializing_if = "ProviderConfigToml::is_empty",
274        alias = "mini-max",
275        alias = "mini_max",
276        alias = "minimax"
277    )]
278    pub minimax: ProviderConfigToml,
279    #[serde(
280        default,
281        skip_serializing_if = "ProviderConfigToml::is_empty",
282        alias = "minimax-anthropic",
283        alias = "minimaxAnthropic",
284        alias = "mini-max-anthropic",
285        alias = "mini_max_anthropic"
286    )]
287    pub minimax_anthropic: ProviderConfigToml,
288    #[serde(
289        default,
290        skip_serializing_if = "ProviderConfigToml::is_empty",
291        alias = "deep-infra",
292        alias = "deep_infra"
293    )]
294    pub deepinfra: ProviderConfigToml,
295    #[serde(
296        default,
297        skip_serializing_if = "ProviderConfigToml::is_empty",
298        alias = "sakana-ai",
299        alias = "sakana_ai",
300        alias = "fugu"
301    )]
302    pub sakana: ProviderConfigToml,
303    #[serde(
304        default,
305        skip_serializing_if = "ProviderConfigToml::is_empty",
306        alias = "long-cat",
307        alias = "meituan-longcat",
308        alias = "meituan"
309    )]
310    pub longcat: ProviderConfigToml,
311    #[serde(
312        default,
313        skip_serializing_if = "ProviderConfigToml::is_empty",
314        alias = "opencode-go",
315        alias = "opencodego"
316    )]
317    pub opencode_go: ProviderConfigToml,
318    #[serde(
319        default,
320        skip_serializing_if = "ProviderConfigToml::is_empty",
321        alias = "meta-ai",
322        alias = "meta_ai",
323        alias = "meta-model-api",
324        alias = "meta_model_api",
325        alias = "muse",
326        alias = "muse-spark"
327    )]
328    pub meta: ProviderConfigToml,
329    #[serde(
330        default,
331        skip_serializing_if = "ProviderConfigToml::is_empty",
332        alias = "x-ai",
333        alias = "x_ai",
334        alias = "grok"
335    )]
336    pub xai: ProviderConfigToml,
337    /// Jiangsu Telecom TokenHub — OpenAI-compatible AI gateway.
338    #[serde(
339        default,
340        skip_serializing_if = "ProviderConfigToml::is_empty",
341        alias = "telecom-js",
342        alias = "telecom_js",
343        alias = "telecomjs-cn",
344        alias = "tokenhub"
345    )]
346    pub telecomjs: ProviderConfigToml,
347    /// Catch-all table for the dynamic OpenAI-compatible custom provider
348    /// identity (#1519). Arbitrary `[providers.<name>]` tables are handled by
349    /// the tui-side flatten map; this named slot keeps the canonical
350    /// `ProviderKind::Custom` lookups total without leaking into another
351    /// provider's config.
352    #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
353    pub custom: ProviderConfigToml,
354    /// Preserve dynamically named provider tables and providers added by a
355    /// newer Codewhale version.
356    #[serde(flatten)]
357    pub extras: BTreeMap<String, toml::Value>,
358}
359
360/// Sibling `permissions.toml` schema.
361///
362/// Each rule is a typed condition that can deny, allow, or ask before a tool
363/// invocation. UI actions that persist deny/allow rules are future work; the
364/// approval card still saves ask rules.
365#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
366#[serde(deny_unknown_fields)]
367pub struct PermissionsToml {
368    #[serde(default, skip_serializing_if = "Vec::is_empty")]
369    pub rules: Vec<ToolAskRule>,
370}
371
372impl PermissionsToml {
373    #[must_use]
374    pub fn is_empty(&self) -> bool {
375        self.rules.is_empty()
376    }
377
378    #[must_use]
379    pub fn ruleset(&self) -> Ruleset {
380        use codewhale_execpolicy::PermissionAction;
381        let mut denied = Vec::new();
382        let mut trusted = Vec::new();
383        let mut ask_rules = Vec::new();
384
385        for rule in &self.rules {
386            match rule.action {
387                PermissionAction::Deny => {
388                    // Command-based deny rules are promoted to denied_prefixes
389                    // so they are caught by execpolicy's deny-always-wins check.
390                    if let Some(cmd) = &rule.command {
391                        denied.push(cmd.clone());
392                    }
393                    // Always keep in ask_rules for path-based and tool-only matching.
394                    ask_rules.push(rule.clone());
395                }
396                PermissionAction::Allow => {
397                    // Command-based allow rules are promoted to trusted_prefixes
398                    // for arity-aware matching.  Path-only allow rules are
399                    // handled through ask_rules (they skip the approval prompt).
400                    if let Some(cmd) = &rule.command {
401                        trusted.push(cmd.clone());
402                    }
403                    // Keep in ask_rules so path-only allow rules also work.
404                    ask_rules.push(rule.clone());
405                }
406                PermissionAction::Ask => {
407                    ask_rules.push(rule.clone());
408                }
409            }
410        }
411
412        Ruleset::user(trusted, denied).with_ask_rules(ask_rules)
413    }
414}
415
416impl ProvidersToml {
417    #[must_use]
418    pub fn is_empty(&self) -> bool {
419        self.extras.is_empty()
420            && ProviderKind::all()
421                .iter()
422                .all(|provider| self.for_provider(*provider).is_empty())
423    }
424
425    #[must_use]
426    pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
427        match provider {
428            ProviderKind::Deepseek => &self.deepseek,
429            ProviderKind::DeepseekAnthropic => &self.deepseek_anthropic,
430            ProviderKind::NvidiaNim => &self.nvidia_nim,
431            ProviderKind::Openai => &self.openai,
432            ProviderKind::Atlascloud => &self.atlascloud,
433            ProviderKind::WanjieArk => &self.wanjie_ark,
434            ProviderKind::Volcengine => &self.volcengine,
435            ProviderKind::Openrouter => &self.openrouter,
436            ProviderKind::XiaomiMimo => &self.xiaomi_mimo,
437            ProviderKind::Novita => &self.novita,
438            ProviderKind::Fireworks => &self.fireworks,
439            ProviderKind::Siliconflow => &self.siliconflow,
440            ProviderKind::SiliconflowCN => &self.siliconflow_cn,
441            ProviderKind::Arcee => &self.arcee,
442            ProviderKind::Moonshot => &self.moonshot,
443            ProviderKind::Sglang => &self.sglang,
444            ProviderKind::Vllm => &self.vllm,
445            ProviderKind::Ollama => &self.ollama,
446            ProviderKind::Huggingface => &self.huggingface,
447            ProviderKind::Together => &self.together,
448            ProviderKind::Qianfan => &self.qianfan,
449            ProviderKind::OpenaiCodex => &self.openai_codex,
450            ProviderKind::Anthropic => &self.anthropic,
451            ProviderKind::Openmodel => &self.openmodel,
452            ProviderKind::Zai => &self.zai,
453            ProviderKind::Stepfun => &self.stepfun,
454            ProviderKind::Minimax => &self.minimax,
455            ProviderKind::MinimaxAnthropic => &self.minimax_anthropic,
456            ProviderKind::Deepinfra => &self.deepinfra,
457            ProviderKind::Sakana => &self.sakana,
458            ProviderKind::LongCat => &self.longcat,
459            ProviderKind::OpencodeGo => &self.opencode_go,
460            ProviderKind::Meta => &self.meta,
461            ProviderKind::Xai => &self.xai,
462            ProviderKind::Telecomjs => &self.telecomjs,
463            ProviderKind::Custom => &self.custom,
464        }
465    }
466
467    pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
468        match provider {
469            ProviderKind::Deepseek => &mut self.deepseek,
470            ProviderKind::DeepseekAnthropic => &mut self.deepseek_anthropic,
471            ProviderKind::NvidiaNim => &mut self.nvidia_nim,
472            ProviderKind::Openai => &mut self.openai,
473            ProviderKind::Atlascloud => &mut self.atlascloud,
474            ProviderKind::WanjieArk => &mut self.wanjie_ark,
475            ProviderKind::Volcengine => &mut self.volcengine,
476            ProviderKind::Openrouter => &mut self.openrouter,
477            ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo,
478            ProviderKind::Novita => &mut self.novita,
479            ProviderKind::Fireworks => &mut self.fireworks,
480            ProviderKind::Siliconflow => &mut self.siliconflow,
481            ProviderKind::SiliconflowCN => &mut self.siliconflow_cn,
482            ProviderKind::Arcee => &mut self.arcee,
483            ProviderKind::Moonshot => &mut self.moonshot,
484            ProviderKind::Sglang => &mut self.sglang,
485            ProviderKind::Vllm => &mut self.vllm,
486            ProviderKind::Ollama => &mut self.ollama,
487            ProviderKind::Huggingface => &mut self.huggingface,
488            ProviderKind::Together => &mut self.together,
489            ProviderKind::Qianfan => &mut self.qianfan,
490            ProviderKind::OpenaiCodex => &mut self.openai_codex,
491            ProviderKind::Anthropic => &mut self.anthropic,
492            ProviderKind::Openmodel => &mut self.openmodel,
493            ProviderKind::Zai => &mut self.zai,
494            ProviderKind::Stepfun => &mut self.stepfun,
495            ProviderKind::Minimax => &mut self.minimax,
496            ProviderKind::MinimaxAnthropic => &mut self.minimax_anthropic,
497            ProviderKind::Deepinfra => &mut self.deepinfra,
498            ProviderKind::Sakana => &mut self.sakana,
499            ProviderKind::LongCat => &mut self.longcat,
500            ProviderKind::OpencodeGo => &mut self.opencode_go,
501            ProviderKind::Meta => &mut self.meta,
502            ProviderKind::Xai => &mut self.xai,
503            ProviderKind::Telecomjs => &mut self.telecomjs,
504            ProviderKind::Custom => &mut self.custom,
505        }
506    }
507}
508
509fn deserialize_root_provider<'de, D>(deserializer: D) -> std::result::Result<ProviderKind, D::Error>
510where
511    D: serde::Deserializer<'de>,
512{
513    let value = String::deserialize(deserializer)?;
514    let strict = serde::de::value::StringDeserializer::<D::Error>::new(value);
515    Ok(ProviderKind::deserialize(strict).unwrap_or(ProviderKind::Custom))
516}
517
518#[derive(Debug, Clone, Serialize, Deserialize, Default)]
519pub struct ConfigToml {
520    /// TUI-compatible DeepSeek API key. Kept at the root so both `deepseek`
521    /// and `codewhale-tui` can share a single config file.
522    pub api_key: Option<String>,
523    /// TUI-compatible DeepSeek base URL.
524    pub base_url: Option<String>,
525    /// Optional extra HTTP headers forwarded to model API requests.
526    #[serde(default, skip_serializing_if = "http_headers_are_effectively_empty")]
527    pub http_headers: BTreeMap<String, String>,
528    /// TUI-compatible default DeepSeek model.
529    pub default_text_model: Option<String>,
530    #[serde(default, deserialize_with = "deserialize_root_provider")]
531    pub provider: ProviderKind,
532    /// Exact id for a dynamically named root provider.
533    ///
534    /// This is runtime parse state rather than a second on-disk key. The
535    /// serialized `provider` value is restored by [`ConfigStore`] so a typed
536    /// dispatcher read/write cannot collapse `[providers.<name>]` back to the
537    /// legacy literal `custom` route.
538    #[doc(hidden)]
539    #[serde(skip)]
540    pub selected_provider_id: Option<String>,
541    pub model: Option<String>,
542    pub auth_mode: Option<String>,
543    pub output_mode: Option<String>,
544    pub verbosity: Option<String>,
545    pub log_level: Option<String>,
546    pub telemetry: Option<bool>,
547    pub approval_policy: Option<String>,
548    pub sandbox_mode: Option<String>,
549    /// Native tool catalog controls shared with `codewhale-tui`.
550    #[serde(default)]
551    pub tools: Option<ToolsToml>,
552    #[serde(default, skip_serializing_if = "ProvidersToml::is_empty")]
553    pub providers: ProvidersToml,
554    /// Provider fallback chain (#2574). TUI runtime code may advance through
555    /// these providers after recoverable provider errors; config resolution
556    /// itself still reports the selected primary provider.
557    #[serde(default, skip_serializing_if = "Vec::is_empty")]
558    pub fallback_providers: Vec<ProviderKind>,
559    /// Per-domain network policy (#135). When absent, network tools fall back
560    /// to a permissive default that mirrors pre-v0.7.0 behavior.
561    #[serde(default)]
562    pub network: Option<NetworkPolicyToml>,
563    /// Verifier-preview behavior (#2093). When absent, verifier tools keep the
564    /// shipped defaults: disabled automatic preview and hunt verdict mapping.
565    #[serde(default)]
566    pub verifier: Option<VerifierConfigToml>,
567    /// Community skill installer settings (#140). Mirrors
568    /// [`SkillsToml`] from the TUI side; the dispatcher consults
569    /// `registry_url` when running `deepseek skill install`.
570    #[serde(default)]
571    pub skills: Option<SkillsToml>,
572    /// Workspace side-git snapshots (#137). The live TUI defaults this to
573    /// enabled with 7-day retention when absent.
574    #[serde(default)]
575    pub snapshots: Option<SnapshotsToml>,
576    /// Post-edit LSP diagnostics injection (#136). When absent, the engine
577    /// applies the defaults documented in [`LspConfigToml`].
578    #[serde(default)]
579    pub lsp: Option<LspConfigToml>,
580    /// Per-model harness profiles (#2693). Runtime wiring lands in follow-up
581    /// v0.9 slices; this is the durable config data model.
582    #[serde(default)]
583    pub harness_profiles: Vec<HarnessProfile>,
584    /// Optional 1-8 hotbar slot bindings (#2064). When absent, the TUI falls
585    /// back to the built-in default slots.
586    #[serde(default, skip_serializing_if = "Option::is_none")]
587    pub hotbar: Option<Vec<HotbarBindingToml>>,
588    /// App-server hook sink configuration. Kept separate from the TUI
589    /// lifecycle `[hooks]` table so config rewrites preserve existing hooks.
590    #[serde(default)]
591    pub hook_sinks: Option<HookSinksToml>,
592    /// Agent Fleet trust and security policy (#3165). When absent, fleet
593    /// workers inherit conservative Sandbox defaults.
594    #[serde(default)]
595    pub fleet: Option<FleetConfigToml>,
596    /// Workflow automatic-launch, approval, isolation, and activity
597    /// persistence knobs (#4128 / Section 2.11). When absent, consumers use
598    /// [`WorkflowConfigToml::default`].
599    #[serde(default)]
600    pub workflow: Option<WorkflowConfigToml>,
601    #[serde(flatten)]
602    pub extras: BTreeMap<String, toml::Value>,
603}
604
605#[derive(Debug, Clone, Copy, PartialEq, Eq)]
606enum ProviderConfigField {
607    ApiKey,
608    BaseUrl,
609    Model,
610    ContextWindow,
611    Mode,
612    AuthMode,
613    InsecureSkipTlsVerify,
614    HttpHeaders,
615    PathSuffix,
616}
617
618impl ProviderConfigField {
619    fn parse(key: &str) -> Option<Self> {
620        Some(match key {
621            "api_key" => Self::ApiKey,
622            "base_url" => Self::BaseUrl,
623            "model" => Self::Model,
624            "context_window" | "context_window_tokens" => Self::ContextWindow,
625            "mode" => Self::Mode,
626            "auth_mode" => Self::AuthMode,
627            "insecure_skip_tls_verify" => Self::InsecureSkipTlsVerify,
628            "http_headers" => Self::HttpHeaders,
629            "path_suffix" => Self::PathSuffix,
630            _ => return None,
631        })
632    }
633
634    fn key(self) -> &'static str {
635        match self {
636            Self::ApiKey => "api_key",
637            Self::BaseUrl => "base_url",
638            Self::Model => "model",
639            Self::ContextWindow => "context_window",
640            Self::Mode => "mode",
641            Self::AuthMode => "auth_mode",
642            Self::InsecureSkipTlsVerify => "insecure_skip_tls_verify",
643            Self::HttpHeaders => "http_headers",
644            Self::PathSuffix => "path_suffix",
645        }
646    }
647}
648
649fn parse_provider_config_key(key: &str) -> Option<(ProviderKind, ProviderConfigField)> {
650    let suffix = key.strip_prefix("providers.")?;
651    let (provider_key, field_key) = suffix.split_once('.')?;
652    let field = ProviderConfigField::parse(field_key)?;
653    let provider = ProviderKind::ALL
654        .iter()
655        .copied()
656        .find(|kind| kind.provider().provider_config_key() == provider_key)?;
657    Some((provider, field))
658}
659
660fn provider_config_key(provider: ProviderKind, field: ProviderConfigField) -> String {
661    format!(
662        "providers.{}.{}",
663        provider.provider().provider_config_key(),
664        field.key()
665    )
666}
667
668fn get_provider_config_value(
669    config: &ProviderConfigToml,
670    field: ProviderConfigField,
671) -> Option<String> {
672    match field {
673        ProviderConfigField::ApiKey => config.api_key.clone(),
674        ProviderConfigField::BaseUrl => config.base_url.clone(),
675        ProviderConfigField::Model => config.model.clone(),
676        ProviderConfigField::ContextWindow => config.context_window.map(|value| value.to_string()),
677        ProviderConfigField::Mode => config.mode.clone(),
678        ProviderConfigField::AuthMode => config.auth_mode.clone(),
679        ProviderConfigField::InsecureSkipTlsVerify => config
680            .insecure_skip_tls_verify
681            .map(|value| value.to_string()),
682        ProviderConfigField::HttpHeaders => serialize_http_headers(&config.http_headers),
683        ProviderConfigField::PathSuffix => config.path_suffix.clone(),
684    }
685}
686
687fn get_provider_config_display_value(
688    config: &ProviderConfigToml,
689    field: ProviderConfigField,
690) -> Option<String> {
691    match field {
692        ProviderConfigField::ApiKey => config.api_key.as_deref().map(redact_secret),
693        ProviderConfigField::HttpHeaders => {
694            serialize_http_headers_for_display(&config.http_headers)
695        }
696        _ => get_provider_config_value(config, field),
697    }
698}
699
700fn parse_context_window(value: &str) -> Result<u32> {
701    let parsed = value.trim().parse::<u32>().with_context(|| {
702        format!("invalid context_window '{value}': expected a positive token count")
703    })?;
704    if parsed == 0 {
705        bail!("context_window must be greater than 0");
706    }
707    Ok(parsed)
708}
709
710fn set_provider_config_value(
711    config: &mut ConfigToml,
712    provider: ProviderKind,
713    field: ProviderConfigField,
714    value: &str,
715) -> Result<()> {
716    match field {
717        ProviderConfigField::ApiKey => {
718            let value = value.to_string();
719            config.providers.for_provider_mut(provider).api_key = Some(value.clone());
720            if provider == ProviderKind::Deepseek {
721                config.api_key = Some(value);
722            }
723        }
724        ProviderConfigField::BaseUrl => {
725            let value = value.to_string();
726            config.providers.for_provider_mut(provider).base_url = Some(value.clone());
727            if provider == ProviderKind::Deepseek {
728                config.base_url = Some(value);
729            }
730        }
731        ProviderConfigField::Model => {
732            let value = value.to_string();
733            config.providers.for_provider_mut(provider).model = Some(value.clone());
734            if provider == ProviderKind::Deepseek {
735                config.default_text_model = Some(value);
736            }
737        }
738        ProviderConfigField::ContextWindow => {
739            config.providers.for_provider_mut(provider).context_window =
740                Some(parse_context_window(value)?);
741        }
742        ProviderConfigField::Mode => {
743            config.providers.for_provider_mut(provider).mode = Some(value.to_string());
744        }
745        ProviderConfigField::AuthMode => {
746            config.providers.for_provider_mut(provider).auth_mode = Some(value.to_string());
747        }
748        ProviderConfigField::InsecureSkipTlsVerify => {
749            config
750                .providers
751                .for_provider_mut(provider)
752                .insecure_skip_tls_verify = Some(parse_bool(value)?);
753        }
754        ProviderConfigField::HttpHeaders => {
755            let headers = parse_http_headers(value)?;
756            config.providers.for_provider_mut(provider).http_headers = headers.clone();
757            if provider == ProviderKind::Deepseek {
758                config.http_headers = headers;
759            }
760        }
761        ProviderConfigField::PathSuffix => {
762            config.providers.for_provider_mut(provider).path_suffix = Some(value.to_string());
763        }
764    }
765    Ok(())
766}
767
768fn unset_provider_config_value(
769    config: &mut ConfigToml,
770    provider: ProviderKind,
771    field: ProviderConfigField,
772) {
773    match field {
774        ProviderConfigField::ApiKey => {
775            config.providers.for_provider_mut(provider).api_key = None;
776            if provider == ProviderKind::Deepseek {
777                config.api_key = None;
778            }
779        }
780        ProviderConfigField::BaseUrl => {
781            config.providers.for_provider_mut(provider).base_url = None;
782            if provider == ProviderKind::Deepseek {
783                config.base_url = None;
784            }
785        }
786        ProviderConfigField::Model => {
787            config.providers.for_provider_mut(provider).model = None;
788            if provider == ProviderKind::Deepseek {
789                config.default_text_model = None;
790            }
791        }
792        ProviderConfigField::ContextWindow => {
793            config.providers.for_provider_mut(provider).context_window = None;
794        }
795        ProviderConfigField::Mode => {
796            config.providers.for_provider_mut(provider).mode = None;
797        }
798        ProviderConfigField::AuthMode => {
799            config.providers.for_provider_mut(provider).auth_mode = None;
800        }
801        ProviderConfigField::InsecureSkipTlsVerify => {
802            config
803                .providers
804                .for_provider_mut(provider)
805                .insecure_skip_tls_verify = None;
806        }
807        ProviderConfigField::HttpHeaders => {
808            config
809                .providers
810                .for_provider_mut(provider)
811                .http_headers
812                .clear();
813            if provider == ProviderKind::Deepseek {
814                config.http_headers.clear();
815            }
816        }
817        ProviderConfigField::PathSuffix => {
818            config.providers.for_provider_mut(provider).path_suffix = None;
819        }
820    }
821}
822
823fn insert_provider_config_values(
824    out: &mut BTreeMap<String, String>,
825    provider: ProviderKind,
826    config: &ProviderConfigToml,
827) {
828    if let Some(v) = config.api_key.as_ref() {
829        out.insert(
830            provider_config_key(provider, ProviderConfigField::ApiKey),
831            redact_secret(v),
832        );
833    }
834    if let Some(v) = config.base_url.as_ref() {
835        out.insert(
836            provider_config_key(provider, ProviderConfigField::BaseUrl),
837            v.clone(),
838        );
839    }
840    if let Some(v) = config.model.as_ref() {
841        out.insert(
842            provider_config_key(provider, ProviderConfigField::Model),
843            v.clone(),
844        );
845    }
846    if let Some(v) = config.context_window {
847        out.insert(
848            provider_config_key(provider, ProviderConfigField::ContextWindow),
849            v.to_string(),
850        );
851    }
852    if let Some(v) = config.mode.as_ref() {
853        out.insert(
854            provider_config_key(provider, ProviderConfigField::Mode),
855            v.clone(),
856        );
857    }
858    if let Some(v) = config.auth_mode.as_ref() {
859        out.insert(
860            provider_config_key(provider, ProviderConfigField::AuthMode),
861            v.clone(),
862        );
863    }
864    if let Some(v) = config.insecure_skip_tls_verify {
865        out.insert(
866            provider_config_key(provider, ProviderConfigField::InsecureSkipTlsVerify),
867            v.to_string(),
868        );
869    }
870    if let Some(v) = serialize_http_headers_for_display(&config.http_headers) {
871        out.insert(
872            provider_config_key(provider, ProviderConfigField::HttpHeaders),
873            v,
874        );
875    }
876    if let Some(v) = config.path_suffix.as_ref() {
877        out.insert(
878            provider_config_key(provider, ProviderConfigField::PathSuffix),
879            v.clone(),
880        );
881    }
882}
883
884impl ConfigToml {
885    /// Resolve the first configured harness profile for a provider/model route.
886    ///
887    /// This helper is deliberately dormant for v0.9: callers may display or
888    /// test the resolved profile, but runtime provider/model routing and prompt
889    /// shaping remain unchanged until a later, explicit integration slice.
890    #[must_use]
891    pub fn resolve_harness_profile(
892        &self,
893        provider_route: &str,
894        model: &str,
895    ) -> Option<&HarnessProfile> {
896        self.harness_profiles
897            .iter()
898            .chain(built_in_harness_profiles().iter())
899            .find(|profile| profile.matches_route(provider_route, model))
900    }
901
902    /// Resolve durable hotbar config into normalized 1-8 slot bindings.
903    ///
904    /// `known_action_ids` is supplied by the TUI action registry in later
905    /// slices. Unknown actions are preserved so the UI can render a disabled
906    /// `?` cell instead of silently deleting user config.
907    #[must_use]
908    pub fn resolve_hotbar_bindings(&self, known_action_ids: &[&str]) -> HotbarConfigResolution {
909        resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids)
910    }
911}
912
913/// Ordered primary-plus-fallback provider list for future provider routing.
914///
915/// The helper is intentionally dormant: constructing or parsing a chain does
916/// not change [`ConfigToml::resolve_runtime_options`].
917#[derive(Debug, Clone, PartialEq, Eq)]
918pub struct ProviderChain {
919    providers: Vec<ProviderKind>,
920    position: usize,
921}
922
923pub const HOTBAR_SLOT_COUNT: u8 = 8;
924
925pub const DEFAULT_HOTBAR_ACTIONS: [&str; HOTBAR_SLOT_COUNT as usize] = [
926    "voice.toggle",
927    "session.compact",
928    "mode.plan",
929    "mode.agent",
930    "mode.operate",
931    "palette.open",
932    "sidebar.toggle",
933    "trust.toggle",
934];
935
936/// On-disk schema for one `[[hotbar]]` table.
937#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
938#[serde(deny_unknown_fields)]
939pub struct HotbarBindingToml {
940    pub slot: u8,
941    pub action: String,
942    #[serde(default)]
943    pub label: Option<String>,
944}
945
946/// Validated hotbar binding used by future render/dispatch layers.
947#[derive(Debug, Clone, PartialEq, Eq)]
948pub struct HotbarBinding {
949    pub slot: u8,
950    pub action: String,
951    pub label: Option<String>,
952}
953
954/// Non-fatal hotbar config issue. Invalid slots are skipped; duplicate slots
955/// use the last binding; unknown actions are kept for UI feedback.
956#[derive(Debug, Clone, PartialEq, Eq)]
957pub enum HotbarConfigWarning {
958    SlotOutOfRange {
959        slot: u8,
960        action: String,
961    },
962    DuplicateSlot {
963        slot: u8,
964        previous_action: String,
965        replacement_action: String,
966    },
967    UnknownAction {
968        slot: u8,
969        action: String,
970    },
971}
972
973impl fmt::Display for HotbarConfigWarning {
974    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
975        match self {
976            Self::SlotOutOfRange { slot, action } => write!(
977                f,
978                "hotbar slot {slot} for action '{action}' is outside 1-{HOTBAR_SLOT_COUNT}; skipped"
979            ),
980            Self::DuplicateSlot {
981                slot,
982                previous_action,
983                replacement_action,
984            } => write!(
985                f,
986                "hotbar slot {slot} was bound to '{previous_action}' more than once; using '{replacement_action}'"
987            ),
988            Self::UnknownAction { slot, action } => write!(
989                f,
990                "hotbar slot {slot} references unknown action '{action}'; keeping binding"
991            ),
992        }
993    }
994}
995
996#[derive(Debug, Clone, PartialEq, Eq)]
997pub struct HotbarConfigResolution {
998    pub bindings: Vec<HotbarBinding>,
999    pub warnings: Vec<HotbarConfigWarning>,
1000}
1001
1002#[must_use]
1003pub fn default_hotbar_bindings() -> Vec<HotbarBinding> {
1004    DEFAULT_HOTBAR_ACTIONS
1005        .iter()
1006        .enumerate()
1007        .map(|(idx, action)| HotbarBinding {
1008            slot: u8::try_from(idx + 1).expect("default hotbar slot fits in u8"),
1009            action: (*action).to_string(),
1010            label: None,
1011        })
1012        .collect()
1013}
1014
1015/// The default hotbar slots in on-disk (`[[hotbar]]`) form. Since #3807 an
1016/// absent `hotbar` key means "hidden", so `/hotbar on` persists these explicit
1017/// bindings rather than deleting the key. Kept in terms of
1018/// [`default_hotbar_bindings`] so `DEFAULT_HOTBAR_ACTIONS` stays the single
1019/// source of truth.
1020#[must_use]
1021pub fn default_hotbar_bindings_toml() -> Vec<HotbarBindingToml> {
1022    default_hotbar_bindings()
1023        .into_iter()
1024        .map(|binding| HotbarBindingToml {
1025            slot: binding.slot,
1026            action: binding.action,
1027            label: binding.label,
1028        })
1029        .collect()
1030}
1031
1032#[must_use]
1033pub fn resolve_hotbar_bindings(
1034    configured: Option<&[HotbarBindingToml]>,
1035    known_action_ids: &[&str],
1036) -> HotbarConfigResolution {
1037    let known = known_action_ids.iter().copied().collect::<BTreeSet<&str>>();
1038    let mut warnings = Vec::new();
1039
1040    let source = match configured {
1041        Some(bindings) => bindings
1042            .iter()
1043            .map(|binding| HotbarBinding {
1044                slot: binding.slot,
1045                action: binding.action.clone(),
1046                label: binding.label.clone(),
1047            })
1048            .collect::<Vec<_>>(),
1049        // #3807: an absent `hotbar` key means the Hotbar is hidden until the
1050        // user opts in (via the setup wizard or `/hotbar on`). Only an explicit
1051        // `[[hotbar]]` config produces bindings. `Some([])` stays "disabled".
1052        None => Vec::new(),
1053    };
1054
1055    let mut by_slot: BTreeMap<u8, HotbarBinding> = BTreeMap::new();
1056    for binding in source {
1057        if !(1..=HOTBAR_SLOT_COUNT).contains(&binding.slot) {
1058            warnings.push(HotbarConfigWarning::SlotOutOfRange {
1059                slot: binding.slot,
1060                action: binding.action,
1061            });
1062            continue;
1063        }
1064        if !known.is_empty() && !known.contains(binding.action.as_str()) {
1065            warnings.push(HotbarConfigWarning::UnknownAction {
1066                slot: binding.slot,
1067                action: binding.action.clone(),
1068            });
1069        }
1070        if let Some(previous) = by_slot.insert(binding.slot, binding.clone()) {
1071            warnings.push(HotbarConfigWarning::DuplicateSlot {
1072                slot: binding.slot,
1073                previous_action: previous.action,
1074                replacement_action: binding.action,
1075            });
1076        }
1077    }
1078
1079    HotbarConfigResolution {
1080        bindings: by_slot.into_values().collect(),
1081        warnings,
1082    }
1083}
1084
1085impl ProviderChain {
1086    #[must_use]
1087    pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self {
1088        let mut providers = vec![active];
1089        for fallback in fallbacks {
1090            if *fallback != active && !providers.contains(fallback) {
1091                providers.push(*fallback);
1092            }
1093        }
1094        Self {
1095            providers,
1096            position: 0,
1097        }
1098    }
1099
1100    #[must_use]
1101    pub fn providers(&self) -> &[ProviderKind] {
1102        &self.providers
1103    }
1104
1105    #[must_use]
1106    pub fn position(&self) -> usize {
1107        self.position
1108    }
1109
1110    #[must_use]
1111    pub fn current(&self) -> ProviderKind {
1112        self.providers
1113            .get(self.position)
1114            .copied()
1115            .or_else(|| self.providers.first().copied())
1116            .unwrap_or_default()
1117    }
1118
1119    #[must_use]
1120    pub fn has_next(&self) -> bool {
1121        self.position + 1 < self.providers.len()
1122    }
1123
1124    pub fn advance(&mut self) -> Option<ProviderKind> {
1125        if !self.has_next() {
1126            return None;
1127        }
1128        self.position += 1;
1129        Some(self.current())
1130    }
1131
1132    pub fn reset(&mut self) {
1133        self.position = 0;
1134    }
1135
1136    #[must_use]
1137    pub fn is_fallback_active(&self) -> bool {
1138        self.position > 0
1139    }
1140
1141    /// Count the current provider plus untried chain entries.
1142    #[must_use]
1143    pub fn remaining(&self) -> usize {
1144        self.providers.len() - self.position
1145    }
1146}
1147
1148#[cfg(test)]
1149mod provider_chain_tests {
1150    use super::*;
1151
1152    #[test]
1153    fn current_on_empty_chain_returns_default_provider() {
1154        let chain = ProviderChain {
1155            providers: vec![],
1156            position: 0,
1157        };
1158        assert_eq!(chain.current(), ProviderKind::default());
1159    }
1160}
1161
1162/// On-disk schema for the `[hook_sinks]` table.
1163#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1164pub struct HookSinksToml {
1165    /// Unix domain socket path used by the app-server event sink.
1166    ///
1167    /// When unset, no Unix socket sink is registered. There is deliberately no
1168    /// shared `/tmp` default because socket ownership should be explicit.
1169    #[serde(default)]
1170    pub unix_socket_path: Option<PathBuf>,
1171}
1172
1173/// On-disk schema for the `[skills]` table (#140). See `config.example.toml`
1174/// for documentation.
1175#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1176pub struct SkillsToml {
1177    /// Curated registry index URL. When unset, the TUI falls back to the
1178    /// bundled default (community-curated GitHub raw).
1179    #[serde(default)]
1180    pub registry_url: Option<String>,
1181    /// Per-skill maximum *uncompressed* size in bytes. When unset, the TUI
1182    /// uses 5 MiB.
1183    #[serde(default)]
1184    pub max_install_size_bytes: Option<u64>,
1185}
1186
1187/// On-disk schema for the `[tools]` table (#2076).
1188#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1189pub struct ToolsToml {
1190    /// Native tool names to keep loaded outside the default core catalog.
1191    #[serde(default)]
1192    pub always_load: Vec<String>,
1193}
1194
1195/// On-disk schema for the `[snapshots]` table (#137). See
1196/// `config.example.toml` for documentation.
1197#[derive(Debug, Clone, Serialize, Deserialize)]
1198pub struct SnapshotsToml {
1199    #[serde(default = "default_snapshots_enabled")]
1200    pub enabled: bool,
1201    #[serde(default = "default_snapshot_max_age_days")]
1202    pub max_age_days: u64,
1203}
1204
1205fn default_snapshots_enabled() -> bool {
1206    true
1207}
1208
1209fn default_snapshot_max_age_days() -> u64 {
1210    7
1211}
1212
1213impl Default for SnapshotsToml {
1214    fn default() -> Self {
1215        Self {
1216            enabled: default_snapshots_enabled(),
1217            max_age_days: default_snapshot_max_age_days(),
1218        }
1219    }
1220}
1221
1222/// On-disk schema for the `[fleet]` table (#3165). See `config.example.toml`
1223/// and `docs/FLEET.md` for documentation.
1224#[derive(Debug, Clone, Serialize, Deserialize)]
1225pub struct FleetConfigToml {
1226    /// Default trust level for fleet workers. One of `"sandbox"`, `"local"`,
1227    /// `"remote-verified"`, or `"operator"`. Defaults to `"sandbox"`.
1228    #[serde(default = "default_fleet_trust_level_str")]
1229    pub default_trust_level: String,
1230    /// Require identity verification for remote (SSH) workers before
1231    /// granting them `remote-verified` trust. Defaults to true.
1232    #[serde(default = "default_fleet_require_identity")]
1233    pub require_identity_verification: bool,
1234    /// Maximum trust level any worker may have (`"sandbox"`, `"local"`,
1235    /// `"remote-verified"`, or `"operator"`). Defaults to `"operator"`.
1236    #[serde(default = "default_fleet_max_trust_level_str")]
1237    pub max_trust_level: String,
1238    /// User-defined and built-in role presets.
1239    ///
1240    /// Each role defines default tool profiles, capabilities, budgets, and
1241    /// trust settings that task specs can reference by name. Built-in roles
1242    /// (`smoke-runner`, `reviewer`, `builder`, `read-only`) are always
1243    /// available; user-defined roles in config override or extend them.
1244    #[serde(default)]
1245    pub roles: BTreeMap<String, FleetRolePreset>,
1246    /// Fleet profile vocabulary (#3167). Profiles group role semantics,
1247    /// loadout hints, permission defaults, and delegation bounds. They are
1248    /// config-only in this slice; executor/model routing wiring lands later.
1249    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1250    pub profiles: BTreeMap<String, FleetProfile>,
1251    /// Headless worker execution hardening (#3027).
1252    #[serde(default)]
1253    pub exec: FleetExecConfig,
1254}
1255
1256/// Canonical recursion-depth policy for the headless worker runtime.
1257///
1258/// Single source of truth shared by BOTH standalone sub-agents and fleet
1259/// workers so the two cannot drift into "two moving targets":
1260/// - [`DEFAULT_SPAWN_DEPTH`] is the default recursion budget (the sub-agent
1261///   runtime's `DEFAULT_MAX_SPAWN_DEPTH` is defined as this value).
1262/// - [`MAX_SPAWN_DEPTH_CEILING`] is the opt-in safety cap; every configured
1263///   value (fleet `max_spawn_depth`, the `agent` tool's `max_depth`) clamps to it.
1264///
1265/// A worker runs at `spawn_depth = 0` and may spawn while
1266/// `spawn_depth + 1 <= max_spawn_depth`, so a depth of N affords N nested
1267/// delegation levels below the root worker. The default of 3 affords at least
1268/// three recursion levels out of the box; the root worker still runs at
1269/// depth 0 even when the budget is 0.
1270pub const DEFAULT_SPAWN_DEPTH: u32 = 3;
1271pub const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 900;
1272pub const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1;
1273pub const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600;
1274
1275/// Hard ceiling on recursion depth for any worker/sub-agent. The default stays
1276/// conservative at [`DEFAULT_SPAWN_DEPTH`], while explicit config can opt into
1277/// deeper trees for direct-API providers that can tolerate the fanout.
1278/// Raising this single constant lifts the limit everywhere (the fleet clamp
1279/// and `agent` validation both read it).
1280pub const MAX_SPAWN_DEPTH_CEILING: u32 = 8;
1281
1282/// Headless worker execution constraints (#3027).
1283///
1284/// These limits apply to all fleet workers and sub-agents spawned through
1285/// the headless worker runtime. Task specs can tighten but not loosen them.
1286#[derive(Debug, Clone, Serialize, Deserialize)]
1287pub struct FleetExecConfig {
1288    /// Tools that are always allowed regardless of role or task spec.
1289    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1290    pub allowed_tools: Vec<String>,
1291    /// Tools that are always disallowed, overriding role and task spec.
1292    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1293    pub disallowed_tools: Vec<String>,
1294    /// Hard ceiling on sub-agent steps (tool calls + model turns).
1295    /// Workers that exceed this are terminated. Default: unbounded (u32::MAX).
1296    #[serde(default = "default_fleet_max_turns")]
1297    pub max_turns: u32,
1298    /// Recursive child-agent budget for headless fleet workers.
1299    /// Defaults to [`DEFAULT_SPAWN_DEPTH`] (3) so a fleet worker has the SAME
1300    /// recursion budget as a standalone sub-agent — fleet and sub-agents are one
1301    /// substrate, not two. Set 0 to block child `agent` calls (the root worker
1302    /// still runs); the value is clamped to [`MAX_SPAWN_DEPTH_CEILING`].
1303    #[serde(default = "default_fleet_max_spawn_depth")]
1304    pub max_spawn_depth: u32,
1305    /// Extra system prompt text appended to every headless worker.
1306    /// Useful for injecting org-wide policy or behavior constraints.
1307    #[serde(default, skip_serializing_if = "String::is_empty")]
1308    pub append_system_prompt: String,
1309    /// Output format for fleet worker results.
1310    /// `"text"` (default) or `"stream-json"` for newline-delimited JSON events.
1311    #[serde(default = "default_fleet_output_format")]
1312    pub output_format: String,
1313}
1314
1315fn default_fleet_max_turns() -> u32 {
1316    u32::MAX
1317}
1318
1319fn default_fleet_max_spawn_depth() -> u32 {
1320    DEFAULT_SPAWN_DEPTH
1321}
1322
1323fn default_fleet_output_format() -> String {
1324    "text".to_string()
1325}
1326
1327impl Default for FleetExecConfig {
1328    fn default() -> Self {
1329        Self {
1330            allowed_tools: Vec::new(),
1331            disallowed_tools: Vec::new(),
1332            max_turns: default_fleet_max_turns(),
1333            max_spawn_depth: default_fleet_max_spawn_depth(),
1334            append_system_prompt: String::new(),
1335            output_format: default_fleet_output_format(),
1336        }
1337    }
1338}
1339
1340/// Fleet org-chart profile.
1341///
1342/// A profile is an additive config record for future fleet scheduling policy.
1343/// Loading one must not grant runtime permissions by itself: shell and trust
1344/// escalation default off, and approvals default on.
1345#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1346pub struct FleetProfile {
1347    /// Org-chart slot this profile describes.
1348    #[serde(default)]
1349    pub slot: FleetSlot,
1350    /// Semantic role name and optional instruction overlay.
1351    #[serde(default)]
1352    pub role: FleetRole,
1353    /// Model class / route-role hint. This is data only in this slice.
1354    #[serde(default)]
1355    pub loadout: FleetLoadout,
1356    /// Optional explicit model id for this profile on the active/resolved route.
1357    ///
1358    /// This is not an auth or endpoint selector. Provider-scoped routing still
1359    /// validates the executable provider/model/wire-model decision.
1360    #[serde(default, skip_serializing_if = "Option::is_none")]
1361    pub model: Option<String>,
1362    /// Optional explicit provider id for this profile's model (#4093).
1363    ///
1364    /// Present only when the profile was created against a specific,
1365    /// credential-checked provider (e.g. via the Fleet setup model picker),
1366    /// so a worker can be pinned to a route independent of the parent/current
1367    /// session provider. `None` means "no route pin" (inherit), matching
1368    /// `model: None`; a profile must never carry `provider` without `model`.
1369    ///
1370    /// EPIC #2608 explicit-config-only mandate: this field is the ONLY
1371    /// authority for the profile's provider. It is never inferred by sniffing
1372    /// a substring/prefix out of `model` — callers that need the provider for
1373    /// this profile must read this field, not guess from the model id.
1374    #[serde(default, skip_serializing_if = "Option::is_none")]
1375    pub provider: Option<String>,
1376    /// Optional explicit reasoning/thinking tier for this profile (#4137).
1377    ///
1378    /// This is a safe, non-secret route tuning value. `None` means inherit the
1379    /// operator/session reasoning tier. Concrete values are normalized by the
1380    /// TUI loader before they are used at runtime.
1381    #[serde(default, skip_serializing_if = "Option::is_none")]
1382    pub reasoning_effort: Option<String>,
1383    /// Permission defaults requested by the profile.
1384    #[serde(default)]
1385    pub permissions: FleetProfilePermissions,
1386    /// Delegation hints for future manager policy.
1387    #[serde(default)]
1388    pub delegation: FleetDelegationHints,
1389}
1390
1391/// Semantic role declaration for a fleet profile.
1392///
1393/// TOML may use either `role = "reviewer"` or a role table with `name` and
1394/// `instructions`.
1395#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
1396pub struct FleetRole {
1397    /// Stable role name, e.g. `scout`, `implementer`, or `verifier`.
1398    pub name: String,
1399    /// Optional short description for config UIs and docs.
1400    #[serde(default, skip_serializing_if = "Option::is_none")]
1401    pub description: Option<String>,
1402    /// Optional instruction overlay to apply when the role is later consumed.
1403    #[serde(default, skip_serializing_if = "Option::is_none")]
1404    pub instructions: Option<String>,
1405}
1406
1407impl Default for FleetRole {
1408    fn default() -> Self {
1409        Self {
1410            name: "general".to_string(),
1411            description: None,
1412            instructions: None,
1413        }
1414    }
1415}
1416
1417impl<'de> Deserialize<'de> for FleetRole {
1418    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1419    where
1420        D: serde::Deserializer<'de>,
1421    {
1422        #[derive(Deserialize)]
1423        #[serde(untagged)]
1424        enum FleetRoleWire {
1425            Name(String),
1426            Full {
1427                #[serde(default)]
1428                name: Option<String>,
1429                #[serde(default)]
1430                description: Option<String>,
1431                #[serde(default)]
1432                instructions: Option<String>,
1433            },
1434        }
1435
1436        match FleetRoleWire::deserialize(deserializer)? {
1437            FleetRoleWire::Name(name) => Ok(Self {
1438                name,
1439                ..Self::default()
1440            }),
1441            FleetRoleWire::Full {
1442                name,
1443                description,
1444                instructions,
1445            } => Ok(Self {
1446                name: name.unwrap_or_else(|| Self::default().name),
1447                description,
1448                instructions,
1449            }),
1450        }
1451    }
1452}
1453
1454/// Org-chart slot for grouping fleet profiles.
1455#[derive(Debug, Clone, PartialEq, Eq, Default)]
1456pub enum FleetSlot {
1457    Manager,
1458    Scout,
1459    Implementer,
1460    Reviewer,
1461    Verifier,
1462    Operator,
1463    Summarizer,
1464    #[default]
1465    General,
1466    Custom(String),
1467}
1468
1469impl FleetSlot {
1470    #[must_use]
1471    pub fn as_str(&self) -> &str {
1472        match self {
1473            Self::Manager => "manager",
1474            Self::Scout => "scout",
1475            Self::Implementer => "implementer",
1476            Self::Reviewer => "reviewer",
1477            Self::Verifier => "verifier",
1478            Self::Operator => "operator",
1479            Self::Summarizer => "summarizer",
1480            Self::General => "general",
1481            Self::Custom(value) => value.as_str(),
1482        }
1483    }
1484
1485    #[must_use]
1486    pub fn from_name(value: &str) -> Self {
1487        match value.trim() {
1488            "manager" | "coordinator" => Self::Manager,
1489            "scout" | "research" | "research-worker" => Self::Scout,
1490            "implementer" | "builder" => Self::Implementer,
1491            "reviewer" => Self::Reviewer,
1492            "verifier" | "tester" => Self::Verifier,
1493            "operator" | "incident" | "incident-worker" => Self::Operator,
1494            "summarizer" | "reducer" => Self::Summarizer,
1495            "general" | "" => Self::General,
1496            // Removed slots (e.g. the old "tool-heavy") and unknown names parse
1497            // as Custom, which dispatches on the General surface — identical to
1498            // the behavior the removed variants had.
1499            other => Self::Custom(other.to_string()),
1500        }
1501    }
1502}
1503
1504impl Serialize for FleetSlot {
1505    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1506    where
1507        S: serde::Serializer,
1508    {
1509        serializer.serialize_str(self.as_str())
1510    }
1511}
1512
1513impl<'de> Deserialize<'de> for FleetSlot {
1514    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1515    where
1516        D: serde::Deserializer<'de>,
1517    {
1518        let value = String::deserialize(deserializer)?;
1519        Ok(Self::from_name(&value))
1520    }
1521}
1522
1523/// Model class or route-role hint for a profile.
1524#[derive(Debug, Clone, PartialEq, Eq, Default)]
1525pub enum FleetLoadout {
1526    /// Reuse the active session route (the operator's model). Default.
1527    #[default]
1528    Inherit,
1529    /// Route to the provider's faster/cheaper model class for wide fan-out.
1530    Fast,
1531    /// Unrecognized loadout names parse here (including the retired
1532    /// strong/balanced/deep-reasoning/code/review/tool-heavy tiers, which
1533    /// never routed differently). Treated as auto routing.
1534    Custom(String),
1535}
1536
1537impl FleetLoadout {
1538    #[must_use]
1539    pub fn as_str(&self) -> &str {
1540        match self {
1541            Self::Inherit => "inherit",
1542            Self::Fast => "fast",
1543            Self::Custom(value) => value.as_str(),
1544        }
1545    }
1546
1547    #[must_use]
1548    pub fn from_name(value: &str) -> Self {
1549        match value.trim() {
1550            "inherit" | "default" | "auto" | "" => Self::Inherit,
1551            "fast" => Self::Fast,
1552            // Retired tiers (strong/balanced/deep-reasoning/code/review/
1553            // tool-heavy) and unknown names parse as Custom → auto routing,
1554            // exactly what those tiers resolved to before removal.
1555            other => Self::Custom(other.to_string()),
1556        }
1557    }
1558}
1559
1560impl Serialize for FleetLoadout {
1561    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1562    where
1563        S: serde::Serializer,
1564    {
1565        serializer.serialize_str(self.as_str())
1566    }
1567}
1568
1569impl<'de> Deserialize<'de> for FleetLoadout {
1570    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1571    where
1572        D: serde::Deserializer<'de>,
1573    {
1574        let value = String::deserialize(deserializer)?;
1575        Ok(Self::from_name(&value))
1576    }
1577}
1578
1579/// Safe permission defaults attached to a fleet profile.
1580#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1581pub struct FleetProfilePermissions {
1582    /// Permit shell-capable tools for this profile when later consumed.
1583    #[serde(default)]
1584    pub allow_shell: bool,
1585    /// Permit trusted/elevated execution for this profile when later consumed.
1586    #[serde(default)]
1587    pub trust: bool,
1588    /// Require approval by default. This intentionally defaults on.
1589    #[serde(default = "default_fleet_profile_approval_required")]
1590    pub approval_required: bool,
1591}
1592
1593fn default_fleet_profile_approval_required() -> bool {
1594    true
1595}
1596
1597impl Default for FleetProfilePermissions {
1598    fn default() -> Self {
1599        Self {
1600            allow_shell: false,
1601            trust: false,
1602            approval_required: true,
1603        }
1604    }
1605}
1606
1607/// Delegation hints for future fleet manager scheduling.
1608#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1609pub struct FleetDelegationHints {
1610    /// Optional profile-level child spawn depth. `None` means inherit existing
1611    /// fleet/sub-agent config.
1612    #[serde(default, skip_serializing_if = "Option::is_none")]
1613    pub max_spawn_depth: Option<u32>,
1614    /// Optional profile-level worker concurrency hint.
1615    #[serde(
1616        default,
1617        alias = "concurrency",
1618        skip_serializing_if = "Option::is_none"
1619    )]
1620    pub max_concurrency: Option<usize>,
1621}
1622
1623/// A named role preset that bundles common worker settings.
1624///
1625/// Task specs reference a role name (e.g. `"role": "reviewer"`), and the
1626/// fleet manager fills in any missing fields from the preset. User-defined
1627/// roles in `[fleet.roles]` override built-in defaults with the same name.
1628///
1629/// Token budgets and tool-call limits are task-level decisions — they don't
1630/// belong on role presets. Use `timeout_seconds` as the safety bound.
1631#[derive(Debug, Clone, Serialize, Deserialize)]
1632pub struct FleetRolePreset {
1633    /// Short description of what this role is for.
1634    #[serde(skip_serializing_if = "Option::is_none")]
1635    pub description: Option<String>,
1636    /// Default tool profile (`"read-only"`, `"read-write"`, or `"custom"`).
1637    #[serde(skip_serializing_if = "Option::is_none")]
1638    pub tool_profile: Option<String>,
1639    /// Default set of tool names available to this role.
1640    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1641    pub tools: Vec<String>,
1642    /// Default capability tags (e.g. `"rust"`, `"git"`, `"gh"`).
1643    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1644    pub capabilities: Vec<String>,
1645    /// Default timeout in seconds for tasks using this role.
1646    #[serde(skip_serializing_if = "Option::is_none")]
1647    pub timeout_seconds: Option<u64>,
1648    /// Default trust level override for this role.
1649    #[serde(skip_serializing_if = "Option::is_none")]
1650    pub trust_level: Option<String>,
1651}
1652
1653fn default_fleet_trust_level_str() -> String {
1654    "sandbox".to_string()
1655}
1656
1657fn default_fleet_require_identity() -> bool {
1658    true
1659}
1660
1661fn default_fleet_max_trust_level_str() -> String {
1662    "operator".to_string()
1663}
1664
1665impl Default for FleetConfigToml {
1666    fn default() -> Self {
1667        Self {
1668            default_trust_level: default_fleet_trust_level_str(),
1669            require_identity_verification: default_fleet_require_identity(),
1670            max_trust_level: default_fleet_max_trust_level_str(),
1671            roles: BTreeMap::new(),
1672            profiles: BTreeMap::new(),
1673            exec: FleetExecConfig::default(),
1674        }
1675    }
1676}
1677
1678impl FleetConfigToml {
1679    /// Resolve a role preset by name. Checks user-defined roles first,
1680    /// then falls back to built-in role defaults.
1681    #[must_use]
1682    pub fn resolve_role(&self, name: &str) -> Option<FleetRolePreset> {
1683        self.roles
1684            .get(name)
1685            .cloned()
1686            .or_else(|| built_in_role_presets().get(name).cloned())
1687    }
1688}
1689
1690/// On-disk schema for the `[workflow]` table (#4128 / Section 2.11).
1691///
1692/// Automatic Workflow launch, write/approval gates, child/isolation budgets,
1693/// and completed-activity persistence all read from this one model. When the
1694/// table is absent, consumers resolve [`WorkflowConfigToml::default`].
1695/// See `config.example.toml` for documentation.
1696#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1697pub struct WorkflowConfigToml {
1698    /// Allow the parent agent to auto-launch Workflow for multi-agent work.
1699    /// Product default is on; set `false` to require explicit `/workflow`.
1700    #[serde(default = "default_workflow_automatic")]
1701    pub automatic: bool,
1702    /// When automatic launch is enabled, start read-only child plans without
1703    /// an approval card. Write/shell/network plans still consult
1704    /// [`Self::require_approval_for_writes`].
1705    #[serde(default = "default_workflow_auto_start_read_only")]
1706    pub auto_start_read_only: bool,
1707    /// Require an operator approval card before launching plans that write,
1708    /// elevate shell/network, or otherwise leave the read-only envelope.
1709    #[serde(default = "default_workflow_require_approval_for_writes")]
1710    pub require_approval_for_writes: bool,
1711    /// Soft upper bound on children admitted by automatic launch. Larger plans
1712    /// should ask the operator or use explicit `/workflow`.
1713    #[serde(default = "default_workflow_auto_start_child_limit")]
1714    pub auto_start_child_limit: u32,
1715    /// Hard ceiling on total children in one Workflow run (product: 1000).
1716    #[serde(default = "default_workflow_max_children")]
1717    pub max_children: u32,
1718    /// Maximum concurrently live agents inside one Workflow run (product: 16).
1719    #[serde(default = "default_workflow_max_concurrent")]
1720    pub max_concurrent: u32,
1721    /// Maximum nested Workflow / child-orchestration depth.
1722    #[serde(default = "default_workflow_max_depth")]
1723    pub max_depth: u32,
1724    /// Default shared token budget for a Workflow run and its children.
1725    #[serde(default = "default_workflow_default_token_budget")]
1726    pub default_token_budget: u64,
1727    /// How many parallel write children may share the parent worktree without
1728    /// isolation. `0` forces worktree isolation for parallel writes.
1729    #[serde(default = "default_workflow_max_parallel_writes_without_worktree")]
1730    pub max_parallel_writes_without_worktree: u32,
1731    /// Keep completed Workflow activity visible in the session activity surface
1732    /// until the next run (or explicit clear).
1733    #[serde(default = "default_workflow_persist_completed_activity")]
1734    pub persist_completed_activity: bool,
1735    /// Persist completed Workflow activity across process restarts via the
1736    /// durable run journal.
1737    #[serde(default = "default_workflow_persist_completed_across_restarts")]
1738    pub persist_completed_across_restarts: bool,
1739}
1740
1741fn default_workflow_automatic() -> bool {
1742    true
1743}
1744
1745fn default_workflow_auto_start_read_only() -> bool {
1746    true
1747}
1748
1749fn default_workflow_require_approval_for_writes() -> bool {
1750    true
1751}
1752
1753fn default_workflow_auto_start_child_limit() -> u32 {
1754    // Soft auto stays small; explicit launches may use the full concurrent cap.
1755    16
1756}
1757
1758fn default_workflow_max_children() -> u32 {
1759    1000
1760}
1761
1762fn default_workflow_max_concurrent() -> u32 {
1763    16
1764}
1765
1766fn default_workflow_max_depth() -> u32 {
1767    2
1768}
1769
1770fn default_workflow_default_token_budget() -> u64 {
1771    120_000
1772}
1773
1774fn default_workflow_max_parallel_writes_without_worktree() -> u32 {
1775    0
1776}
1777
1778fn default_workflow_persist_completed_activity() -> bool {
1779    true
1780}
1781
1782fn default_workflow_persist_completed_across_restarts() -> bool {
1783    true
1784}
1785
1786impl Default for WorkflowConfigToml {
1787    fn default() -> Self {
1788        Self {
1789            automatic: default_workflow_automatic(),
1790            auto_start_read_only: default_workflow_auto_start_read_only(),
1791            require_approval_for_writes: default_workflow_require_approval_for_writes(),
1792            auto_start_child_limit: default_workflow_auto_start_child_limit(),
1793            max_children: default_workflow_max_children(),
1794            max_concurrent: default_workflow_max_concurrent(),
1795            max_depth: default_workflow_max_depth(),
1796            default_token_budget: default_workflow_default_token_budget(),
1797            max_parallel_writes_without_worktree:
1798                default_workflow_max_parallel_writes_without_worktree(),
1799            persist_completed_activity: default_workflow_persist_completed_activity(),
1800            persist_completed_across_restarts: default_workflow_persist_completed_across_restarts(),
1801        }
1802    }
1803}
1804
1805/// Built-in role presets that are always available without config.
1806#[must_use]
1807pub fn built_in_role_presets() -> BTreeMap<String, FleetRolePreset> {
1808    [
1809        (
1810            "smoke-runner".to_string(),
1811            FleetRolePreset {
1812                description: Some("Lightweight read-only smoke check worker".to_string()),
1813                tool_profile: Some("read-only".to_string()),
1814                tools: vec![],
1815                capabilities: vec![],
1816                timeout_seconds: Some(300),
1817                trust_level: Some("local".to_string()),
1818            },
1819        ),
1820        (
1821            "reviewer".to_string(),
1822            FleetRolePreset {
1823                description: Some("Read-only code and documentation review".to_string()),
1824                tool_profile: Some("read-only".to_string()),
1825                tools: vec![],
1826                capabilities: vec![],
1827                timeout_seconds: Some(600),
1828                trust_level: None,
1829            },
1830        ),
1831        (
1832            "builder".to_string(),
1833            FleetRolePreset {
1834                description: Some(
1835                    "Read-write builder with compilation and test access".to_string(),
1836                ),
1837                tool_profile: Some("read-write".to_string()),
1838                tools: vec![],
1839                capabilities: vec![],
1840                timeout_seconds: Some(1800),
1841                trust_level: Some("local".to_string()),
1842            },
1843        ),
1844        (
1845            "read-only".to_string(),
1846            FleetRolePreset {
1847                description: Some(
1848                    "Minimal read-only observer with no writes or secrets".to_string(),
1849                ),
1850                tool_profile: Some("read-only".to_string()),
1851                tools: vec![],
1852                capabilities: vec![],
1853                timeout_seconds: Some(300),
1854                trust_level: Some("sandbox".to_string()),
1855            },
1856        ),
1857    ]
1858    .into()
1859}
1860
1861/// Verdict policy for the verifier-preview surface (#2093).
1862///
1863/// Only the hunt vocabulary is shipped today. Keeping this typed lets future
1864/// policy additions reject misspellings instead of silently accepting unknown
1865/// strings.
1866#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1867#[serde(rename_all = "snake_case")]
1868pub enum VerifierVerdictPolicy {
1869    #[default]
1870    Hunt,
1871}
1872
1873/// On-disk schema for `[verifier]`.
1874#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1875pub struct VerifierConfigToml {
1876    /// Enable automatic verifier preview when the runtime wires a
1877    /// claim-of-done trigger. Manual `run_verifiers` remains available
1878    /// regardless.
1879    #[serde(default)]
1880    pub enabled: bool,
1881    /// How verifier verdicts map into the goal/hunt system.
1882    #[serde(default)]
1883    pub verdict_policy: VerifierVerdictPolicy,
1884}
1885
1886impl Default for VerifierConfigToml {
1887    fn default() -> Self {
1888        Self {
1889            enabled: false,
1890            verdict_policy: VerifierVerdictPolicy::Hunt,
1891        }
1892    }
1893}
1894
1895/// On-disk schema for the `[network]` table (#135). See `config.example.toml`
1896/// for documentation.
1897#[derive(Debug, Clone, Serialize, Deserialize)]
1898pub struct NetworkPolicyToml {
1899    /// Decision for hosts that are not in `allow` or `deny`. One of
1900    /// `"allow" | "deny" | "prompt"`. Defaults to `"prompt"`.
1901    #[serde(default = "default_network_decision")]
1902    pub default: String,
1903    /// Hosts that are always allowed. Subdomain rules: a leading dot
1904    /// (`.example.com`) matches subdomains but not the apex.
1905    #[serde(default)]
1906    pub allow: Vec<String>,
1907    /// Hosts that are always denied. Deny entries win over allow entries.
1908    #[serde(default)]
1909    pub deny: Vec<String>,
1910    /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an
1911    /// explicitly trusted proxy setup. Literal IP URLs remain blocked.
1912    #[serde(default)]
1913    pub proxy: Vec<String>,
1914    /// Explicit fake-IP placeholder CIDRs for those proxy hosts. The runtime
1915    /// accepts only subnets contained by `198.18.0.0/15`.
1916    #[serde(default)]
1917    pub proxy_fake_ip_cidrs: Vec<String>,
1918    /// Whether to record one audit-log line per outbound network call.
1919    #[serde(default = "default_network_audit")]
1920    pub audit: bool,
1921}
1922
1923fn default_network_decision() -> String {
1924    "prompt".to_string()
1925}
1926
1927fn default_network_audit() -> bool {
1928    true
1929}
1930
1931impl Default for NetworkPolicyToml {
1932    fn default() -> Self {
1933        Self {
1934            default: default_network_decision(),
1935            allow: Vec::new(),
1936            deny: Vec::new(),
1937            proxy: Vec::new(),
1938            proxy_fake_ip_cidrs: Vec::new(),
1939            audit: default_network_audit(),
1940        }
1941    }
1942}
1943
1944/// User-defined LSP server for one file extension (used inside
1945/// [`LspConfigToml::custom`]).
1946#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1947pub struct CustomLspDef {
1948    /// LSP `languageId` value used in `textDocument/didOpen`.
1949    pub language_id: String,
1950    /// Executable to spawn.
1951    pub command: String,
1952    /// Arguments passed to the executable.
1953    #[serde(default)]
1954    pub args: Vec<String>,
1955}
1956
1957/// On-disk schema for the `[lsp]` table (#136). See `config.example.toml`
1958/// for documentation. All fields are optional so the TUI runtime can fall
1959/// back to its own defaults when keys are absent.
1960#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1961pub struct LspConfigToml {
1962    /// Master switch.
1963    pub enabled: Option<bool>,
1964    /// Maximum time to wait for diagnostics after an edit, in milliseconds.
1965    pub poll_after_edit_ms: Option<u64>,
1966    /// Cap on diagnostics surfaced per file.
1967    pub max_diagnostics_per_file: Option<usize>,
1968    /// When `true`, warnings (severity 2) are surfaced in addition to errors.
1969    pub include_warnings: Option<bool>,
1970    /// Optional override for the `language -> [cmd, ...args]` table.
1971    pub servers: Option<BTreeMap<String, Vec<String>>>,
1972    /// User-defined LSP servers for file extensions not in the built-in
1973    /// registry. Keyed by extension (e.g. `"php"`, `"rb"`).
1974    pub custom: Option<BTreeMap<String, CustomLspDef>>,
1975}
1976
1977impl ConfigToml {
1978    /// Exact configured provider id, including a dynamically named custom
1979    /// provider selected by the TUI.
1980    #[must_use]
1981    pub fn provider_id(&self) -> &str {
1982        self.named_custom_provider_id()
1983            .unwrap_or_else(|| self.provider.as_str())
1984    }
1985
1986    /// Return the exact id only when the root selection names a dynamic custom
1987    /// provider rather than the legacy literal `custom` route.
1988    #[must_use]
1989    pub fn named_custom_provider_id(&self) -> Option<&str> {
1990        (self.provider == ProviderKind::Custom)
1991            .then_some(self.selected_provider_id.as_deref())
1992            .flatten()
1993    }
1994
1995    fn named_custom_provider_table(&self, provider_id: &str) -> Result<&toml::value::Table> {
1996        let table = self
1997            .providers
1998            .extras
1999            .get(provider_id)
2000            .and_then(toml::Value::as_table)
2001            .with_context(|| {
2002                format!(
2003                    "custom provider '{provider_id}' requires a matching [providers.{provider_id}] table"
2004                )
2005            })?;
2006        let compatible = table
2007            .get("kind")
2008            .and_then(toml::Value::as_str)
2009            .is_some_and(|kind| {
2010                kind.trim()
2011                    .to_ascii_lowercase()
2012                    .replace('_', "-")
2013                    .eq("openai-compatible")
2014            });
2015        if !compatible {
2016            bail!(
2017                "custom provider '{provider_id}' must set [providers.{provider_id}].kind = \"openai-compatible\""
2018            );
2019        }
2020        Ok(table)
2021    }
2022
2023    fn named_custom_provider_config(&self) -> Option<ProviderConfigToml> {
2024        let provider_id = self.named_custom_provider_id()?;
2025        self.named_custom_provider_table(provider_id).ok()?;
2026        self.providers
2027            .extras
2028            .get(provider_id)
2029            .cloned()?
2030            .try_into()
2031            .ok()
2032    }
2033
2034    fn bind_persisted_provider_id(&mut self, provider_id: &str) -> Result<()> {
2035        self.selected_provider_id = None;
2036        if self.provider != ProviderKind::Custom || provider_id == ProviderKind::Custom.as_str() {
2037            return Ok(());
2038        }
2039
2040        self.named_custom_provider_table(provider_id)?;
2041        self.selected_provider_id = Some(provider_id.to_string());
2042        Ok(())
2043    }
2044
2045    /// Merge safe project-level overrides from `$WORKSPACE/.codewhale/config.toml`
2046    /// or legacy `$WORKSPACE/.deepseek/config.toml`.
2047    ///
2048    /// Repo-local config is untrusted input. This helper intentionally ignores
2049    /// credentials, endpoints, provider selection, auth/session values, telemetry,
2050    /// network policy, skill registry, LSP command tables, and unknown extras.
2051    /// Approval and sandbox values may only tighten the existing user/global
2052    /// posture.
2053    pub fn merge_project_overrides(&mut self, project: ConfigToml) {
2054        if project.default_text_model.is_some() {
2055            self.default_text_model = project.default_text_model;
2056        }
2057        if project.model.is_some() {
2058            self.model = project.model;
2059        }
2060        if project.output_mode.is_some() {
2061            self.output_mode = project.output_mode;
2062        }
2063        if project.verbosity.is_some() {
2064            self.verbosity = project.verbosity;
2065        }
2066        if project.log_level.is_some() {
2067            self.log_level = project.log_level;
2068        }
2069        if let Some(policy) = project.approval_policy
2070            && project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy)
2071        {
2072            self.approval_policy = Some(policy);
2073        }
2074        if let Some(mode) = project.sandbox_mode
2075            && project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode)
2076        {
2077            self.sandbox_mode = Some(mode);
2078        }
2079        if project.tools.is_some() {
2080            self.tools = project.tools;
2081        }
2082        for provider in ProviderKind::ALL {
2083            merge_project_provider_config(
2084                self.providers.for_provider_mut(provider),
2085                project.providers.for_provider(provider),
2086            );
2087        }
2088    }
2089
2090    #[must_use]
2091    pub fn get_value(&self, key: &str) -> Option<String> {
2092        if let Some((provider, field)) = parse_provider_config_key(key) {
2093            return get_provider_config_value(self.providers.for_provider(provider), field);
2094        }
2095
2096        match key {
2097            "provider" => Some(self.provider_id().to_string()),
2098            "stream_chunk_timeout_secs" | "tui.stream_chunk_timeout_secs" => {
2099                Some(self.stream_chunk_timeout_secs().to_string())
2100            }
2101            "api_key" => self.api_key.clone(),
2102            "base_url" => self.base_url.clone(),
2103            "http_headers" => serialize_http_headers(&self.http_headers),
2104            "default_text_model" => self.default_text_model.clone(),
2105            "model" => self.model.clone(),
2106            "auth.mode" => self.auth_mode.clone(),
2107            "output_mode" => self.output_mode.clone(),
2108            "verbosity" => self.verbosity.clone(),
2109            "log_level" => self.log_level.clone(),
2110            "telemetry" => self.telemetry.map(|v| v.to_string()),
2111            "approval_policy" => self.approval_policy.clone(),
2112            "sandbox_mode" => self.sandbox_mode.clone(),
2113            "tools.always_load" => self.tools.as_ref().map(|tools| tools.always_load.join(",")),
2114            "hook_sinks.unix_socket_path" => self
2115                .hook_sinks
2116                .as_ref()
2117                .and_then(|sinks| sinks.unix_socket_path.as_ref())
2118                .map(|path| path.display().to_string()),
2119            _ => self.extras.get(key).map(toml::Value::to_string),
2120        }
2121    }
2122
2123    #[must_use]
2124    pub fn get_display_value(&self, key: &str) -> Option<String> {
2125        if let Some((provider, field)) = parse_provider_config_key(key) {
2126            return get_provider_config_display_value(self.providers.for_provider(provider), field);
2127        }
2128
2129        if key == "http_headers" {
2130            return serialize_http_headers_for_display(&self.http_headers);
2131        }
2132
2133        if let Some(value) = self.extras.get(key) {
2134            return Some(redact_toml_value_for_display(key, value));
2135        }
2136
2137        self.get_value(key).map(|value| {
2138            if is_sensitive_config_key(key) {
2139                redact_secret(&value)
2140            } else {
2141                value
2142            }
2143        })
2144    }
2145
2146    #[must_use]
2147    pub fn stream_chunk_timeout_secs(&self) -> u64 {
2148        let raw = self
2149            .extras
2150            .get("tui")
2151            .and_then(toml::Value::as_table)
2152            .and_then(|table| table.get("stream_chunk_timeout_secs"))
2153            .and_then(toml_value_as_u64)
2154            .or_else(|| {
2155                self.extras
2156                    .get("tui.stream_chunk_timeout_secs")
2157                    .and_then(toml_value_as_u64)
2158            })
2159            .or_else(|| {
2160                self.extras
2161                    .get("stream_chunk_timeout_secs")
2162                    .and_then(toml_value_as_u64)
2163            })
2164            .unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS);
2165        if raw == 0 {
2166            DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
2167        } else {
2168            raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS)
2169        }
2170    }
2171
2172    pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
2173        if let Some((provider, field)) = parse_provider_config_key(key) {
2174            return set_provider_config_value(self, provider, field, value);
2175        }
2176
2177        match key {
2178            "provider" => {
2179                if let Some(provider) = ProviderKind::parse(value) {
2180                    self.provider = provider;
2181                    self.selected_provider_id = None;
2182                } else {
2183                    let provider_id = value.trim();
2184                    self.named_custom_provider_table(provider_id)
2185                        .with_context(|| {
2186                            format!(
2187                                "unknown provider '{value}': expected {} or a configured custom provider",
2188                                ProviderKind::names_hint()
2189                            )
2190                        })?;
2191                    self.provider = ProviderKind::Custom;
2192                    self.selected_provider_id = Some(provider_id.to_string());
2193                }
2194            }
2195            "api_key" => self.api_key = Some(value.to_string()),
2196            "base_url" => self.base_url = Some(value.to_string()),
2197            "http_headers" => self.http_headers = parse_http_headers(value)?,
2198            "default_text_model" => self.default_text_model = Some(value.to_string()),
2199            "model" => self.model = Some(value.to_string()),
2200            "auth.mode" => self.auth_mode = Some(value.to_string()),
2201            "output_mode" => self.output_mode = Some(value.to_string()),
2202            "verbosity" => self.verbosity = Some(value.to_string()),
2203            "log_level" => self.log_level = Some(value.to_string()),
2204            "telemetry" => {
2205                self.telemetry = Some(parse_bool(value)?);
2206            }
2207            "approval_policy" => self.approval_policy = Some(value.to_string()),
2208            "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
2209            "hook_sinks.unix_socket_path" => {
2210                self.hook_sinks
2211                    .get_or_insert_with(HookSinksToml::default)
2212                    .unix_socket_path = Some(PathBuf::from(value));
2213            }
2214            _ => {
2215                self.extras
2216                    .insert(key.to_string(), toml::Value::String(value.to_string()));
2217            }
2218        }
2219        Ok(())
2220    }
2221
2222    pub fn unset_value(&mut self, key: &str) -> Result<()> {
2223        if let Some((provider, field)) = parse_provider_config_key(key) {
2224            unset_provider_config_value(self, provider, field);
2225            return Ok(());
2226        }
2227
2228        match key {
2229            "provider" => {
2230                self.provider = ProviderKind::Deepseek;
2231                self.selected_provider_id = None;
2232            }
2233            "api_key" => self.api_key = None,
2234            "base_url" => self.base_url = None,
2235            "http_headers" => self.http_headers.clear(),
2236            "default_text_model" => self.default_text_model = None,
2237            "model" => self.model = None,
2238            "auth.mode" => self.auth_mode = None,
2239            "output_mode" => self.output_mode = None,
2240            "verbosity" => self.verbosity = None,
2241            "log_level" => self.log_level = None,
2242            "telemetry" => self.telemetry = None,
2243            "approval_policy" => self.approval_policy = None,
2244            "sandbox_mode" => self.sandbox_mode = None,
2245            "hook_sinks.unix_socket_path" => {
2246                if let Some(sinks) = self.hook_sinks.as_mut() {
2247                    sinks.unix_socket_path = None;
2248                }
2249            }
2250            _ => {
2251                self.extras.remove(key);
2252            }
2253        }
2254        Ok(())
2255    }
2256
2257    #[must_use]
2258    pub fn list_values(&self) -> BTreeMap<String, String> {
2259        let mut out = BTreeMap::new();
2260        out.insert("provider".to_string(), self.provider_id().to_string());
2261
2262        if let Some(v) = self.api_key.as_ref() {
2263            out.insert("api_key".to_string(), redact_secret(v));
2264        }
2265        if let Some(v) = self.base_url.as_ref() {
2266            out.insert("base_url".to_string(), v.clone());
2267        }
2268        if let Some(v) = serialize_http_headers_for_display(&self.http_headers) {
2269            out.insert("http_headers".to_string(), v);
2270        }
2271        if let Some(v) = self.default_text_model.as_ref() {
2272            out.insert("default_text_model".to_string(), v.clone());
2273        }
2274        if let Some(v) = self.model.as_ref() {
2275            out.insert("model".to_string(), v.clone());
2276        }
2277        if let Some(v) = self.auth_mode.as_ref() {
2278            out.insert("auth.mode".to_string(), v.clone());
2279        }
2280        if let Some(v) = self.output_mode.as_ref() {
2281            out.insert("output_mode".to_string(), v.clone());
2282        }
2283        if let Some(v) = self.verbosity.as_ref() {
2284            out.insert("verbosity".to_string(), v.clone());
2285        }
2286        if let Some(v) = self.log_level.as_ref() {
2287            out.insert("log_level".to_string(), v.clone());
2288        }
2289        if let Some(v) = self.telemetry {
2290            out.insert("telemetry".to_string(), v.to_string());
2291        }
2292        if let Some(v) = self.approval_policy.as_ref() {
2293            out.insert("approval_policy".to_string(), v.clone());
2294        }
2295        if let Some(v) = self.sandbox_mode.as_ref() {
2296            out.insert("sandbox_mode".to_string(), v.clone());
2297        }
2298        if let Some(v) = self
2299            .hook_sinks
2300            .as_ref()
2301            .and_then(|sinks| sinks.unix_socket_path.as_ref())
2302        {
2303            out.insert(
2304                "hook_sinks.unix_socket_path".to_string(),
2305                v.display().to_string(),
2306            );
2307        }
2308
2309        for provider in ProviderKind::ALL {
2310            insert_provider_config_values(
2311                &mut out,
2312                provider,
2313                self.providers.for_provider(provider),
2314            );
2315        }
2316
2317        for (k, v) in &self.extras {
2318            out.insert(k.clone(), redact_toml_value_for_display(k, v));
2319        }
2320        out
2321    }
2322
2323    /// Resolve runtime options without touching platform credential stores.
2324    ///
2325    /// This method keeps library callers prompt-free: CLI flag → config file
2326    /// → environment. Call `resolve_runtime_options_with_secrets` when a
2327    /// user-facing dispatcher should recover credentials from the configured
2328    /// secret store.
2329    #[must_use]
2330    pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
2331        let no_keyring = Secrets::new(std::sync::Arc::new(
2332            codewhale_secrets::InMemoryKeyringStore::new(),
2333        ));
2334        self.resolve_runtime_options_with_secrets(cli, &no_keyring)
2335    }
2336
2337    /// Resolve runtime options using an explicit secrets façade.
2338    ///
2339    /// API-key precedence is **CLI flag → config-file → secret store → environment**.
2340    #[must_use]
2341    pub fn resolve_runtime_options_with_secrets(
2342        &self,
2343        cli: &CliRuntimeOverrides,
2344        secrets: &Secrets,
2345    ) -> ResolvedRuntimeOptions {
2346        let env = EnvRuntimeOverrides::load();
2347        let (provider, provider_source) = if let Some(provider) = cli.provider {
2348            (provider, ProviderSource::Cli)
2349        } else if let Some(provider) = env.provider {
2350            (
2351                provider,
2352                ProviderSource::Env(env.provider_source.unwrap_or("CODEWHALE_PROVIDER")),
2353            )
2354        } else {
2355            (self.provider, ProviderSource::Config)
2356        };
2357
2358        let mut provider_cfg = if provider == ProviderKind::Custom
2359            && matches!(provider_source, ProviderSource::Config)
2360        {
2361            self.named_custom_provider_config()
2362                .unwrap_or_else(|| self.providers.for_provider(provider).clone())
2363        } else {
2364            self.providers.for_provider(provider).clone()
2365        };
2366        if provider == ProviderKind::SiliconflowCN {
2367            let fb = &self.providers.siliconflow;
2368            if provider_cfg.api_key.is_none() {
2369                provider_cfg.api_key = fb.api_key.clone();
2370            }
2371            if provider_cfg.base_url.is_none() {
2372                provider_cfg.base_url = fb.base_url.clone();
2373            }
2374            if provider_cfg.model.is_none() {
2375                provider_cfg.model = fb.model.clone();
2376            }
2377        }
2378        let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
2379            .then(|| self.api_key.clone())
2380            .flatten();
2381        let root_deepseek_base_url = (provider == ProviderKind::Deepseek)
2382            .then(|| self.base_url.clone())
2383            .flatten();
2384        let root_deepseek_model = (provider == ProviderKind::Deepseek)
2385            .then(|| self.default_text_model.clone())
2386            .flatten();
2387        let auth_mode = cli
2388            .auth_mode
2389            .clone()
2390            .or_else(|| env.auth_mode.clone())
2391            .or_else(|| provider_cfg.auth_mode.clone())
2392            .or_else(|| self.auth_mode.clone());
2393        let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
2394        let cli_base_url = cli.base_url.clone();
2395        let env_base_url = env.base_url_for(provider);
2396        let file_base_url = provider_cfg.base_url.clone().or(root_deepseek_base_url);
2397        let base_url_from_file =
2398            cli_base_url.is_none() && env_base_url.is_none() && file_base_url.is_some();
2399        let configured_base_url = cli_base_url.or(env_base_url).or(file_base_url);
2400        let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo {
2401            env.xiaomi_mimo_mode
2402                .clone()
2403                .or_else(|| provider_cfg.mode.clone())
2404        } else {
2405            None
2406        };
2407        let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo {
2408            xiaomi_mimo_env_api_key_for_runtime(
2409                xiaomi_mimo_mode.as_deref(),
2410                configured_base_url.as_deref(),
2411            )
2412        } else {
2413            None
2414        };
2415        let explicit_api_key_for_endpoint = cli
2416            .api_key
2417            .as_deref()
2418            .or(from_file.as_deref())
2419            .or(xiaomi_mimo_env_api_key.as_deref());
2420        let base_url = if provider == ProviderKind::XiaomiMimo {
2421            resolve_xiaomi_mimo_base_url(
2422                configured_base_url,
2423                explicit_api_key_for_endpoint,
2424                xiaomi_mimo_mode.as_deref(),
2425            )
2426        } else {
2427            configured_base_url.unwrap_or_else(|| match provider {
2428                ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
2429                ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string(),
2430                ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
2431                ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
2432                ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
2433                ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
2434                ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
2435                ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
2436                ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
2437                ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
2438                ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
2439                ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
2440                ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL.to_string(),
2441                ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL.to_string(),
2442                ProviderKind::Moonshot => {
2443                    if auth_mode
2444                        .as_deref()
2445                        .is_some_and(auth_mode_uses_kimi_imported_token)
2446                    {
2447                        DEFAULT_KIMI_CODE_BASE_URL.to_string()
2448                    } else {
2449                        DEFAULT_MOONSHOT_BASE_URL.to_string()
2450                    }
2451                }
2452                ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
2453                ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
2454                ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
2455                ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(),
2456                ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL.to_string(),
2457                ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL.to_string(),
2458                ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
2459                ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(),
2460                ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL.to_string(),
2461                ProviderKind::Zai => DEFAULT_ZAI_BASE_URL.to_string(),
2462                ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL.to_string(),
2463                ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL.to_string(),
2464                ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string(),
2465                ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL.to_string(),
2466                ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL.to_string(),
2467                ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL.to_string(),
2468                ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL.to_string(),
2469                ProviderKind::Meta => DEFAULT_META_BASE_URL.to_string(),
2470                ProviderKind::Xai => DEFAULT_XAI_BASE_URL.to_string(),
2471                ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL.to_string(),
2472                // The custom provider has no built-in endpoint; fall back to its
2473                // descriptor placeholder so the lookup is total. Real custom
2474                // routes always supply a configured base_url before this point.
2475                ProviderKind::Custom => provider.provider().default_base_url().to_string(),
2476            })
2477        };
2478        // `auth_mode = "none"` is an endpoint contract, so it suppresses every
2479        // credential source (including explicit CLI/config values). Otherwise
2480        // CLI and route-local config win outright. Ambient provider credentials
2481        // are allowed only on the provider's official endpoint family: a saved
2482        // OpenRouter key must never follow `provider = "openrouter"` to an
2483        // unrelated custom gateway merely because the provider id stayed the
2484        // same.
2485        let uses_kimi_imported_token = provider == ProviderKind::Moonshot
2486            && auth_mode
2487                .as_deref()
2488                .is_some_and(auth_mode_uses_kimi_imported_token);
2489        let auth_disabled = auth_mode_disables_api_key(auth_mode.as_deref());
2490        let custom_endpoint = provider_preserves_custom_base_url_model(provider, &base_url);
2491        let (api_key, api_key_source) = if auth_disabled {
2492            (None, None)
2493        } else if let Some(value) = cli.api_key.clone() {
2494            (Some(value), Some(RuntimeApiKeySource::Cli))
2495        } else if uses_kimi_imported_token && !custom_endpoint {
2496            (None, None)
2497        } else if (!custom_endpoint || base_url_from_file)
2498            && let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty())
2499        {
2500            (Some(value), Some(RuntimeApiKeySource::ConfigFile))
2501        } else if !custom_endpoint
2502            && let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty())
2503        {
2504            (Some(value), Some(RuntimeApiKeySource::Env))
2505        } else if custom_endpoint {
2506            (None, None)
2507        } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
2508            match env_api_key_for_provider(provider) {
2509                Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
2510                None => (None, None),
2511            }
2512        } else {
2513            match secrets.resolve_with_source(provider.as_str()) {
2514                Some((value, source)) => {
2515                    let source = match source {
2516                        SecretSource::Keyring => RuntimeApiKeySource::Keyring,
2517                        SecretSource::Env => RuntimeApiKeySource::Env,
2518                    };
2519                    (Some(value), Some(source))
2520                }
2521                None => match env_api_key_for_provider(provider) {
2522                    Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
2523                    None => (None, None),
2524                },
2525            }
2526        };
2527
2528        let env_provider_model = env.model_for(provider, &base_url);
2529        let explicit_model = cli.model.is_some()
2530            || env.model.is_some()
2531            || env_provider_model.is_some()
2532            || provider_cfg.model.is_some()
2533            || root_deepseek_model.is_some()
2534            || self.model.is_some();
2535        let model = cli
2536            .model
2537            .clone()
2538            .or_else(|| env.model.clone())
2539            .or(env_provider_model)
2540            .or_else(|| provider_cfg.model.clone())
2541            .or(root_deepseek_model)
2542            .or_else(|| self.model.clone())
2543            .unwrap_or_else(|| {
2544                if provider == ProviderKind::Moonshot
2545                    && (auth_mode
2546                        .as_deref()
2547                        .is_some_and(auth_mode_uses_kimi_imported_token)
2548                        || moonshot_base_url_uses_kimi_code(&base_url))
2549                {
2550                    DEFAULT_KIMI_CODE_MODEL.to_string()
2551                } else {
2552                    default_model_for_provider(provider).to_string()
2553                }
2554            });
2555        let model = if provider == ProviderKind::OpencodeGo {
2556            // OpenCode Go's `/models` response also contains models that only
2557            // speak Anthropic Messages. This provider is deliberately bound to
2558            // Chat Completions, so even custom endpoint/env overrides cannot
2559            // promote an incompatible id onto `/chat/completions`.
2560            normalize_model_for_provider(provider, &model)
2561        } else if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
2562            model.trim().to_string()
2563        } else {
2564            normalize_model_for_provider(provider, &model)
2565        };
2566
2567        let mut http_headers = self.http_headers.clone();
2568        http_headers.extend(provider_cfg.http_headers.clone());
2569        if let Some(env_headers) = env.http_headers {
2570            http_headers.extend(env_headers);
2571        }
2572        http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
2573        if auth_disabled {
2574            http_headers.retain(|name, _| !is_upstream_auth_header(name));
2575        }
2576
2577        let output_mode = cli
2578            .output_mode
2579            .clone()
2580            .or_else(|| env.output_mode.clone())
2581            .or_else(|| self.output_mode.clone());
2582        let log_level = cli
2583            .log_level
2584            .clone()
2585            .or_else(|| env.log_level.clone())
2586            .or_else(|| self.log_level.clone());
2587        let telemetry = cli
2588            .telemetry
2589            .or(env.telemetry)
2590            .or(self.telemetry)
2591            .unwrap_or(false);
2592        let approval_policy = cli
2593            .approval_policy
2594            .clone()
2595            .or_else(|| env.approval_policy.clone())
2596            .or_else(|| self.approval_policy.clone());
2597        let sandbox_mode = cli
2598            .sandbox_mode
2599            .clone()
2600            .or_else(|| env.sandbox_mode.clone())
2601            .or_else(|| self.sandbox_mode.clone());
2602        let yolo = cli.yolo.or(env.yolo);
2603        let verbosity = cli
2604            .verbosity
2605            .clone()
2606            .or_else(|| env.verbosity.clone())
2607            .or_else(|| self.verbosity.clone());
2608
2609        ResolvedRuntimeOptions {
2610            provider,
2611            provider_source,
2612            model,
2613            api_key,
2614            api_key_source,
2615            base_url,
2616            auth_mode,
2617            insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false),
2618            output_mode,
2619            log_level,
2620            telemetry,
2621            approval_policy,
2622            sandbox_mode,
2623            yolo,
2624            verbosity,
2625            http_headers,
2626        }
2627    }
2628}
2629
2630fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
2631    if source.model.is_some() {
2632        target.model = source.model.clone();
2633    }
2634}
2635
2636#[must_use]
2637pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
2638    let Some(project_rank) = approval_policy_rank(project) else {
2639        return false;
2640    };
2641    match current.and_then(approval_policy_rank) {
2642        Some(current_rank) => project_rank >= current_rank,
2643        None => project_rank >= 2,
2644    }
2645}
2646
2647#[must_use]
2648pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
2649    let normalized_project = project.trim().to_ascii_lowercase();
2650    if normalized_project == "external-sandbox" {
2651        return current
2652            .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
2653            .unwrap_or(false);
2654    }
2655
2656    let Some(project_rank) = sandbox_mode_rank(project) else {
2657        return false;
2658    };
2659    match current.and_then(sandbox_mode_rank) {
2660        Some(current_rank) => project_rank >= current_rank,
2661        None => project_rank >= 2,
2662    }
2663}
2664
2665fn approval_policy_rank(value: &str) -> Option<u8> {
2666    match value.trim().to_ascii_lowercase().as_str() {
2667        "auto" => Some(0),
2668        "suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
2669        "never" | "deny" | "denied" => Some(2),
2670        _ => None,
2671    }
2672}
2673
2674fn sandbox_mode_rank(value: &str) -> Option<u8> {
2675    match value.trim().to_ascii_lowercase().as_str() {
2676        "danger-full-access" => Some(0),
2677        "external-sandbox" => Some(0),
2678        "workspace-write" => Some(1),
2679        "read-only" => Some(2),
2680        _ => None,
2681    }
2682}
2683
2684/// Load a project-level config from the workspace.
2685///
2686/// Checks `$WORKSPACE/.codewhale/config.toml` first, falling back to
2687/// `$WORKSPACE/.deepseek/config.toml` for backward compatibility.
2688/// Returns `None` if neither file exists or can't be parsed.
2689pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
2690    for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
2691        let path = workspace.join(dir).join(CONFIG_FILE_NAME);
2692        if !project_config_candidate_exists(&path) {
2693            continue;
2694        }
2695        let raw = match read_checked_config_file(&path) {
2696            Ok(raw) => raw,
2697            Err(e) => {
2698                tracing::warn!("Failed to read project config {}: {e:#}", path.display());
2699                return None;
2700            }
2701        };
2702        match toml::from_str::<ConfigToml>(&raw) {
2703            Ok(config) => {
2704                let raw_provider = toml::from_str::<toml::Value>(&raw)
2705                    .ok()
2706                    .and_then(|document| document.get("provider").cloned())
2707                    .and_then(|provider| provider.as_str().map(str::to_string));
2708                if config.provider == ProviderKind::Custom
2709                    && raw_provider.as_deref() != Some(ProviderKind::Custom.as_str())
2710                {
2711                    tracing::warn!(
2712                        "Failed to parse project config {}; file contents were omitted",
2713                        quote_os_path(&path)
2714                    );
2715                    return None;
2716                }
2717                return Some(config);
2718            }
2719            Err(_) => {
2720                tracing::warn!(
2721                    "Failed to parse project config {}; file contents were omitted",
2722                    quote_os_path(&path)
2723                );
2724                return None;
2725            }
2726        }
2727    }
2728    None
2729}
2730
2731fn project_config_candidate_exists(path: &Path) -> bool {
2732    fs::symlink_metadata(path).is_ok_and(|metadata| {
2733        let file_type = metadata.file_type();
2734        file_type.is_file() || file_type.is_symlink()
2735    })
2736}
2737
2738fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
2739    if matches!(provider, ProviderKind::OpencodeGo) {
2740        return opencode_go_chat_model_id(model)
2741            .unwrap_or(DEFAULT_OPENCODE_GO_MODEL)
2742            .to_string();
2743    }
2744    if matches!(provider, ProviderKind::XiaomiMimo)
2745        && let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
2746    {
2747        return canonical.to_string();
2748    }
2749    if matches!(
2750        provider,
2751        ProviderKind::Minimax | ProviderKind::MinimaxAnthropic
2752    ) && let Some(canonical) = canonical_minimax_model_id(model)
2753    {
2754        return canonical.to_string();
2755    }
2756    if matches!(provider, ProviderKind::Zai)
2757        && let Some(canonical) = canonical_zai_model_id(model)
2758    {
2759        return canonical.to_string();
2760    }
2761
2762    if matches!(
2763        provider,
2764        ProviderKind::Atlascloud
2765            | ProviderKind::WanjieArk
2766            | ProviderKind::Volcengine
2767            | ProviderKind::XiaomiMimo
2768            | ProviderKind::Zai
2769            | ProviderKind::Stepfun
2770            | ProviderKind::Minimax
2771            | ProviderKind::MinimaxAnthropic
2772            | ProviderKind::Qianfan
2773            | ProviderKind::Ollama
2774            | ProviderKind::Meta
2775            | ProviderKind::Xai
2776    ) {
2777        return model.to_string();
2778    }
2779
2780    let normalized = model.trim().to_ascii_lowercase();
2781    if provider == ProviderKind::Openrouter
2782        && let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
2783    {
2784        return canonical.to_string();
2785    }
2786    match (provider, normalized.as_str()) {
2787        (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
2788            DEFAULT_NVIDIA_NIM_MODEL.to_string()
2789        }
2790        (
2791            ProviderKind::NvidiaNim,
2792            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2793            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2794        ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
2795        (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
2796            DEFAULT_OPENROUTER_MODEL.to_string()
2797        }
2798        (
2799            ProviderKind::Openrouter,
2800            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2801            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2802        ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
2803        (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
2804            DEFAULT_NOVITA_MODEL.to_string()
2805        }
2806        (
2807            ProviderKind::Novita,
2808            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2809            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2810        ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
2811        (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
2812            DEFAULT_FIREWORKS_MODEL.to_string()
2813        }
2814        (
2815            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2816            "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
2817        ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
2818        (
2819            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2820            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
2821        ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
2822        (
2823            ProviderKind::Arcee,
2824            "trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking",
2825        ) => DEFAULT_ARCEE_MODEL.to_string(),
2826        (ProviderKind::Arcee, "trinity-mini" | "arcee-trinity-mini") => {
2827            ARCEE_TRINITY_MINI_MODEL.to_string()
2828        }
2829        (ProviderKind::Arcee, "arcee-trinity-large-preview") => {
2830            ARCEE_TRINITY_LARGE_PREVIEW_MODEL.to_string()
2831        }
2832        (
2833            ProviderKind::Moonshot,
2834            "kimi"
2835            | "kimi-k2"
2836            | "kimi-k2.7"
2837            | "kimi-k2-7"
2838            | "kimi-k2.7-code"
2839            | "kimi-k2-7-code"
2840            | "kimi-code"
2841            | "moonshot-kimi-k2.7-code",
2842        ) => DEFAULT_MOONSHOT_MODEL.to_string(),
2843        (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
2844            MOONSHOT_KIMI_K2_6_MODEL.to_string()
2845        }
2846        (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
2847            DEFAULT_SGLANG_MODEL.to_string()
2848        }
2849        (
2850            ProviderKind::Sglang,
2851            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2852            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2853        ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
2854        (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
2855            DEFAULT_VLLM_MODEL.to_string()
2856        }
2857        (
2858            ProviderKind::Vllm,
2859            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2860            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2861        ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
2862        (ProviderKind::Huggingface, "deepseek-v4-pro" | "deepseek-v4pro") => {
2863            DEFAULT_HUGGINGFACE_MODEL.to_string()
2864        }
2865        (
2866            ProviderKind::Huggingface,
2867            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2868            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2869        ) => DEFAULT_HUGGINGFACE_FLASH_MODEL.to_string(),
2870        (ProviderKind::Together, "deepseek-v4-pro" | "deepseek-v4pro") => {
2871            DEFAULT_TOGETHER_MODEL.to_string()
2872        }
2873        (
2874            ProviderKind::Together,
2875            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2876            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2877        ) => DEFAULT_TOGETHER_FLASH_MODEL.to_string(),
2878        (ProviderKind::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
2879            DEFAULT_DEEPINFRA_MODEL.to_string()
2880        }
2881        (
2882            ProviderKind::Deepinfra,
2883            "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2884            | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2885        ) => DEFAULT_DEEPINFRA_FLASH_MODEL.to_string(),
2886        _ => model.to_string(),
2887    }
2888}
2889
2890/// OpenCode Go models documented for its OpenAI Chat Completions endpoint.
2891///
2892/// Keep config validation, picker/catalog projections, and live-roster
2893/// sanitization on this one protocol-scoped contract. The provider's combined
2894/// `/models` roster also contains Anthropic-Messages-only models, which are
2895/// deliberately absent here.
2896pub const OPENCODE_GO_CHAT_MODELS: &[&str] = &[
2897    DEFAULT_OPENCODE_GO_MODEL,
2898    OPENCODE_GO_GROK_4_5_MODEL,
2899    OPENCODE_GO_GLM_5_2_MODEL,
2900    OPENCODE_GO_GLM_5_1_MODEL,
2901    OPENCODE_GO_KIMI_K3_MODEL,
2902    OPENCODE_GO_KIMI_K2_7_CODE_MODEL,
2903    OPENCODE_GO_KIMI_K2_6_MODEL,
2904    OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL,
2905    OPENCODE_GO_MIMO_V2_5_MODEL,
2906    OPENCODE_GO_MIMO_V2_5_PRO_MODEL,
2907];
2908
2909/// Canonicalize an OpenCode Go model that is documented for the OpenAI Chat
2910/// Completions endpoint. The live `/models` roster also contains
2911/// Anthropic-Messages-only models; returning `None` for those is the protocol
2912/// cutline shared by config and the TUI live-catalog paths.
2913#[must_use]
2914pub fn opencode_go_chat_model_id(model: &str) -> Option<&'static str> {
2915    let normalized = model.trim().to_ascii_lowercase().replace(['_', ' '], "-");
2916    let normalized = normalized
2917        .strip_prefix("opencode-go/")
2918        .unwrap_or(&normalized);
2919    let familiar_alias = match normalized {
2920        "grok-4-5" => Some(OPENCODE_GO_GROK_4_5_MODEL),
2921        "glm-5-2" => Some(OPENCODE_GO_GLM_5_2_MODEL),
2922        "glm-5-1" => Some(OPENCODE_GO_GLM_5_1_MODEL),
2923        "kimi-k2-7-code" => Some(OPENCODE_GO_KIMI_K2_7_CODE_MODEL),
2924        "kimi-k2-6" => Some(OPENCODE_GO_KIMI_K2_6_MODEL),
2925        "deepseek-v4pro" => Some(DEFAULT_OPENCODE_GO_MODEL),
2926        "deepseek-v4flash" => Some(OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL),
2927        "mimo-v2-5" => Some(OPENCODE_GO_MIMO_V2_5_MODEL),
2928        "mimo-v2-5-pro" => Some(OPENCODE_GO_MIMO_V2_5_PRO_MODEL),
2929        _ => None,
2930    };
2931    familiar_alias.or_else(|| {
2932        OPENCODE_GO_CHAT_MODELS
2933            .iter()
2934            .copied()
2935            .find(|candidate| *candidate == normalized)
2936    })
2937}
2938
2939fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> {
2940    let normalized = model.trim().to_ascii_lowercase();
2941    let normalized = normalized.replace(['_', ' '], "-");
2942    match normalized.as_str() {
2943        "mimo"
2944        | DEFAULT_XIAOMI_MIMO_MODEL
2945        | "mimo-v2-5-pro"
2946        | "xiaomi-mimo-v2.5-pro"
2947        | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL),
2948        XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL
2949        | "mimo-v2-5-pro-ultraspeed"
2950        | "xiaomi-mimo-v2.5-pro-ultraspeed"
2951        | "xiaomi-mimo-v2-5-pro-ultraspeed"
2952        | "ultraspeed"
2953        | "pro-ultraspeed" => Some(XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL),
2954        "omni"
2955        | "mimo-omni"
2956        | "v2.5-omni"
2957        | "v25-omni"
2958        | "mimo-v2.5"
2959        | "mimo-v25"
2960        | "mimo-v2-5"
2961        | "mimo-v2.5-omni"
2962        | "mimo-v25-omni"
2963        | "mimo-v2-5-omni"
2964        | "xiaomi-mimo-v2.5"
2965        | "xiaomi-mimo-v2-5"
2966        | "xiaomi-mimo-v2.5-omni"
2967        | "xiaomi-mimo-v2-5-omni" => Some(XIAOMI_MIMO_V2_5_OMNI_MODEL),
2968        "asr" | "mimo-asr" | "mimo-v2.5-asr" | "speech-to-text" | "transcribe" => {
2969            Some(XIAOMI_MIMO_ASR_MODEL)
2970        }
2971        "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => {
2972            Some(XIAOMI_MIMO_TTS_MODEL)
2973        }
2974        "mimo-tts-voicedesign"
2975        | "mimo-voice-design"
2976        | "mimo-v25-tts-voicedesign"
2977        | "mimo-v2.5-tts-voicedesign"
2978        | "voicedesign"
2979        | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL),
2980        "mimo-tts-voiceclone"
2981        | "mimo-voice-clone"
2982        | "mimo-v25-tts-voiceclone"
2983        | "mimo-v2.5-tts-voiceclone"
2984        | "voiceclone"
2985        | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL),
2986        "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL),
2987        _ => None,
2988    }
2989}
2990
2991fn canonical_minimax_model_id(model: &str) -> Option<&'static str> {
2992    let normalized = model.trim().to_ascii_lowercase();
2993    let normalized = normalized.replace(['_', ' '], "-");
2994    match normalized.as_str() {
2995        "minimax" | "minimax-m3" | "minimax-m-3" | "minimax-m-3-thinking" => {
2996            Some(DEFAULT_MINIMAX_MODEL)
2997        }
2998        "minimax-m2.7" | "minimax-m2-7" | "minimax-m-2.7" | "minimax-m-2-7" => {
2999            Some(MINIMAX_M2_7_MODEL)
3000        }
3001        "minimax-m2.7-highspeed"
3002        | "minimax-m2-7-highspeed"
3003        | "minimax-m-2.7-highspeed"
3004        | "minimax-m-2-7-highspeed" => Some(MINIMAX_M2_7_HIGHSPEED_MODEL),
3005        "minimax-m2.5" | "minimax-m2-5" | "minimax-m-2.5" | "minimax-m-2-5" => {
3006            Some(MINIMAX_M2_5_MODEL)
3007        }
3008        "minimax-m2.5-highspeed"
3009        | "minimax-m2-5-highspeed"
3010        | "minimax-m-2.5-highspeed"
3011        | "minimax-m-2-5-highspeed" => Some(MINIMAX_M2_5_HIGHSPEED_MODEL),
3012        "minimax-m2.1" | "minimax-m2-1" | "minimax-m-2.1" | "minimax-m-2-1" => {
3013            Some(MINIMAX_M2_1_MODEL)
3014        }
3015        "minimax-m2.1-highspeed"
3016        | "minimax-m2-1-highspeed"
3017        | "minimax-m-2.1-highspeed"
3018        | "minimax-m-2-1-highspeed" => Some(MINIMAX_M2_1_HIGHSPEED_MODEL),
3019        "minimax-m2" | "minimax-m-2" => Some(MINIMAX_M2_MODEL),
3020        _ => None,
3021    }
3022}
3023
3024fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
3025    let normalized = model.trim().to_ascii_lowercase();
3026    let normalized = normalized.replace(['_', ' '], "-");
3027    match normalized.as_str() {
3028        "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
3029        "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(DEFAULT_ZAI_MODEL),
3030        "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
3031        _ => None,
3032    }
3033}
3034
3035fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
3036    let normalized = model.trim().to_ascii_lowercase();
3037    let normalized = normalized.replace(['_', ' '], "-");
3038    match normalized.as_str() {
3039        OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
3040        | "trinity"
3041        | "trinity-large-thinking"
3042        | "arcee-trinity"
3043        | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
3044        OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
3045            Some(OPENROUTER_GEMMA_4_31B_MODEL)
3046        }
3047        OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
3048            Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
3049        }
3050        OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
3051            Some(OPENROUTER_GLM_5_1_MODEL)
3052        }
3053        OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
3054            Some(OPENROUTER_GLM_5_2_MODEL)
3055        }
3056        OPENROUTER_KIMI_K2_7_CODE_MODEL
3057        | "kimi"
3058        | "kimi-k2"
3059        | "kimi-k2.7"
3060        | "kimi-k2-7"
3061        | "kimi-k2.7-code"
3062        | "kimi-k2-7-code"
3063        | "kimi-code"
3064        | "moonshot-kimi-k2.7-code"
3065        | "openrouter-kimi-k2.7-code" => Some(OPENROUTER_KIMI_K2_7_CODE_MODEL),
3066        OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
3067            Some(OPENROUTER_KIMI_K2_6_MODEL)
3068        }
3069        OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => {
3070            Some(OPENROUTER_MINIMAX_M3_MODEL)
3071        }
3072        OPENROUTER_MINIMAX_M2_7_MODEL
3073        | "minimax-2.7"
3074        | "minimax-2-7"
3075        | "minimax-m2.7"
3076        | "minimax-m2-7"
3077        | "minimax-m-2.7"
3078        | "minimax-m-2-7" => Some(OPENROUTER_MINIMAX_M2_7_MODEL),
3079        OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
3080        | "nemotron-3-nano-omni"
3081        | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
3082        OPENROUTER_QWEN_3_6_35B_A3B_MODEL
3083        | "qwen3.6-35b-a3b"
3084        | "qwen-3.6-35b-a3b"
3085        | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
3086        OPENROUTER_QWEN_3_6_FLASH_MODEL | "qwen3.6-flash" | "qwen-3.6-flash" => {
3087            Some(OPENROUTER_QWEN_3_6_FLASH_MODEL)
3088        }
3089        OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL
3090        | "qwen3.6-max-preview"
3091        | "qwen-3.6-max-preview"
3092        | "qwen-max-preview" => Some(OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
3093        OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
3094            Some(OPENROUTER_QWEN_3_6_27B_MODEL)
3095        }
3096        OPENROUTER_QWEN_3_6_PLUS_MODEL | "qwen3.6-plus" | "qwen-3.6-plus" => {
3097            Some(OPENROUTER_QWEN_3_6_PLUS_MODEL)
3098        }
3099        OPENROUTER_QWEN_3_7_PLUS_MODEL | "qwen3.7-plus" | "qwen-3.7-plus" => {
3100            Some(OPENROUTER_QWEN_3_7_PLUS_MODEL)
3101        }
3102        OPENROUTER_QWEN_3_7_MAX_MODEL | "qwen3.7-max" | "qwen-3.7-max" => {
3103            Some(OPENROUTER_QWEN_3_7_MAX_MODEL)
3104        }
3105        OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
3106            Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
3107        }
3108        OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
3109        | "mimo-v2.5-pro"
3110        | "mimo-v2-5-pro"
3111        | "xiaomi-mimo-v2.5-pro"
3112        | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
3113        OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
3114        | "mimo-v2.5"
3115        | "mimo-v2-5"
3116        | "xiaomi-mimo-v2.5"
3117        | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
3118        _ => None,
3119    }
3120}
3121
3122fn default_model_for_provider(provider: ProviderKind) -> &'static str {
3123    match provider {
3124        ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
3125        ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_MODEL,
3126        ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
3127        ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
3128        ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
3129        ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
3130        ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
3131        ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
3132        ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
3133        ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
3134        ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
3135        ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_MODEL,
3136        ProviderKind::Arcee => DEFAULT_ARCEE_MODEL,
3137        ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
3138        ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
3139        ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
3140        ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
3141        ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
3142        ProviderKind::Together => DEFAULT_TOGETHER_MODEL,
3143        ProviderKind::Qianfan => DEFAULT_QIANFAN_MODEL,
3144        ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL,
3145        ProviderKind::Anthropic => DEFAULT_ANTHROPIC_MODEL,
3146        ProviderKind::Openmodel => DEFAULT_OPENMODEL_MODEL,
3147        ProviderKind::Zai => DEFAULT_ZAI_MODEL,
3148        ProviderKind::Stepfun => DEFAULT_STEPFUN_MODEL,
3149        ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_MODEL,
3150        ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
3151        ProviderKind::Sakana => DEFAULT_SAKANA_MODEL,
3152        ProviderKind::LongCat => DEFAULT_LONGCAT_MODEL,
3153        ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_MODEL,
3154        ProviderKind::Meta => DEFAULT_META_MODEL,
3155        ProviderKind::Xai => DEFAULT_XAI_MODEL,
3156        ProviderKind::Telecomjs => DEFAULT_TELECOMJS_MODEL,
3157        // No built-in default model; the registry placeholder keeps this total.
3158        ProviderKind::Custom => provider.provider().default_model(),
3159    }
3160}
3161
3162fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
3163    match provider {
3164        ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
3165        ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
3166        ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
3167        ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
3168        ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
3169        ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
3170        ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
3171        ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
3172        ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
3173        ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
3174        ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
3175        ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
3176        ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL,
3177        ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL,
3178        ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
3179        ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
3180        ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
3181        ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
3182        ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
3183        ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL,
3184        ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL,
3185        ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
3186        ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
3187        ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL,
3188        ProviderKind::Zai => DEFAULT_ZAI_BASE_URL,
3189        ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL,
3190        ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL,
3191        ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
3192        ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
3193        ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL,
3194        ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL,
3195        ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL,
3196        ProviderKind::Meta => DEFAULT_META_BASE_URL,
3197        ProviderKind::Xai => DEFAULT_XAI_BASE_URL,
3198        ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL,
3199        // No built-in default base URL; the registry placeholder keeps this total.
3200        ProviderKind::Custom => provider.provider().default_base_url(),
3201    }
3202}
3203
3204fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
3205    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
3206    normalized == DEFAULT_KIMI_CODE_BASE_URL
3207        || normalized == "https://api.kimi.com/coding"
3208        || normalized.starts_with("https://api.kimi.com/coding/")
3209}
3210
3211fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
3212    let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
3213    if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
3214        return None;
3215    }
3216    Some(match normalized.as_str() {
3217        "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => {
3218            DEFAULT_XIAOMI_MIMO_BASE_URL
3219        }
3220        "token-plan-cn"
3221        | "token-plan-china"
3222        | "token-plan-mainland"
3223        | "token-plan-mainland-china"
3224        | "cn"
3225        | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
3226        "token-plan-sgp"
3227        | "token-plan-sg"
3228        | "token-plan-singapore"
3229        | "sgp"
3230        | "sg"
3231        | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
3232        "token-plan-ams"
3233        | "token-plan-eu"
3234        | "token-plan-europe"
3235        | "token-plan-amsterdam"
3236        | "ams"
3237        | "eu"
3238        | "europe"
3239        | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
3240        _ => DEFAULT_XIAOMI_MIMO_BASE_URL,
3241    })
3242}
3243
3244fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
3245    matches!(
3246        normalized_mode,
3247        "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
3248    )
3249}
3250
3251fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
3252    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
3253    normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
3254        || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL
3255        || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
3256}
3257
3258fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option<String> {
3259    candidates.iter().find_map(|name| {
3260        std::env::var(name)
3261            .ok()
3262            .filter(|value| !value.trim().is_empty())
3263    })
3264}
3265
3266fn xiaomi_mimo_env_api_key_for_runtime(
3267    mode: Option<&str>,
3268    base_url: Option<&str>,
3269) -> Option<String> {
3270    const TOKEN_PLAN_ENV_VARS: &[&str] =
3271        &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"];
3272    const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"];
3273
3274    let normalized_mode =
3275        mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
3276    let standard_selected = normalized_mode
3277        .as_deref()
3278        .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint)
3279        || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go);
3280    if standard_selected {
3281        return xiaomi_mimo_env_var(STANDARD_ENV_VARS);
3282    }
3283
3284    let token_plan_selected = normalized_mode
3285        .as_deref()
3286        .and_then(xiaomi_mimo_base_url_for_mode)
3287        .is_some()
3288        || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan);
3289    if token_plan_selected {
3290        return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS);
3291    }
3292
3293    xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS))
3294}
3295
3296fn resolve_xiaomi_mimo_base_url(
3297    configured: Option<String>,
3298    api_key: Option<&str>,
3299    mode: Option<&str>,
3300) -> String {
3301    let normalized_mode =
3302        mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
3303    let uses_standard_mode = normalized_mode
3304        .as_deref()
3305        .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint);
3306    let mode_base_url = normalized_mode
3307        .as_deref()
3308        .and_then(xiaomi_mimo_base_url_for_mode);
3309    let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key);
3310    match configured {
3311        Some(base_url) if uses_standard_mode => base_url,
3312        Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => {
3313            mode_base_url
3314                .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL)
3315                .to_string()
3316        }
3317        Some(base_url) => base_url,
3318        None => {
3319            if let Some(base_url) = mode_base_url {
3320                base_url.to_string()
3321            } else if uses_standard_mode {
3322                XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
3323            } else if uses_token_plan || api_key.is_none() {
3324                DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()
3325            } else {
3326                XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
3327            }
3328        }
3329    }
3330}
3331
3332fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
3333    api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
3334}
3335
3336fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool {
3337    matches!(
3338        base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
3339        "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
3340    )
3341}
3342
3343/// Whether `base_url` belongs to the provider's official endpoint family.
3344///
3345/// Some providers publish multiple stable paths for the same credential and
3346/// model namespace. Keep that family definition centralized so route
3347/// canonicalization and credential scoping cannot disagree.
3348#[must_use]
3349pub fn provider_base_url_is_official(provider: ProviderKind, base_url: &str) -> bool {
3350    let normalized = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
3351    match provider {
3352        ProviderKind::Deepseek => matches!(
3353            normalized.as_str(),
3354            "https://api.deepseek.com"
3355                | "https://api.deepseek.com/v1"
3356                | "https://api.deepseek.com/beta"
3357        ),
3358        ProviderKind::DeepseekAnthropic => matches!(
3359            normalized.as_str(),
3360            "https://api.deepseek.com/anthropic" | "https://api.deepseek.com/anthropic/v1"
3361        ),
3362        ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => matches!(
3363            normalized.as_str(),
3364            "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
3365        ),
3366        ProviderKind::Moonshot => {
3367            normalized == DEFAULT_MOONSHOT_BASE_URL || moonshot_base_url_uses_kimi_code(base_url)
3368        }
3369        ProviderKind::XiaomiMimo => {
3370            xiaomi_mimo_base_url_uses_token_plan(base_url)
3371                || xiaomi_mimo_base_url_is_pay_as_you_go(base_url)
3372        }
3373        // Custom routes have no Codewhale-owned official endpoint. The
3374        // descriptor URL is a schema placeholder, never a credential scope.
3375        ProviderKind::Custom => false,
3376        _ => {
3377            normalized
3378                == default_base_url_for_provider(provider)
3379                    .trim()
3380                    .trim_end_matches('/')
3381                    .to_ascii_lowercase()
3382        }
3383    }
3384}
3385
3386fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
3387    !provider_base_url_is_official(provider, base_url)
3388}
3389
3390/// Whether `base_url` is outside the provider's official endpoint family and
3391/// therefore owns its model-id namespace.
3392///
3393/// Custom OpenAI-compatible endpoints must receive the exact model selector
3394/// the user supplied. Official endpoints may safely canonicalize known aliases
3395/// to their provider wire ids.
3396#[must_use]
3397pub fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
3398    base_url_is_custom_for_provider(provider, base_url)
3399}
3400
3401fn should_skip_secret_store_for_provider(
3402    provider: ProviderKind,
3403    base_url: &str,
3404    auth_mode: Option<&str>,
3405) -> bool {
3406    if auth_mode_disables_api_key(auth_mode) {
3407        return true;
3408    }
3409    if base_url_is_custom_for_provider(provider, base_url) {
3410        return true;
3411    }
3412    if auth_mode_requires_api_key(auth_mode) {
3413        return false;
3414    }
3415
3416    matches!(
3417        provider,
3418        ProviderKind::Sglang | ProviderKind::Vllm | ProviderKind::Ollama
3419    ) || base_url_uses_local_host(base_url)
3420}
3421
3422fn env_api_key_for_provider(provider: ProviderKind) -> Option<String> {
3423    if provider == ProviderKind::Huggingface {
3424        return std::env::var("HUGGINGFACE_API_KEY")
3425            .ok()
3426            .filter(|value| !value.trim().is_empty())
3427            .or_else(|| {
3428                std::env::var("HF_TOKEN")
3429                    .ok()
3430                    .filter(|value| !value.trim().is_empty())
3431            });
3432    }
3433
3434    codewhale_secrets::env_for(provider.as_str())
3435}
3436
3437fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
3438    matches!(
3439        auth_mode
3440            .map(str::trim)
3441            .filter(|value| !value.is_empty())
3442            .map(|value| value.to_ascii_lowercase()),
3443        Some(value)
3444            if matches!(
3445                value.as_str(),
3446                "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
3447            )
3448    )
3449}
3450
3451/// Whether an authentication mode explicitly disables upstream provider auth.
3452#[must_use]
3453pub fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
3454    matches!(
3455        auth_mode
3456            .map(str::trim)
3457            .filter(|value| !value.is_empty())
3458            .map(|value| value.to_ascii_lowercase()),
3459        Some(value)
3460            if matches!(
3461                value.as_str(),
3462                "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
3463            )
3464    )
3465}
3466
3467fn auth_mode_uses_kimi_imported_token(auth_mode: &str) -> bool {
3468    matches!(
3469        auth_mode
3470            .trim()
3471            .to_ascii_lowercase()
3472            .replace('-', "_")
3473            .as_str(),
3474        "kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
3475    )
3476}
3477
3478fn base_url_uses_local_host(base_url: &str) -> bool {
3479    let Some(host) = base_url_host(base_url) else {
3480        return false;
3481    };
3482    let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
3483    if matches!(host.as_str(), "localhost" | "0.0.0.0") {
3484        return true;
3485    }
3486    host.parse::<std::net::IpAddr>()
3487        .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
3488}
3489
3490fn base_url_host(base_url: &str) -> Option<&str> {
3491    let without_scheme = base_url
3492        .split_once("://")
3493        .map_or(base_url, |(_, rest)| rest);
3494    let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
3495    if let Some(rest) = authority.strip_prefix('[') {
3496        return rest.split_once(']').map(|(host, _)| host);
3497    }
3498    authority.split(':').next().filter(|host| !host.is_empty())
3499}
3500
3501#[derive(Debug, Clone, Default)]
3502pub struct CliRuntimeOverrides {
3503    pub provider: Option<ProviderKind>,
3504    pub model: Option<String>,
3505    pub api_key: Option<String>,
3506    pub base_url: Option<String>,
3507    pub auth_mode: Option<String>,
3508    pub output_mode: Option<String>,
3509    pub log_level: Option<String>,
3510    pub telemetry: Option<bool>,
3511    pub approval_policy: Option<String>,
3512    pub sandbox_mode: Option<String>,
3513    pub yolo: Option<bool>,
3514    pub verbosity: Option<String>,
3515}
3516
3517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3518pub enum RuntimeApiKeySource {
3519    Cli,
3520    ConfigFile,
3521    Keyring,
3522    Env,
3523}
3524
3525impl RuntimeApiKeySource {
3526    #[must_use]
3527    pub fn as_env_value(self) -> &'static str {
3528        match self {
3529            Self::Cli => "cli",
3530            Self::ConfigFile => "config",
3531            Self::Keyring => "keyring",
3532            Self::Env => "env",
3533        }
3534    }
3535}
3536
3537#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3538pub enum ProviderSource {
3539    Cli,
3540    Env(&'static str),
3541    Config,
3542}
3543
3544#[derive(Debug, Clone)]
3545pub struct ResolvedRuntimeOptions {
3546    pub provider: ProviderKind,
3547    pub provider_source: ProviderSource,
3548    pub model: String,
3549    pub api_key: Option<String>,
3550    pub api_key_source: Option<RuntimeApiKeySource>,
3551    pub base_url: String,
3552    pub auth_mode: Option<String>,
3553    pub insecure_skip_tls_verify: bool,
3554    pub output_mode: Option<String>,
3555    pub log_level: Option<String>,
3556    pub telemetry: bool,
3557    pub approval_policy: Option<String>,
3558    pub sandbox_mode: Option<String>,
3559    pub yolo: Option<bool>,
3560    pub verbosity: Option<String>,
3561    pub http_headers: BTreeMap<String, String>,
3562}
3563
3564#[derive(Debug, Clone)]
3565pub struct ConfigStore {
3566    path: PathBuf,
3567    pub config: ConfigToml,
3568    permissions: PermissionsToml,
3569    /// Original file text, retained so [`save`](Self::save) can merge
3570    /// comments back after serialisation.
3571    original_raw: Option<String>,
3572}
3573
3574impl ConfigStore {
3575    pub fn load(path: Option<PathBuf>) -> Result<Self> {
3576        let path = resolve_config_path(path)?;
3577        let (config, original_raw) = if checked_path_exists(&path)? {
3578            let raw = read_checked_config_file(&path)?;
3579            let mut parsed: ConfigToml = toml::from_str(&raw).map_err(|_| {
3580                anyhow::anyhow!(
3581                    "failed to parse config at {}; file contents were omitted",
3582                    quote_os_path(&path)
3583                )
3584            })?;
3585            let raw_document: toml::Value = toml::from_str(&raw).map_err(|_| {
3586                anyhow::anyhow!(
3587                    "failed to parse config at {}; file contents were omitted",
3588                    quote_os_path(&path)
3589                )
3590            })?;
3591            if let Some(provider_id) = raw_document.get("provider").and_then(toml::Value::as_str) {
3592                parsed
3593                    .bind_persisted_provider_id(provider_id)
3594                    .with_context(|| {
3595                        format!("failed to parse config at {}", quote_os_path(&path))
3596                    })?;
3597            }
3598            (parsed, Some(raw))
3599        } else {
3600            (ConfigToml::default(), None)
3601        };
3602        let permissions = load_sibling_permissions(&path)?;
3603
3604        Ok(Self {
3605            path,
3606            config,
3607            permissions,
3608            original_raw,
3609        })
3610    }
3611
3612    /// Render the exact body [`save`](Self::save) would write: the serialized
3613    /// config with comments and disabled keys from the originally-loaded file
3614    /// merged back in. Exposed so setup flows can stage this body into a
3615    /// [`persistence::SetupTransaction`] alongside sibling files and keep the
3616    /// comment-preserving write atomic with the rest of the transaction.
3617    pub fn rendered_body(&self) -> Result<String> {
3618        let mut serialized =
3619            toml::to_string_pretty(&self.config).context("failed to serialize config")?;
3620        if let Some(provider_id) = self.config.named_custom_provider_id() {
3621            let mut document = serialized
3622                .parse::<toml_edit::DocumentMut>()
3623                .context("failed to edit serialized config")?;
3624            document["provider"] = toml_edit::value(provider_id);
3625            serialized = document.to_string();
3626        }
3627        if let Some(ref original_raw) = self.original_raw {
3628            merge_and_preserve_comments(&serialized, original_raw).with_context(|| {
3629                format!(
3630                    "cannot safely preserve config at {}; reload it and retry instead of replacing an unmergeable snapshot",
3631                    quote_os_path(&self.path)
3632                )
3633            })
3634        } else {
3635            Ok(serialized)
3636        }
3637    }
3638
3639    pub fn save(&mut self) -> Result<()> {
3640        let path = normalize_config_file_path(self.path.clone())?;
3641        let body = self.rendered_body()?;
3642        replace_config_document_if_unchanged(&path, self.original_raw.as_deref(), &body)?;
3643        self.original_raw = Some(body);
3644        Ok(())
3645    }
3646
3647    /// Refresh the typed value and byte snapshot after a targeted writer used
3648    /// the shared config lock. This keeps a long-lived command process from
3649    /// treating its own successful mutation as an external stale conflict.
3650    pub fn reload(&mut self) -> Result<()> {
3651        *self = Self::load(Some(self.path.clone()))?;
3652        Ok(())
3653    }
3654
3655    #[must_use]
3656    pub fn path(&self) -> &Path {
3657        &self.path
3658    }
3659
3660    #[must_use]
3661    pub fn permissions(&self) -> &PermissionsToml {
3662        &self.permissions
3663    }
3664
3665    #[must_use]
3666    pub fn permissions_path(&self) -> PathBuf {
3667        checked_permissions_path_for_config_path(&self.path)
3668            .expect("ConfigStore path is validated before construction")
3669    }
3670
3671    #[must_use]
3672    pub fn exec_policy_engine(&self) -> ExecPolicyEngine {
3673        if self.permissions.is_empty() {
3674            ExecPolicyEngine::new(Vec::new(), Vec::new())
3675        } else {
3676            ExecPolicyEngine::with_rulesets(vec![self.permissions.ruleset()])
3677        }
3678    }
3679
3680    /// Atomically append ask-only permission rules to the sibling
3681    /// `permissions.toml` file.
3682    ///
3683    /// Existing comments and formatting are preserved. Exact duplicate rules
3684    /// are ignored, and the in-memory permissions snapshot is refreshed after
3685    /// a successful write.
3686    pub fn append_ask_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
3687        if rules.is_empty() {
3688            return Ok(0);
3689        }
3690
3691        let path = checked_permissions_path_for_config_path(&self.path)?;
3692        let raw = if checked_path_exists(&path)? {
3693            read_checked_permissions_file(&path)?
3694        } else {
3695            String::new()
3696        };
3697        let mut permissions = if raw.trim().is_empty() {
3698            PermissionsToml::default()
3699        } else {
3700            toml::from_str(&raw).map_err(|_| {
3701                anyhow::anyhow!(
3702                    "failed to parse permissions at {}; file contents were omitted",
3703                    quote_os_path(&path)
3704                )
3705            })?
3706        };
3707        let mut document = if raw.trim().is_empty() {
3708            toml_edit::DocumentMut::new()
3709        } else {
3710            raw.parse::<toml_edit::DocumentMut>().map_err(|_| {
3711                anyhow::anyhow!(
3712                    "failed to edit permissions at {}; file contents were omitted",
3713                    quote_os_path(&path)
3714                )
3715            })?
3716        };
3717
3718        if !document.contains_key("rules") {
3719            document["rules"] = toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
3720        }
3721        let rules_item = document
3722            .get_mut("rules")
3723            .expect("rules entry was inserted above");
3724
3725        let mut added = 0;
3726        for rule in rules {
3727            if permissions.rules.contains(rule) {
3728                continue;
3729            }
3730            append_ask_rule(rules_item, rule)?;
3731            permissions.rules.push(rule.clone());
3732            added += 1;
3733        }
3734        if added == 0 {
3735            self.permissions = permissions;
3736            return Ok(0);
3737        }
3738
3739        let body = document.to_string();
3740        let persisted: PermissionsToml = toml::from_str(&body).map_err(|_| {
3741            anyhow::anyhow!(
3742                "generated invalid permissions document for {}; file contents were omitted",
3743                quote_os_path(&path)
3744            )
3745        })?;
3746        write_permissions_atomic(&path, body.as_bytes())?;
3747        self.permissions = persisted;
3748        Ok(added)
3749    }
3750}
3751
3752fn config_backup_file_name(path: &Path) -> OsString {
3753    let mut file_name = path
3754        .file_name()
3755        .map(OsString::from)
3756        .unwrap_or_else(|| OsString::from(CONFIG_FILE_NAME));
3757    file_name.push(".bak");
3758    file_name
3759}
3760
3761fn config_sibling_path_unchecked(config_path: &Path, file_name: &OsStr) -> PathBuf {
3762    config_path
3763        .parent()
3764        .unwrap_or_else(|| Path::new("."))
3765        .join(file_name)
3766}
3767
3768fn checked_config_sibling_path(config_path: &Path, file_name: &OsStr) -> Result<PathBuf> {
3769    let config_path = normalize_config_file_path(config_path.to_path_buf())?;
3770    let parent = config_path
3771        .parent()
3772        .context("config path must include a parent directory")?;
3773    let path = parent.join(file_name);
3774    reject_path_symlink(&path)?;
3775    Ok(path)
3776}
3777
3778#[cfg(test)]
3779fn config_backup_path(path: &Path) -> PathBuf {
3780    config_sibling_path_unchecked(path, &config_backup_file_name(path))
3781}
3782
3783fn checked_config_backup_path(path: &Path) -> Result<PathBuf> {
3784    checked_config_sibling_path(path, &config_backup_file_name(path))
3785}
3786
3787/// Remove plaintext `api_key` entries from the one-time config backup, if it
3788/// exists.
3789///
3790/// Credential migration deliberately preserves the rest of `config.toml.bak`
3791/// while ensuring that moving a key into the durable secret store does not
3792/// leave the same credential behind in an older backup.
3793pub fn scrub_plaintext_api_keys_from_config_backup(path: &Path) -> Result<()> {
3794    let backup = checked_config_backup_path(path)?;
3795    if !backup.exists() {
3796        return Ok(());
3797    }
3798
3799    let raw = read_checked_toml_file(&backup, "config backup")?;
3800    let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
3801        format!(
3802            "failed to scrub plaintext API keys from config backup {}",
3803            backup.display()
3804        )
3805    })?;
3806    if scrubbed != raw {
3807        persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
3808            format!(
3809                "failed to write credential-free config backup {}",
3810                backup.display()
3811            )
3812        })?;
3813    }
3814    Ok(())
3815}
3816
3817fn write_one_time_config_backup(path: &Path) -> Result<()> {
3818    let backup = checked_config_backup_path(path)?;
3819    if backup.exists() {
3820        return scrub_plaintext_api_keys_from_config_backup(path);
3821    }
3822
3823    let raw = read_checked_config_file(path)?;
3824    let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
3825        format!(
3826            "failed to scrub plaintext API keys while creating config backup {}",
3827            backup.display()
3828        )
3829    })?;
3830    persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
3831        format!(
3832            "failed to create credential-free config backup {} from {}",
3833            backup.display(),
3834            path.display()
3835        )
3836    })?;
3837    Ok(())
3838}
3839
3840fn config_toml_without_plaintext_api_keys(raw: &str) -> Result<String> {
3841    let mut document = raw
3842        .parse::<toml_edit::DocumentMut>()
3843        .map_err(|_| {
3844            anyhow::anyhow!(
3845                "failed to parse config TOML while removing plaintext API keys; file contents were omitted"
3846            )
3847        })?;
3848    remove_plaintext_api_keys_recursive(document.as_table_mut());
3849    Ok(document.to_string())
3850}
3851
3852fn remove_plaintext_api_keys_recursive(table: &mut dyn toml_edit::TableLike) {
3853    table.remove("api_key");
3854    for (_, item) in table.iter_mut() {
3855        if let toml_edit::Item::ArrayOfTables(tables) = item {
3856            for nested in tables.iter_mut() {
3857                remove_plaintext_api_keys_recursive(nested);
3858            }
3859        } else if let Some(nested) = item.as_table_like_mut() {
3860            remove_plaintext_api_keys_recursive(nested);
3861        }
3862    }
3863}
3864
3865/// Merge comments and formatting from an original TOML file into a
3866/// freshly serialized document so user annotations (comments, whitespace,
3867/// disabled keys) survive config rewrites.
3868///
3869/// `original_raw` is the raw text of the file before the change; the
3870/// function parses it internally with [`toml_edit`] so callers stay free
3871/// of that dependency.
3872pub fn merge_and_preserve_comments(serialized: &str, original_raw: &str) -> Result<String> {
3873    let original = original_raw
3874        .parse::<toml_edit::DocumentMut>()
3875        .map_err(|_| {
3876            anyhow::anyhow!(
3877                "failed to parse original config for comment merge; file contents were omitted"
3878            )
3879        })?;
3880
3881    let mut new_doc = serialized.parse::<toml_edit::DocumentMut>().map_err(|_| {
3882        anyhow::anyhow!(
3883            "failed to parse serialized config for comment merge; file contents were omitted"
3884        )
3885    })?;
3886
3887    // Reuse the original document’s trailing text (file-footer comments /
3888    // disabled keys) so they survive the rewrite.
3889    new_doc.set_trailing(original.trailing().clone());
3890
3891    // Copy the top-level table's decor (document-header comments, whitespace
3892    // before the first key) which `toml_edit` stores on the root `Table` itself.
3893    *new_doc.as_table_mut().decor_mut() = original.as_table().decor().clone();
3894
3895    merge_decor_table(new_doc.as_table_mut(), original.as_table());
3896
3897    Ok(new_doc.to_string())
3898}
3899
3900/// Recursively copy `decor` (prefix/suffix comments and whitespace) from
3901/// every key in `source` that also exists in `target`.
3902fn merge_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
3903    // Collect keys first — the borrow checker won't let us hold
3904    // `get_key_value_mut` while iterating.
3905    let keys: Vec<String> = source.iter().map(|(k, _)| k.to_owned()).collect();
3906    for key in &keys {
3907        let Some((source_key, source_item)) = source.get_key_value(key) else {
3908            continue;
3909        };
3910        let Some((mut target_key_mut, target_item)) = target.get_key_value_mut(key) else {
3911            continue;
3912        };
3913
3914        // Copy the key-level decor (comments before the key itself)
3915        *target_key_mut.leaf_decor_mut() = source_key.leaf_decor().clone();
3916
3917        copy_item_decor(target_item, source_item);
3918
3919        if let (Some(tt), Some(st)) = (target_item.as_table_mut(), source_item.as_table()) {
3920            merge_decor_table(tt, st);
3921        }
3922
3923        if let (Some(ta), Some(sa)) = (
3924            target_item.as_array_of_tables_mut(),
3925            source_item.as_array_of_tables(),
3926        ) {
3927            for (i, source_table) in sa.iter().enumerate() {
3928                if let Some(target_table) = ta.get_mut(i) {
3929                    copy_item_decor_table(target_table, source_table);
3930                    merge_decor_table(target_table, source_table);
3931                }
3932            }
3933        }
3934    }
3935}
3936
3937/// Copy the decor (comments and surrounding whitespace) from `source` to `target`,
3938/// respecting the concrete item type since [`toml_edit::Item`] has no uniform
3939/// `decor` accessor.
3940fn copy_item_decor(target: &mut toml_edit::Item, source: &toml_edit::Item) {
3941    match (target, source) {
3942        (toml_edit::Item::Table(tt), toml_edit::Item::Table(st)) => {
3943            *tt.decor_mut() = st.decor().clone();
3944        }
3945        (toml_edit::Item::Value(tv), toml_edit::Item::Value(sv)) => {
3946            *tv.decor_mut() = sv.decor().clone();
3947        }
3948        _ => {}
3949    }
3950}
3951
3952fn copy_item_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
3953    *target.decor_mut() = source.decor().clone();
3954}
3955
3956/// Process-wide default [`Secrets`] façade. The first caller wins; the
3957/// lock is exposed so test or CLI code can install an explicit
3958/// backend (e.g. an [`codewhale_secrets::InMemoryKeyringStore`]) before
3959/// any resolver runs.
3960pub fn default_secrets() -> &'static Secrets {
3961    static SECRETS: OnceLock<Secrets> = OnceLock::new();
3962    SECRETS.get_or_init(|| {
3963        // Tests should never poke real platform credential stores. Cargo sets the
3964        // `RUST_TEST_*` family of env vars (and `CARGO_PKG_NAME` is
3965        // always populated), but the `cfg(test)` flag is the canonical
3966        // signal here. See `install_test_secrets` for explicit installs.
3967        #[cfg(test)]
3968        {
3969            Secrets::new(std::sync::Arc::new(
3970                codewhale_secrets::InMemoryKeyringStore::new(),
3971            ))
3972        }
3973        #[cfg(not(test))]
3974        {
3975            Secrets::auto_detect()
3976        }
3977    })
3978}
3979
3980// ── CodeWhale state root (v0.8.44) ──────────────────────────────────
3981//
3982// v0.8.44 migrates product-owned app state from ~/.deepseek/ to
3983// ~/.codewhale/ while keeping ~/.deepseek/ as a compatibility fallback.
3984// New installs write to ~/.codewhale/. Existing installs with only
3985// ~/.deepseek/ continue working without data loss.
3986
3987/// Canonical CodeWhale app directory name under $HOME.
3988pub const CODEWHALE_APP_DIR: &str = ".codewhale";
3989
3990/// Legacy DeepSeek-branded app directory name (compatibility fallback).
3991pub const LEGACY_APP_DIR: &str = ".deepseek";
3992
3993/// Resolve the primary CodeWhale home directory.
3994///
3995/// `$CODEWHALE_HOME` takes precedence when set. Otherwise defaults to
3996/// `$HOME/.codewhale`. This is the write target for new product state.
3997pub fn codewhale_home() -> Result<PathBuf> {
3998    if let Some(path) = codewhale_home_env_override() {
3999        return Ok(path);
4000    }
4001    let home = effective_home_dir().context("failed to resolve home directory")?;
4002    Ok(home.join(CODEWHALE_APP_DIR))
4003}
4004
4005fn codewhale_home_env_override() -> Option<PathBuf> {
4006    let val = std::env::var("CODEWHALE_HOME").ok()?;
4007    let trimmed = val.trim();
4008    if trimmed.is_empty() {
4009        None
4010    } else {
4011        Some(PathBuf::from(trimmed))
4012    }
4013}
4014
4015/// Whether `$CODEWHALE_HOME` is set to a non-empty value.
4016///
4017/// An explicit CodeWhale home is an isolation boundary: state/config resolvers
4018/// must not fall back to ambient legacy `~/.deepseek` data outside that root.
4019pub fn codewhale_home_is_explicit() -> bool {
4020    codewhale_home_env_override().is_some()
4021}
4022
4023/// Resolve the legacy DeepSeek home directory (`$HOME/.deepseek`).
4024///
4025/// Always returns the legacy path regardless of whether it exists.
4026pub fn legacy_deepseek_home() -> Result<PathBuf> {
4027    let home = effective_home_dir().context("failed to resolve home directory")?;
4028    Ok(home.join(LEGACY_APP_DIR))
4029}
4030
4031fn effective_home_dir() -> Option<PathBuf> {
4032    std::env::var_os("HOME")
4033        .filter(|value| !value.is_empty())
4034        .map(PathBuf::from)
4035        .or_else(dirs::home_dir)
4036}
4037
4038/// Reject state subdirs that could escape the state root via path injection.
4039///
4040/// `ensure_state_dir` / `resolve_state_dir` are public APIs taking an arbitrary
4041/// subdir string; every in-tree caller passes a hardcoded single component
4042/// (e.g. `"sessions"`, `"."`). This validates defensively so a future caller
4043/// can never traverse out of the state root via `..` components or an absolute
4044/// path. Nested relative paths such as `"a/b"` are permitted.
4045fn ensure_safe_state_subdir(subdir: &str) -> Result<()> {
4046    if subdir.is_empty() {
4047        bail!("state subdir must not be empty");
4048    }
4049    let path = std::path::Path::new(subdir);
4050    if path.is_absolute() {
4051        bail!("state subdir must not be an absolute path: {subdir}");
4052    }
4053    if path.components().any(|c| {
4054        matches!(
4055            c,
4056            std::path::Component::RootDir | std::path::Component::Prefix(_)
4057        )
4058    }) {
4059        bail!("state subdir must not contain a root or prefix: {subdir}");
4060    }
4061    if path
4062        .components()
4063        .any(|c| matches!(c, std::path::Component::ParentDir))
4064    {
4065        bail!("state subdir must not contain parent-dir (..) components: {subdir}");
4066    }
4067    Ok(())
4068}
4069
4070/// Resolve a state subdirectory, preferring the CodeWhale root if
4071/// it already exists, otherwise falling back to the legacy root.
4072///
4073/// This is the read-path resolver: it returns the primary path when
4074/// migration has occurred or on a fresh install, but keeps reading
4075/// from the legacy path for users who haven't migrated yet.
4076pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
4077    ensure_safe_state_subdir(subdir)?;
4078    let explicit_codewhale_home = codewhale_home_env_override().is_some();
4079    let primary = codewhale_home()?.join(subdir);
4080    if explicit_codewhale_home || primary.exists() {
4081        return Ok(primary);
4082    }
4083    let legacy = legacy_deepseek_home()?.join(subdir);
4084    if legacy.exists() {
4085        return Ok(legacy);
4086    }
4087    // Neither exists — return primary for first-write creation.
4088    Ok(primary)
4089}
4090
4091/// Ensure a state subdirectory exists under the primary CodeWhale root,
4092/// creating it if necessary. This is the write-path resolver.
4093///
4094/// On the first creation of a real subdirectory (not the root sentinel `"."`),
4095/// if a legacy `~/.deepseek/<subdir>` exists but the primary
4096/// `~/.codewhale/<subdir>` does not, the legacy directory is relocated into
4097/// the primary location so the user keeps their data and the legacy tree
4098/// stops growing (#3240). After migration, [`resolve_state_dir`] finds the
4099/// data in the primary location; the read resolver itself is unchanged.
4100pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
4101    let (dir, migration) = ensure_state_dir_with_migration(subdir)?;
4102    if let Some(migration) = migration {
4103        eprintln!("{}", migration.user_notice());
4104    }
4105    Ok(dir)
4106}
4107
4108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4109pub enum StateMigrationKind {
4110    Relocated,
4111    Copied,
4112}
4113
4114#[derive(Debug, Clone, PartialEq, Eq)]
4115pub struct StateMigration {
4116    pub subdir: String,
4117    pub legacy_path: PathBuf,
4118    pub primary_path: PathBuf,
4119    pub kind: StateMigrationKind,
4120}
4121
4122impl StateMigration {
4123    pub fn user_notice(&self) -> String {
4124        let action = match self.kind {
4125            StateMigrationKind::Relocated => "relocated",
4126            StateMigrationKind::Copied => "copied",
4127        };
4128        let legacy_detail = match self.kind {
4129            StateMigrationKind::Relocated => {
4130                "The legacy .deepseek copy for this state path was removed by the move."
4131            }
4132            StateMigrationKind::Copied => {
4133                "The legacy .deepseek copy was left in place because a direct move failed."
4134            }
4135        };
4136
4137        format!(
4138            "Codewhale migrated legacy state ({action}):\n  {} -> {}\nYour data was preserved. Use .codewhale as the canonical state location from now on.\n{legacy_detail}\nIf no other apps use it, you can remove the legacy .deepseek tree after confirming everything looks right.",
4139            self.legacy_path.display(),
4140            self.primary_path.display(),
4141        )
4142    }
4143}
4144
4145/// Variant of [`ensure_state_dir`] that exposes whether a legacy state path was
4146/// migrated. Most callers should use [`ensure_state_dir`]; this is kept for
4147/// tests and future UI surfaces that want to render the notice themselves.
4148pub fn ensure_state_dir_with_migration(subdir: &str) -> Result<(PathBuf, Option<StateMigration>)> {
4149    ensure_safe_state_subdir(subdir)?;
4150    let explicit_codewhale_home = codewhale_home_env_override().is_some();
4151    let dir = codewhale_home()?.join(subdir);
4152    let migration = if !explicit_codewhale_home {
4153        migrate_legacy_state_dir(&dir, subdir)?
4154    } else {
4155        None
4156    };
4157    std::fs::create_dir_all(&dir)
4158        .with_context(|| format!("failed to create {}/", dir.display()))?;
4159    Ok((dir, migration))
4160}
4161
4162/// One-time relocation of a legacy `~/.deepseek/<subdir>` state directory into
4163/// the primary `~/.codewhale/<subdir>` location (#3240). No-op once the primary
4164/// exists, for the root sentinel `"."` (a whole-tree move is owned by the
4165/// config-file migration), or when no legacy directory is present.
4166fn migrate_legacy_state_dir(primary: &Path, subdir: &str) -> Result<Option<StateMigration>> {
4167    if primary.exists() || subdir == "." || subdir.is_empty() {
4168        return Ok(None);
4169    }
4170    let legacy = match legacy_deepseek_home() {
4171        Ok(home) => home.join(subdir),
4172        Err(_) => return Ok(None),
4173    };
4174    if !legacy.exists() {
4175        return Ok(None);
4176    }
4177    // The primary's parent (the ~/.codewhale root) must exist for the rename.
4178    if let Some(parent) = primary.parent()
4179        && let Err(err) = std::fs::create_dir_all(parent)
4180    {
4181        tracing::warn!(
4182            target: "config::migration",
4183            "Could not create {} for state migration ({}); writing to primary anyway",
4184            parent.display(),
4185            err
4186        );
4187    }
4188    match std::fs::rename(&legacy, primary) {
4189        Ok(()) => {
4190            tracing::info!(
4191                target: "config::migration",
4192                "Migrated legacy state directory {} -> {} (relocated). The .deepseek copy was removed.",
4193                legacy.display(),
4194                primary.display()
4195            );
4196            return Ok(Some(StateMigration {
4197                subdir: subdir.to_string(),
4198                legacy_path: legacy,
4199                primary_path: primary.to_path_buf(),
4200                kind: StateMigrationKind::Relocated,
4201            }));
4202        }
4203        Err(err) => {
4204            // Cross-device rename or permission issue: fall back to a
4205            // recursive copy so the user keeps their data. The legacy tree is
4206            // left in place; it stops growing because writes now target the
4207            // primary path.
4208            match copy_dir_recursive(&legacy, primary) {
4209                Ok(()) => {
4210                    tracing::info!(
4211                        target: "config::migration",
4212                        "Migrated legacy state directory {} -> {} (copied; rename failed: {err}). \
4213                         The legacy .deepseek copy was left in place.",
4214                        legacy.display(),
4215                        primary.display()
4216                    );
4217                    return Ok(Some(StateMigration {
4218                        subdir: subdir.to_string(),
4219                        legacy_path: legacy,
4220                        primary_path: primary.to_path_buf(),
4221                        kind: StateMigrationKind::Copied,
4222                    }));
4223                }
4224                Err(copy_err) => {
4225                    tracing::warn!(
4226                        target: "config::migration",
4227                        "Could not migrate legacy state {} -> {} (rename: {err}; copy: {copy_err}). \
4228                         New data is written to the primary path; the legacy tree remains untouched.",
4229                        legacy.display(),
4230                        primary.display()
4231                    );
4232                }
4233            }
4234        }
4235    }
4236    Ok(None)
4237}
4238
4239/// Recursively copy a directory tree from `src` to `dst`, creating `dst`.
4240/// Symlinks and other non-file/non-dir entries are skipped (rare in state dirs).
4241fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
4242    std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
4243    for entry in
4244        std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
4245    {
4246        let entry = entry.with_context(|| format!("failed to read entry in {}", src.display()))?;
4247        let path = entry.path();
4248        let target = dst.join(entry.file_name());
4249        let file_type = entry
4250            .file_type()
4251            .with_context(|| format!("failed to read file type for {}", path.display()))?;
4252        if file_type.is_dir() {
4253            copy_dir_recursive(&path, &target)?;
4254        } else if file_type.is_file() {
4255            std::fs::copy(&path, &target).with_context(|| {
4256                format!("failed to copy {} -> {}", path.display(), target.display())
4257            })?;
4258        }
4259    }
4260    Ok(())
4261}
4262
4263/// Resolve a project-local state subdirectory, preferring `.codewhale/`
4264/// when it exists, falling back to `.deepseek/` for legacy projects.
4265///
4266/// Returns `(true, path)` when the primary `.codewhale/` path is used,
4267/// `(false, path)` for the legacy fallback. The boolean helps callers
4268/// emit a deprecation notice on legacy paths.
4269pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> Result<(bool, PathBuf)> {
4270    ensure_safe_state_subdir(subdir)?;
4271    let workspace = normalize_project_workspace(workspace)?;
4272    let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
4273    if primary.exists() {
4274        return Ok((true, primary));
4275    }
4276    let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
4277    Ok((false, legacy))
4278}
4279
4280/// Ensure a project-local state subdirectory exists under `.codewhale/`,
4281/// creating it if necessary. Returns the directory path.
4282pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
4283    ensure_safe_state_subdir(subdir)?;
4284    let workspace = normalize_project_workspace(workspace)?;
4285    let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
4286    std::fs::create_dir_all(&dir)
4287        .with_context(|| format!("failed to create {}/", dir.display()))?;
4288    Ok(dir)
4289}
4290
4291pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
4292    if let Some(path) = explicit {
4293        return normalize_config_file_path(path);
4294    }
4295    if let Ok(path) = std::env::var("CODEWHALE_CONFIG_PATH") {
4296        if let Some(path) = config_path_from_env_value(&path)? {
4297            return Ok(path);
4298        }
4299        return default_config_path();
4300    }
4301    if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
4302        if let Some(path) = config_path_from_env_value(&path)? {
4303            return Ok(path);
4304        }
4305        return default_config_path();
4306    }
4307    default_config_path()
4308}
4309
4310fn config_path_from_env_value(path: &str) -> Result<Option<PathBuf>> {
4311    let trimmed = path.trim();
4312    if trimmed.is_empty() {
4313        Ok(None)
4314    } else {
4315        normalize_config_file_path(PathBuf::from(trimmed)).map(Some)
4316    }
4317}
4318
4319#[must_use]
4320pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
4321    config_sibling_path_unchecked(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
4322}
4323
4324fn checked_permissions_path_for_config_path(config_path: &Path) -> Result<PathBuf> {
4325    checked_config_sibling_path(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
4326}
4327
4328pub fn resolve_permissions_path(config_path: Option<PathBuf>) -> Result<PathBuf> {
4329    checked_permissions_path_for_config_path(&resolve_config_path(config_path)?)
4330}
4331
4332/// Read a resolved `permissions.toml` path using the same checked/no-follow
4333/// path handling as config loading.
4334pub fn read_permissions_file(path: &Path) -> Result<String> {
4335    read_checked_permissions_file(path)
4336}
4337
4338fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
4339    let permissions_path = checked_permissions_path_for_config_path(config_path)?;
4340    if !checked_path_exists(&permissions_path)? {
4341        return Ok(PermissionsToml::default());
4342    }
4343
4344    let raw = read_checked_permissions_file(&permissions_path)?;
4345    toml::from_str(&raw).map_err(|_| {
4346        anyhow::anyhow!(
4347            "failed to parse permissions at {}; file contents were omitted",
4348            quote_os_path(&permissions_path)
4349        )
4350    })
4351}
4352
4353fn append_ask_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
4354    match item {
4355        toml_edit::Item::ArrayOfTables(rules) => {
4356            rules.push(ask_rule_table(rule));
4357            Ok(())
4358        }
4359        toml_edit::Item::Value(value) => {
4360            let Some(rules) = value.as_array_mut() else {
4361                bail!("`rules` in permissions.toml must be an array");
4362            };
4363            rules.push(toml_edit::Value::InlineTable(ask_rule_inline_table(rule)));
4364            Ok(())
4365        }
4366        _ => bail!("`rules` in permissions.toml must be an array"),
4367    }
4368}
4369
4370fn ask_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
4371    let mut table = toml_edit::Table::new();
4372    table["tool"] = toml_edit::value(rule.tool.clone());
4373    if let Some(command) = rule.command.as_deref() {
4374        table["command"] = toml_edit::value(command);
4375    }
4376    if let Some(path) = rule.path.as_deref() {
4377        table["path"] = toml_edit::value(path);
4378    }
4379    table
4380}
4381
4382fn ask_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
4383    let mut table = toml_edit::InlineTable::new();
4384    table.insert("tool", toml_edit::Value::from(rule.tool.clone()));
4385    if let Some(command) = rule.command.as_deref() {
4386        table.insert("command", toml_edit::Value::from(command));
4387    }
4388    if let Some(path) = rule.path.as_deref() {
4389        table.insert("path", toml_edit::Value::from(path));
4390    }
4391    table
4392}
4393
4394fn write_permissions_atomic(path: &Path, body: &[u8]) -> Result<()> {
4395    let parent = path.parent().with_context(|| {
4396        format!(
4397            "permissions path has no parent directory: {}",
4398            path.display()
4399        )
4400    })?;
4401    fs::create_dir_all(parent).with_context(|| {
4402        format!(
4403            "failed to create permissions directory {}",
4404            parent.display()
4405        )
4406    })?;
4407
4408    let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
4409        format!(
4410            "failed to create temporary permissions file in {}",
4411            parent.display()
4412        )
4413    })?;
4414    #[cfg(unix)]
4415    temporary
4416        .as_file()
4417        .set_permissions(fs::Permissions::from_mode(0o600))
4418        .with_context(|| {
4419            format!(
4420                "failed to secure temporary permissions file for {}",
4421                path.display()
4422            )
4423        })?;
4424    temporary
4425        .write_all(body)
4426        .with_context(|| format!("failed to write permissions at {}", path.display()))?;
4427    temporary
4428        .as_file()
4429        .sync_all()
4430        .with_context(|| format!("failed to sync permissions at {}", path.display()))?;
4431    temporary
4432        .persist(path)
4433        .map_err(|error| error.error)
4434        .with_context(|| format!("failed to replace permissions at {}", path.display()))?;
4435    Ok(())
4436}
4437
4438pub fn default_config_path() -> Result<PathBuf> {
4439    // Prefer ~/.codewhale/config.toml when it exists (fresh install or
4440    // migrated), otherwise fall back to ~/.deepseek/config.toml.
4441    let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
4442    if codewhale_home_is_explicit() || primary.exists() {
4443        return Ok(primary);
4444    }
4445    let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
4446    if legacy.exists() {
4447        return Ok(legacy);
4448    }
4449    // Neither exists — return primary so first write creates it there.
4450    Ok(primary)
4451}
4452
4453#[derive(Debug, Clone, PartialEq, Eq)]
4454pub struct ConfigMigration {
4455    pub legacy_path: PathBuf,
4456    pub primary_path: PathBuf,
4457}
4458
4459impl ConfigMigration {
4460    pub fn user_notice(&self) -> String {
4461        format!(
4462            "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.",
4463            self.legacy_path.display(),
4464            self.primary_path.display()
4465        )
4466    }
4467}
4468
4469/// v0.8.44: one-time migration from `~/.deepseek/config.toml` to
4470/// `~/.codewhale/config.toml`. Called on first launch after the config
4471/// is loaded; copies the legacy file if the primary doesn't exist yet.
4472/// Never overwrites an existing primary config.
4473pub fn migrate_config_if_needed() -> Result<Option<ConfigMigration>> {
4474    if codewhale_home_is_explicit() {
4475        return Ok(None);
4476    }
4477    let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
4478    if primary.exists() {
4479        return Ok(None);
4480    }
4481    let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
4482    if !legacy.exists() {
4483        return Ok(None);
4484    }
4485    // Copy the config to the new home.
4486    if let Some(parent) = primary.parent() {
4487        std::fs::create_dir_all(parent).context("failed to create codewhale config directory")?;
4488    }
4489    std::fs::copy(&legacy, &primary)
4490        .context("failed to migrate config from deepseek to codewhale home")?;
4491    tracing::info!(
4492        "Migrated config from {} to {}",
4493        legacy.display(),
4494        primary.display()
4495    );
4496    Ok(Some(ConfigMigration {
4497        legacy_path: legacy,
4498        primary_path: primary,
4499    }))
4500}
4501
4502fn parse_bool(raw: &str) -> Result<bool> {
4503    match raw.trim().to_ascii_lowercase().as_str() {
4504        "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
4505        "0" | "false" | "no" | "off" | "disabled" => Ok(false),
4506        _ => bail!("invalid boolean '{raw}'"),
4507    }
4508}
4509
4510fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
4511    let mut headers = BTreeMap::new();
4512    for pair in raw.trim().split(',') {
4513        let pair = pair.trim();
4514        if pair.is_empty() {
4515            continue;
4516        }
4517        let Some((name, value)) = pair.split_once('=') else {
4518            bail!("invalid header pair '{pair}', expected name=value");
4519        };
4520        let name = name.trim();
4521        let value = value.trim();
4522        if name.is_empty() {
4523            bail!("header name cannot be empty");
4524        }
4525        if value.is_empty() {
4526            continue;
4527        }
4528        headers.insert(name.to_string(), value.to_string());
4529    }
4530    Ok(headers)
4531}
4532
4533fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
4534    if headers.is_empty() {
4535        return None;
4536    }
4537    Some(
4538        headers
4539            .iter()
4540            .map(|(name, value)| format!("{name}={value}"))
4541            .collect::<Vec<_>>()
4542            .join(","),
4543    )
4544}
4545
4546fn serialize_http_headers_for_display(headers: &BTreeMap<String, String>) -> Option<String> {
4547    if headers.is_empty() {
4548        return None;
4549    }
4550    Some(
4551        headers
4552            .iter()
4553            .map(|(name, value)| {
4554                let display_value = if is_sensitive_config_key(name) {
4555                    redact_secret(value)
4556                } else {
4557                    value.clone()
4558                };
4559                format!("{name}={display_value}")
4560            })
4561            .collect::<Vec<_>>()
4562            .join(","),
4563    )
4564}
4565
4566fn redact_secret(secret: &str) -> String {
4567    let chars: Vec<char> = secret.chars().collect();
4568    if chars.len() <= 16 {
4569        return "********".to_string();
4570    }
4571    let prefix: String = chars.iter().take(4).collect();
4572    let suffix: String = chars
4573        .iter()
4574        .rev()
4575        .take(4)
4576        .collect::<Vec<_>>()
4577        .into_iter()
4578        .rev()
4579        .collect();
4580    format!("{prefix}***{suffix}")
4581}
4582
4583#[must_use]
4584pub fn is_sensitive_config_key(key: &str) -> bool {
4585    let Some(segment) = key.rsplit('.').next() else {
4586        return false;
4587    };
4588    let normalized = segment
4589        .trim()
4590        .trim_matches('"')
4591        .replace('-', "_")
4592        .to_ascii_lowercase();
4593
4594    matches!(
4595        normalized.as_str(),
4596        "api_key"
4597            | "apikey"
4598            | "api_keys"
4599            | "authorization"
4600            | "bearer"
4601            | "client_secret"
4602            | "credential"
4603            | "credentials"
4604            | "id_token"
4605            | "password"
4606            | "passwords"
4607            | "passwd"
4608            | "proxy_authorization"
4609            | "refresh_token"
4610            | "secret"
4611            | "secrets"
4612            | "token"
4613            | "tokens"
4614    ) || normalized.ends_with("_api_key")
4615        || normalized.ends_with("_authorization")
4616        || normalized.ends_with("_password")
4617        || normalized.ends_with("_secret")
4618        || normalized.ends_with("_token")
4619}
4620
4621fn redact_toml_value_for_display(key: &str, value: &toml::Value) -> String {
4622    redact_toml_value_for_display_inner(key, false, value).to_string()
4623}
4624
4625fn toml_value_as_u64(value: &toml::Value) -> Option<u64> {
4626    match value {
4627        toml::Value::Integer(value) => u64::try_from(*value).ok(),
4628        toml::Value::String(value) => value.trim().parse().ok(),
4629        _ => None,
4630    }
4631}
4632
4633fn redact_toml_value_for_display_inner(
4634    key: &str,
4635    sensitive_ancestor: bool,
4636    value: &toml::Value,
4637) -> toml::Value {
4638    let sensitive = sensitive_ancestor || is_sensitive_config_key(key);
4639    match value {
4640        toml::Value::String(value) if sensitive => toml::Value::String(redact_secret(value)),
4641        toml::Value::Array(values) => toml::Value::Array(
4642            values
4643                .iter()
4644                .map(|value| redact_toml_value_for_display_inner(key, sensitive, value))
4645                .collect(),
4646        ),
4647        toml::Value::Table(table) => {
4648            let mut redacted = toml::map::Map::new();
4649            for (child_key, child_value) in table {
4650                let path = if key.is_empty() {
4651                    child_key.clone()
4652                } else {
4653                    format!("{key}.{child_key}")
4654                };
4655                redacted.insert(
4656                    child_key.clone(),
4657                    redact_toml_value_for_display_inner(&path, sensitive, child_value),
4658                );
4659            }
4660            toml::Value::Table(redacted)
4661        }
4662        _ if sensitive => toml::Value::String("********".to_string()),
4663        _ => value.clone(),
4664    }
4665}
4666
4667fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
4668    if path.as_os_str().is_empty() {
4669        bail!("config path cannot be empty");
4670    }
4671    if path
4672        .components()
4673        .any(|component| matches!(component, Component::ParentDir))
4674    {
4675        bail!("config path cannot contain '..' components");
4676    }
4677    if path.file_name().is_none() {
4678        bail!("config path must include a file name");
4679    }
4680    let absolute = if path.is_absolute() {
4681        path
4682    } else {
4683        std::env::current_dir()
4684            .context("failed to resolve current directory for config path")?
4685            .join(path)
4686    };
4687    let file_name = absolute
4688        .file_name()
4689        .map(OsString::from)
4690        .context("config path must include a file name")?;
4691    let parent = absolute
4692        .parent()
4693        .context("config path must include a parent directory")?;
4694    let parent = match parent.canonicalize() {
4695        Ok(parent) => parent,
4696        Err(err) if err.kind() == std::io::ErrorKind::NotFound => parent.to_path_buf(),
4697        Err(err) => {
4698            return Err(err).with_context(|| {
4699                format!("failed to resolve config directory {}", parent.display())
4700            });
4701        }
4702    };
4703    let normalized = parent.join(file_name);
4704    reject_path_symlink(&normalized)?;
4705    Ok(normalized)
4706}
4707
4708fn normalize_project_workspace(workspace: &Path) -> Result<PathBuf> {
4709    if workspace.as_os_str().is_empty() {
4710        bail!("project workspace path cannot be empty");
4711    }
4712    if workspace
4713        .components()
4714        .any(|component| matches!(component, Component::ParentDir))
4715    {
4716        bail!("project workspace path cannot contain '..' components");
4717    }
4718    let absolute = if workspace.is_absolute() {
4719        workspace.to_path_buf()
4720    } else {
4721        std::env::current_dir()
4722            .context("failed to resolve current directory for project workspace")?
4723            .join(workspace)
4724    };
4725    match absolute.canonicalize() {
4726        Ok(path) => Ok(path),
4727        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
4728            Ok(normalize_path_components(&absolute))
4729        }
4730        Err(err) => Err(err).with_context(|| {
4731            format!(
4732                "failed to resolve project workspace {}",
4733                workspace.display()
4734            )
4735        }),
4736    }
4737}
4738
4739fn normalize_path_components(path: &Path) -> PathBuf {
4740    let mut normalized = PathBuf::new();
4741    for component in path.components() {
4742        match component {
4743            Component::Prefix(_) | Component::RootDir => normalized.push(component.as_os_str()),
4744            Component::CurDir => {}
4745            Component::ParentDir => {
4746                normalized.pop();
4747            }
4748            Component::Normal(part) => normalized.push(part),
4749        }
4750    }
4751    if normalized.as_os_str().is_empty() {
4752        PathBuf::from(".")
4753    } else {
4754        normalized
4755    }
4756}
4757
4758fn checked_path_exists(path: &Path) -> Result<bool> {
4759    let path = normalize_config_file_path(path.to_path_buf())?;
4760    path.try_exists()
4761        .with_context(|| format!("failed to inspect config path {}", path.display()))
4762}
4763
4764fn read_checked_config_file(path: &Path) -> Result<String> {
4765    read_checked_toml_file(path, "config")
4766}
4767
4768fn read_checked_permissions_file(path: &Path) -> Result<String> {
4769    read_checked_toml_file(path, "permissions")
4770}
4771
4772fn read_checked_toml_file(path: &Path, label: &str) -> Result<String> {
4773    let path = normalize_config_file_path(path.to_path_buf())?;
4774    read_string_no_follow(&path)
4775        .with_context(|| format!("failed to read {label} at {}", path.display()))
4776}
4777
4778#[cfg(unix)]
4779fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
4780    let mut file = fs::OpenOptions::new()
4781        .read(true)
4782        .custom_flags(libc::O_NOFOLLOW)
4783        .open(path)?;
4784    let mut raw = String::new();
4785    file.read_to_string(&mut raw)?;
4786    Ok(raw)
4787}
4788
4789#[cfg(not(unix))]
4790fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
4791    fs::read_to_string(path)
4792}
4793
4794fn reject_path_symlink(path: &Path) -> Result<()> {
4795    let Ok(metadata) = fs::symlink_metadata(path) else {
4796        return Ok(());
4797    };
4798    if metadata.file_type().is_symlink() {
4799        bail!("config path must not be a symlink: {}", path.display());
4800    }
4801    Ok(())
4802}
4803
4804#[derive(Debug, Clone, Default)]
4805struct EnvRuntimeOverrides {
4806    provider: Option<ProviderKind>,
4807    provider_source: Option<&'static str>,
4808    model: Option<String>,
4809    volcengine_model: Option<String>,
4810    wanjie_ark_model: Option<String>,
4811    openrouter_model: Option<String>,
4812    moonshot_model: Option<String>,
4813    xiaomi_mimo_model: Option<String>,
4814    xiaomi_mimo_mode: Option<String>,
4815    novita_model: Option<String>,
4816    fireworks_model: Option<String>,
4817    arcee_model: Option<String>,
4818    output_mode: Option<String>,
4819    auth_mode: Option<String>,
4820    log_level: Option<String>,
4821    telemetry: Option<bool>,
4822    approval_policy: Option<String>,
4823    sandbox_mode: Option<String>,
4824    yolo: Option<bool>,
4825    verbosity: Option<String>,
4826    http_headers: Option<BTreeMap<String, String>>,
4827    deepseek_base_url: Option<String>,
4828    deepseek_anthropic_base_url: Option<String>,
4829    nvidia_base_url: Option<String>,
4830    openai_base_url: Option<String>,
4831    atlascloud_base_url: Option<String>,
4832    volcengine_base_url: Option<String>,
4833    wanjie_ark_base_url: Option<String>,
4834    openrouter_base_url: Option<String>,
4835    xiaomi_mimo_base_url: Option<String>,
4836    novita_base_url: Option<String>,
4837    fireworks_base_url: Option<String>,
4838    siliconflow_base_url: Option<String>,
4839    siliconflow_model: Option<String>,
4840    arcee_base_url: Option<String>,
4841    moonshot_base_url: Option<String>,
4842    sglang_base_url: Option<String>,
4843    vllm_base_url: Option<String>,
4844    ollama_base_url: Option<String>,
4845    huggingface_base_url: Option<String>,
4846    huggingface_model: Option<String>,
4847    together_base_url: Option<String>,
4848    together_model: Option<String>,
4849    qianfan_base_url: Option<String>,
4850    qianfan_model: Option<String>,
4851    openai_codex_base_url: Option<String>,
4852    openai_codex_model: Option<String>,
4853    anthropic_base_url: Option<String>,
4854    anthropic_model: Option<String>,
4855    openmodel_base_url: Option<String>,
4856    openmodel_model: Option<String>,
4857    zai_base_url: Option<String>,
4858    zai_model: Option<String>,
4859    stepfun_base_url: Option<String>,
4860    stepfun_model: Option<String>,
4861    minimax_base_url: Option<String>,
4862    minimax_anthropic_base_url: Option<String>,
4863    minimax_model: Option<String>,
4864    deepinfra_base_url: Option<String>,
4865    deepinfra_model: Option<String>,
4866    sakana_base_url: Option<String>,
4867    sakana_model: Option<String>,
4868    longcat_base_url: Option<String>,
4869    longcat_model: Option<String>,
4870    opencode_go_base_url: Option<String>,
4871    opencode_go_model: Option<String>,
4872    meta_base_url: Option<String>,
4873    meta_model: Option<String>,
4874    xai_base_url: Option<String>,
4875    xai_model: Option<String>,
4876    telecomjs_base_url: Option<String>,
4877    telecomjs_model: Option<String>,
4878}
4879
4880impl EnvRuntimeOverrides {
4881    fn load() -> Self {
4882        let (provider, provider_source) = Self::load_provider();
4883        Self {
4884            provider,
4885            provider_source,
4886            model: std::env::var("CODEWHALE_MODEL")
4887                .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
4888                .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
4889                .ok()
4890                .filter(|v| !v.trim().is_empty()),
4891            volcengine_model: std::env::var("VOLCENGINE_MODEL")
4892                .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
4893                .ok()
4894                .filter(|v| !v.trim().is_empty()),
4895            wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
4896                .or_else(|_| std::env::var("WANJIE_MODEL"))
4897                .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
4898                .ok()
4899                .filter(|v| !v.trim().is_empty()),
4900            openrouter_model: std::env::var("OPENROUTER_MODEL")
4901                .ok()
4902                .filter(|v| !v.trim().is_empty()),
4903            moonshot_model: std::env::var("MOONSHOT_MODEL")
4904                .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
4905                .or_else(|_| std::env::var("KIMI_MODEL"))
4906                .ok()
4907                .filter(|v| !v.trim().is_empty()),
4908            xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
4909                .or_else(|_| std::env::var("MIMO_MODEL"))
4910                .ok()
4911                .filter(|v| !v.trim().is_empty()),
4912            xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
4913                .or_else(|_| std::env::var("MIMO_MODE"))
4914                .ok()
4915                .filter(|v| !v.trim().is_empty()),
4916            novita_model: std::env::var("NOVITA_MODEL")
4917                .ok()
4918                .filter(|v| !v.trim().is_empty()),
4919            fireworks_model: std::env::var("FIREWORKS_MODEL")
4920                .ok()
4921                .filter(|v| !v.trim().is_empty()),
4922            arcee_model: std::env::var("ARCEE_MODEL")
4923                .ok()
4924                .filter(|v| !v.trim().is_empty()),
4925            verbosity: std::env::var("CODEWHALE_VERBOSITY")
4926                .or_else(|_| std::env::var("DEEPSEEK_VERBOSITY"))
4927                .ok(),
4928            output_mode: std::env::var("CODEWHALE_OUTPUT_MODE")
4929                .or_else(|_| std::env::var("DEEPSEEK_OUTPUT_MODE"))
4930                .ok(),
4931            auth_mode: std::env::var("CODEWHALE_AUTH_MODE")
4932                .or_else(|_| std::env::var("DEEPSEEK_AUTH_MODE"))
4933                .ok(),
4934            log_level: std::env::var("CODEWHALE_LOG_LEVEL")
4935                .or_else(|_| std::env::var("DEEPSEEK_LOG_LEVEL"))
4936                .ok(),
4937            telemetry: std::env::var("CODEWHALE_TELEMETRY")
4938                .or_else(|_| std::env::var("DEEPSEEK_TELEMETRY"))
4939                .ok()
4940                .and_then(|v| match parse_bool(&v) {
4941                    Ok(b) => Some(b),
4942                    Err(_) => {
4943                        tracing::warn!("Invalid CODEWHALE_TELEMETRY/DEEPSEEK_TELEMETRY value '{v}', expected true/false");
4944                        None
4945                    }
4946                }),
4947            approval_policy: std::env::var("CODEWHALE_APPROVAL_POLICY")
4948                .or_else(|_| std::env::var("DEEPSEEK_APPROVAL_POLICY"))
4949                .ok(),
4950            sandbox_mode: std::env::var("CODEWHALE_SANDBOX_MODE")
4951                .or_else(|_| std::env::var("DEEPSEEK_SANDBOX_MODE"))
4952                .ok(),
4953            yolo: std::env::var("CODEWHALE_YOLO")
4954                .or_else(|_| std::env::var("DEEPSEEK_YOLO"))
4955                .ok()
4956                .and_then(|v| match parse_bool(&v) {
4957                    Ok(b) => Some(b),
4958                    Err(_) => {
4959                        tracing::warn!("Invalid CODEWHALE_YOLO/DEEPSEEK_YOLO value '{v}', expected true/false");
4960                        None
4961                    }
4962                }),
4963            http_headers: std::env::var("CODEWHALE_HTTP_HEADERS")
4964                .or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
4965                .ok()
4966                .and_then(|value| match parse_http_headers(&value) {
4967                    Ok(h) => Some(h),
4968                    Err(_) => {
4969                        tracing::warn!("Invalid CODEWHALE_HTTP_HEADERS/DEEPSEEK_HTTP_HEADERS value, expected format: header1=val1,header2=val2");
4970                        None
4971                    }
4972                })
4973                .filter(|headers| !headers.is_empty()),
4974            deepseek_base_url: std::env::var("CODEWHALE_BASE_URL")
4975                .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
4976                .ok()
4977                .filter(|v| !v.trim().is_empty()),
4978            deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
4979                .or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
4980                .ok()
4981                .filter(|v| !v.trim().is_empty()),
4982            nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
4983                .or_else(|_| std::env::var("NIM_BASE_URL"))
4984                .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
4985                .ok()
4986                .filter(|v| !v.trim().is_empty()),
4987            openai_base_url: std::env::var("OPENAI_BASE_URL")
4988                .ok()
4989                .filter(|v| !v.trim().is_empty()),
4990            atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
4991                .ok()
4992                .filter(|v| !v.trim().is_empty()),
4993            volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
4994                .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
4995                .or_else(|_| std::env::var("ARK_BASE_URL"))
4996                .ok()
4997                .filter(|v| !v.trim().is_empty()),
4998            wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
4999                .or_else(|_| std::env::var("WANJIE_BASE_URL"))
5000                .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
5001                .ok()
5002                .filter(|v| !v.trim().is_empty()),
5003            openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
5004                .ok()
5005                .filter(|v| !v.trim().is_empty()),
5006            xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
5007                .or_else(|_| std::env::var("MIMO_BASE_URL"))
5008                .ok()
5009                .filter(|v| !v.trim().is_empty()),
5010            novita_base_url: std::env::var("NOVITA_BASE_URL")
5011                .ok()
5012                .filter(|v| !v.trim().is_empty()),
5013            fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
5014                .ok()
5015                .filter(|v| !v.trim().is_empty()),
5016            siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
5017                .ok()
5018                .filter(|v| !v.trim().is_empty()),
5019            siliconflow_model: std::env::var("SILICONFLOW_MODEL")
5020                .ok()
5021                .filter(|v| !v.trim().is_empty()),
5022            arcee_base_url: std::env::var("ARCEE_BASE_URL")
5023                .ok()
5024                .filter(|v| !v.trim().is_empty()),
5025            moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
5026                .or_else(|_| std::env::var("KIMI_BASE_URL"))
5027                .ok()
5028                .filter(|v| !v.trim().is_empty()),
5029            sglang_base_url: std::env::var("SGLANG_BASE_URL")
5030                .ok()
5031                .filter(|v| !v.trim().is_empty()),
5032            vllm_base_url: std::env::var("VLLM_BASE_URL")
5033                .ok()
5034                .filter(|v| !v.trim().is_empty()),
5035            ollama_base_url: std::env::var("OLLAMA_BASE_URL")
5036                .ok()
5037                .filter(|v| !v.trim().is_empty()),
5038            huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
5039                .or_else(|_| std::env::var("HF_BASE_URL"))
5040                .ok()
5041                .filter(|v| !v.trim().is_empty()),
5042            huggingface_model: std::env::var("HUGGINGFACE_MODEL")
5043                .or_else(|_| std::env::var("HF_MODEL"))
5044                .ok()
5045                .filter(|v| !v.trim().is_empty()),
5046            together_base_url: std::env::var("TOGETHER_BASE_URL")
5047                .ok()
5048                .filter(|v| !v.trim().is_empty()),
5049            together_model: std::env::var("TOGETHER_MODEL")
5050                .ok()
5051                .filter(|v| !v.trim().is_empty()),
5052            qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
5053                .ok()
5054                .filter(|v| !v.trim().is_empty())
5055                .or_else(|| {
5056                    std::env::var("BAIDU_QIANFAN_BASE_URL")
5057                        .ok()
5058                        .filter(|v| !v.trim().is_empty())
5059                }),
5060            qianfan_model: std::env::var("QIANFAN_MODEL")
5061                .ok()
5062                .filter(|v| !v.trim().is_empty())
5063                .or_else(|| {
5064                    std::env::var("BAIDU_QIANFAN_MODEL")
5065                        .ok()
5066                        .filter(|v| !v.trim().is_empty())
5067                }),
5068            openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
5069                .or_else(|_| std::env::var("CODEX_BASE_URL"))
5070                .ok()
5071                .filter(|v| !v.trim().is_empty()),
5072            openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
5073                .or_else(|_| std::env::var("CODEX_MODEL"))
5074                .ok()
5075                .filter(|v| !v.trim().is_empty()),
5076            anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
5077                .ok()
5078                .filter(|v| !v.trim().is_empty()),
5079            anthropic_model: std::env::var("ANTHROPIC_MODEL")
5080                .ok()
5081                .filter(|v| !v.trim().is_empty()),
5082            openmodel_base_url: std::env::var("OPENMODEL_BASE_URL")
5083                .ok()
5084                .filter(|v| !v.trim().is_empty()),
5085            openmodel_model: std::env::var("OPENMODEL_MODEL")
5086                .ok()
5087                .filter(|v| !v.trim().is_empty()),
5088            zai_base_url: std::env::var("ZAI_BASE_URL")
5089                .or_else(|_| std::env::var("Z_AI_BASE_URL"))
5090                .or_else(|_| std::env::var("ZHIPU_BASE_URL"))
5091                .or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
5092                .or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
5093                .ok()
5094                .filter(|v| !v.trim().is_empty()),
5095            zai_model: std::env::var("ZAI_MODEL")
5096                .or_else(|_| std::env::var("Z_AI_MODEL"))
5097                .or_else(|_| std::env::var("ZHIPU_MODEL"))
5098                .or_else(|_| std::env::var("ZHIPUAI_MODEL"))
5099                .or_else(|_| std::env::var("BIGMODEL_MODEL"))
5100                .or_else(|_| std::env::var("GLM_MODEL"))
5101                .ok()
5102                .filter(|v| !v.trim().is_empty()),
5103            stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
5104                .or_else(|_| std::env::var("STEP_BASE_URL"))
5105                .ok()
5106                .filter(|v| !v.trim().is_empty()),
5107            stepfun_model: std::env::var("STEPFUN_MODEL")
5108                .or_else(|_| std::env::var("STEP_MODEL"))
5109                .ok()
5110                .filter(|v| !v.trim().is_empty()),
5111            minimax_base_url: std::env::var("MINIMAX_BASE_URL")
5112                .ok()
5113                .filter(|v| !v.trim().is_empty()),
5114            minimax_anthropic_base_url: std::env::var("MINIMAX_ANTHROPIC_BASE_URL")
5115                .ok()
5116                .filter(|v| !v.trim().is_empty()),
5117            minimax_model: std::env::var("MINIMAX_MODEL")
5118                .ok()
5119                .filter(|v| !v.trim().is_empty()),
5120            deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
5121                .ok()
5122                .filter(|v| !v.trim().is_empty()),
5123            deepinfra_model: std::env::var("DEEPINFRA_MODEL")
5124                .ok()
5125                .filter(|v| !v.trim().is_empty()),
5126            sakana_base_url: std::env::var("SAKANA_BASE_URL")
5127                .ok()
5128                .filter(|v| !v.trim().is_empty()),
5129            sakana_model: std::env::var("SAKANA_MODEL")
5130                .ok()
5131                .filter(|v| !v.trim().is_empty()),
5132            longcat_base_url: std::env::var("LONGCAT_BASE_URL")
5133                .ok()
5134                .filter(|v| !v.trim().is_empty()),
5135            longcat_model: std::env::var("LONGCAT_MODEL")
5136                .ok()
5137                .filter(|v| !v.trim().is_empty()),
5138            opencode_go_base_url: std::env::var("OPENCODE_GO_BASE_URL")
5139                .ok()
5140                .filter(|v| !v.trim().is_empty()),
5141            opencode_go_model: std::env::var("OPENCODE_GO_MODEL")
5142                .ok()
5143                .filter(|v| !v.trim().is_empty()),
5144            meta_base_url: std::env::var("META_MODEL_API_BASE_URL")
5145                .ok()
5146                .filter(|v| !v.trim().is_empty())
5147                .or_else(|| {
5148                    std::env::var("MODEL_API_BASE_URL")
5149                        .ok()
5150                        .filter(|v| !v.trim().is_empty())
5151                }),
5152            meta_model: std::env::var("META_MODEL_API_MODEL")
5153                .ok()
5154                .filter(|v| !v.trim().is_empty())
5155                .or_else(|| {
5156                    std::env::var("MODEL_API_MODEL")
5157                        .ok()
5158                        .filter(|v| !v.trim().is_empty())
5159                }),
5160            xai_base_url: std::env::var("XAI_BASE_URL")
5161                .ok()
5162                .filter(|v| !v.trim().is_empty()),
5163            xai_model: std::env::var("XAI_MODEL")
5164                .ok()
5165                .filter(|v| !v.trim().is_empty()),
5166            telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL")
5167                .ok()
5168                .filter(|v| !v.trim().is_empty()),
5169            telecomjs_model: std::env::var("TELECOMJS_MODEL")
5170                .ok()
5171                .filter(|v| !v.trim().is_empty()),
5172        }
5173    }
5174
5175    fn load_provider() -> (Option<ProviderKind>, Option<&'static str>) {
5176        if let Ok(value) = std::env::var("CODEWHALE_PROVIDER") {
5177            let parsed = ProviderKind::parse(&value);
5178            return (parsed, parsed.map(|_| "CODEWHALE_PROVIDER"));
5179        }
5180
5181        if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
5182            let parsed = ProviderKind::parse(&value);
5183            return (parsed, parsed.map(|_| "DEEPSEEK_PROVIDER"));
5184        }
5185
5186        (None, None)
5187    }
5188
5189    fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
5190        // Defaults belong in the resolver's final fallback so config-file
5191        // values (`providers.<name>.base_url`) still win when env is unset.
5192        match provider {
5193            ProviderKind::Deepseek => self.deepseek_base_url.clone(),
5194            ProviderKind::DeepseekAnthropic => self.deepseek_anthropic_base_url.clone(),
5195            ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
5196            ProviderKind::Openai => self.openai_base_url.clone(),
5197            ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
5198            ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
5199            ProviderKind::Volcengine => self.volcengine_base_url.clone(),
5200            ProviderKind::Openrouter => self.openrouter_base_url.clone(),
5201            ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
5202            ProviderKind::Novita => self.novita_base_url.clone(),
5203            ProviderKind::Fireworks => self.fireworks_base_url.clone(),
5204            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
5205                self.siliconflow_base_url.clone()
5206            }
5207            ProviderKind::Arcee => self.arcee_base_url.clone(),
5208            ProviderKind::Moonshot => self.moonshot_base_url.clone(),
5209            ProviderKind::Sglang => self.sglang_base_url.clone(),
5210            ProviderKind::Vllm => self.vllm_base_url.clone(),
5211            ProviderKind::Ollama => self.ollama_base_url.clone(),
5212            ProviderKind::Huggingface => self.huggingface_base_url.clone(),
5213            ProviderKind::Together => self.together_base_url.clone(),
5214            ProviderKind::Qianfan => self.qianfan_base_url.clone(),
5215            ProviderKind::OpenaiCodex => self.openai_codex_base_url.clone(),
5216            ProviderKind::Anthropic => self.anthropic_base_url.clone(),
5217            ProviderKind::Openmodel => self.openmodel_base_url.clone(),
5218            ProviderKind::Zai => self.zai_base_url.clone(),
5219            ProviderKind::Stepfun => self.stepfun_base_url.clone(),
5220            ProviderKind::Minimax => self.minimax_base_url.clone(),
5221            ProviderKind::MinimaxAnthropic => self.minimax_anthropic_base_url.clone(),
5222            ProviderKind::Deepinfra => self.deepinfra_base_url.clone(),
5223            ProviderKind::Sakana => self.sakana_base_url.clone(),
5224            ProviderKind::LongCat => self.longcat_base_url.clone(),
5225            ProviderKind::OpencodeGo => self.opencode_go_base_url.clone(),
5226            ProviderKind::Meta => self.meta_base_url.clone(),
5227            ProviderKind::Xai => self.xai_base_url.clone(),
5228            ProviderKind::Telecomjs => self.telecomjs_base_url.clone(),
5229            // No dedicated CODEWHALE_CUSTOM_BASE_URL env override: a custom
5230            // provider's base URL comes from its `[providers.<name>]` table.
5231            ProviderKind::Custom => None,
5232        }
5233    }
5234
5235    fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
5236        let model = match provider {
5237            ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
5238            ProviderKind::Volcengine => self.volcengine_model.clone(),
5239            ProviderKind::Openrouter => self.openrouter_model.clone(),
5240            ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
5241                self.siliconflow_model.clone()
5242            }
5243            ProviderKind::Arcee => self.arcee_model.clone(),
5244            ProviderKind::Moonshot => self.moonshot_model.clone(),
5245            ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
5246            ProviderKind::Novita => self.novita_model.clone(),
5247            ProviderKind::Fireworks => self.fireworks_model.clone(),
5248            ProviderKind::Huggingface => self.huggingface_model.clone(),
5249            ProviderKind::Together => self.together_model.clone(),
5250            ProviderKind::Qianfan => self.qianfan_model.clone(),
5251            ProviderKind::OpenaiCodex => self.openai_codex_model.clone(),
5252            ProviderKind::Anthropic => self.anthropic_model.clone(),
5253            ProviderKind::Openmodel => self.openmodel_model.clone(),
5254            ProviderKind::Zai => self.zai_model.clone(),
5255            ProviderKind::Stepfun => self.stepfun_model.clone(),
5256            ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => self.minimax_model.clone(),
5257            ProviderKind::Deepinfra => self.deepinfra_model.clone(),
5258            ProviderKind::Sakana => self.sakana_model.clone(),
5259            ProviderKind::LongCat => self.longcat_model.clone(),
5260            ProviderKind::OpencodeGo => self.opencode_go_model.clone(),
5261            ProviderKind::Meta => self.meta_model.clone(),
5262            ProviderKind::Xai => self.xai_model.clone(),
5263            ProviderKind::Telecomjs => self.telecomjs_model.clone(),
5264            _ => None,
5265        }?;
5266
5267        if provider_preserves_custom_base_url_model(provider, base_url) {
5268            Some(model.trim().to_string())
5269        } else {
5270            Some(normalize_model_for_provider(provider, &model))
5271        }
5272    }
5273}
5274
5275#[cfg(test)]
5276mod tests;