返回 CodeWhale
config.rs
根目录 / crates / tui / src / config.rs
1 //! Configuration loading and defaults for codewhale.
2
3 use std::collections::HashMap;
4 use std::fs;
5 use std::path::{Path, PathBuf};
6
7 use anyhow::{Context, Result};
8 use codewhale_execpolicy::ExecPolicyEngine;
9 use serde::{Deserialize, Serialize};
10 use serde_json::json;
11 #[cfg(unix)]
12 use std::os::unix::fs::PermissionsExt;
13
14 use crate::audit::log_sensitive_event;
15 use crate::features::{Feature, Features, FeaturesToml, is_known_feature_key};
16 use crate::hooks::HooksConfig;
17
18 // Sub-agent concurrency/timeout limit constants and their clamp resolvers live
19 // in the `subagent_limits` leaf module. The constants are re-exported (keeping
20 // each item's visibility) so `crate::config::<CONST>` paths resolve unchanged;
21 // the private resolvers are pulled back in without widening external surface
22 // (#3311).
23 #[cfg(test)]
24 mod scope_tests;
25 mod subagent_limits;
26 pub use subagent_limits::*;
27 use subagent_limits::{resolve_subagent_api_timeout_secs, resolve_subagent_heartbeat_timeout_secs};
28
29 // Provider model-name and base-URL constants live in the `models` leaf module
30 // and are re-exported below so every `crate::config::<CONST>` path is unchanged
31 // (#3311).
32 mod models;
33 pub use models::*;
34
35 #[cfg(test)]
36 pub(crate) use codewhale_config::API_KEYRING_SENTINEL;
37 pub(crate) use codewhale_config::{ConfigApiKeyValueKind, classify_config_api_key_value};
38
39 pub const DEFAULT_ZAI_PROVIDER_MAX_CONCURRENCY: usize = 3;
40 pub const MAX_PROVIDER_REQUEST_CONCURRENCY: usize = 64;
41
42 pub fn default_stop_words() -> Vec<String> {
43 ["stop", "wait", "pause"]
44 .into_iter()
45 .map(str::to_string)
46 .collect()
47 }
48
49 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
50 #[serde(rename_all = "snake_case")]
51 pub enum ApiProvider {
52 Deepseek,
53 DeepseekCN,
54 DeepseekAnthropic,
55 NvidiaNim,
56 Openai,
57 Atlascloud,
58 WanjieArk,
59 Volcengine,
60 Openrouter,
61 XiaomiMimo,
62 Novita,
63 Fireworks,
64 Siliconflow,
65 SiliconflowCn,
66 Arcee,
67 Moonshot,
68 Sglang,
69 Vllm,
70 Ollama,
71 Huggingface,
72 Together,
73 Qianfan,
74 OpenaiCodex,
75 Anthropic,
76 Openmodel,
77 Zai,
78 Stepfun,
79 Minimax,
80 MinimaxAnthropic,
81 Deepinfra,
82 Sakana,
83 LongCat,
84 OpencodeGo,
85 OpencodeZen,
86 Meta,
87 Xai,
88 /// Jiangsu Telecom TokenHub — OpenAI-compatible AI gateway.
89 Telecomjs,
90 /// Alibaba Cloud Model Studio — Token Plan (OpenAI-compatible Chat Completions).
91 ModelstudioTokenPlan,
92 /// Alibaba Cloud Model Studio — Token Plan Anthropic-compatible endpoint.
93 ModelstudioTokenPlanAnthropic,
94 /// Alibaba Cloud Model Studio — Coding Plan (OpenAI-compatible Chat Completions).
95 ModelstudioCodingPlan,
96 /// Alibaba Cloud Model Studio — Coding Plan Anthropic-compatible endpoint.
97 ModelstudioCodingPlanAnthropic,
98 /// User-defined OpenAI-compatible endpoint (#1519).
99 ///
100 /// Selected when `provider = "<name>"` names a `[providers.<name>]
101 /// kind="openai-compatible"` table. A single dynamic identity that maps to
102 /// [`codewhale_config::ProviderKind::Custom`] and routes via the OpenAI Chat
103 /// Completions wire protocol; the concrete endpoint/model/auth come from the
104 /// named config table, not from this variant.
105 Custom,
106 }
107
108 /// Exact, non-secret provider identity resolved from live configuration.
109 ///
110 /// Built-ins use their canonical slug (for example `openrouter`). Dynamic
111 /// custom providers keep the user-owned `[providers.<name>]` key so session
112 /// persistence never collapses `lm-studio` into the generic `custom` kind.
113 #[derive(Debug, Clone, PartialEq, Eq)]
114 pub(crate) struct ProviderIdentity {
115 pub(crate) provider: ApiProvider,
116 pub(crate) key: String,
117 /// Additive exact configured provider id written by current persistence
118 /// schemas. `None` is meaningful: it identifies the released legacy
119 /// root-level `provider = "custom"` route and must never be upgraded to an
120 /// exact `[providers.custom]` table merely because one exists later.
121 pub(crate) exact_id: Option<String>,
122 }
123
124 impl ProviderIdentity {
125 #[must_use]
126 pub(crate) fn persisted_id(&self) -> Option<&str> {
127 self.exact_id.as_deref()
128 }
129 }
130
131 impl ApiProvider {
132 #[must_use]
133 pub fn names_hint() -> String {
134 let mut names = Vec::with_capacity(Self::all().len() + 1);
135 names.push(Self::Deepseek.as_str());
136 names.push(Self::DeepseekCN.as_str());
137 names.extend(
138 Self::all()
139 .iter()
140 .filter(|provider| !matches!(provider, Self::Deepseek))
141 .map(|provider| provider.as_str()),
142 );
143 names.join(", ")
144 }
145
146 #[must_use]
147 pub fn parse(value: &str) -> Option<Self> {
148 let trimmed = value.trim();
149 // ApiProvider-specific: "deepseek-cn" is a legacy variant here,
150 // while ProviderKind treats it as a Deepseek alias.
151 if trimmed.eq_ignore_ascii_case("deepseek-cn")
152 || trimmed.eq_ignore_ascii_case("deepseek_china")
153 || trimmed.eq_ignore_ascii_case("deepseekcn")
154 || trimmed.eq_ignore_ascii_case("deepseek-china")
155 {
156 return Some(Self::DeepseekCN);
157 }
158 // Legacy dual-wire slugs keep their own `[providers.<slug>]` tables,
159 // credential slots, and default models even though catalog surfaces
160 // collapse them onto the vendor primary (`ProviderKind::ALL`, and
161 // `catalog_identity` for UI). `ProviderKind::parse` resolves these
162 // spellings as primary aliases, which would orphan the legacy table a
163 // pre-0.9.4 config actually selects: credentials, base_url, and model
164 // pinned under `[providers.deepseek-anthropic]` /
165 // `[providers.minimax-anthropic]` must keep resolving for
166 // `provider = "deepseek-anthropic"` / `"minimax-anthropic"`.
167 if trimmed.eq_ignore_ascii_case("deepseek-anthropic")
168 || trimmed.eq_ignore_ascii_case("deepseek_anthropic")
169 || trimmed.eq_ignore_ascii_case("deepseek-claude")
170 || trimmed.eq_ignore_ascii_case("deepseek_claude")
171 {
172 return Some(Self::DeepseekAnthropic);
173 }
174 if trimmed.eq_ignore_ascii_case("minimax-anthropic")
175 || trimmed.eq_ignore_ascii_case("minimax_anthropic")
176 || trimmed.eq_ignore_ascii_case("mini-max-anthropic")
177 || trimmed.eq_ignore_ascii_case("mini_max_anthropic")
178 {
179 return Some(Self::MinimaxAnthropic);
180 }
181 codewhale_config::ProviderKind::parse(value).map(Self::from_kind)
182 }
183
184 #[must_use]
185 pub fn as_str(self) -> &'static str {
186 match self.kind() {
187 Some(kind) => kind.as_str(),
188 None => "deepseek-cn",
189 }
190 }
191
192 /// Human-friendly label for picker UIs / status chips.
193 #[must_use]
194 pub fn display_name(self) -> &'static str {
195 match self.kind() {
196 Some(kind) => kind.provider().display_name(),
197 None => "DeepSeek (legacy alias)",
198 }
199 }
200
201 /// Provider metadata from the shared config crate.
202 ///
203 /// Returns `None` only for the TUI-only legacy `DeepseekCN` variant, which
204 /// intentionally keeps its own config table while sharing DeepSeek auth envs.
205 #[must_use]
206 pub fn metadata(self) -> Option<&'static dyn codewhale_config::provider::Provider> {
207 self.kind().map(|kind| kind.provider())
208 }
209
210 /// Environment variable candidates for this provider's API key.
211 #[must_use]
212 pub fn env_vars(self) -> &'static [&'static str] {
213 self.metadata().map_or(
214 codewhale_config::ProviderKind::Deepseek
215 .provider()
216 .env_vars(),
217 |provider| provider.env_vars(),
218 )
219 }
220
221 /// Environment variable candidates formatted for UI copy.
222 #[must_use]
223 pub fn env_vars_label(self) -> String {
224 self.env_vars().join(" / ")
225 }
226
227 /// Providers ordered for picker/browsing surfaces.
228 #[must_use]
229 pub fn sorted_for_display() -> Vec<Self> {
230 codewhale_config::provider::providers_sorted_for_display()
231 .iter()
232 .map(|provider| Self::from_kind(provider.kind()))
233 .collect()
234 }
235
236 /// Default base URL for this provider.
237 #[must_use]
238 pub fn default_base_url(self) -> &'static str {
239 match self {
240 Self::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,
241 // Mirror credential_help()/env_vars(): a variant without
242 // registered metadata falls back to the DeepSeek defaults
243 // instead of panicking at startup/render. The
244 // all_provider_variants_have_metadata test guards the table.
245 _ => self.metadata().map_or_else(
246 || {
247 codewhale_config::ProviderKind::Deepseek
248 .provider()
249 .default_base_url()
250 },
251 |provider| provider.default_base_url(),
252 ),
253 }
254 }
255
256 /// Canonical credential acquisition metadata shared by provider surfaces.
257 #[must_use]
258 pub fn credential_help(self) -> codewhale_config::provider::CredentialHelp {
259 self.metadata().map_or_else(
260 || {
261 codewhale_config::provider::provider_for_kind(
262 codewhale_config::ProviderKind::Deepseek,
263 )
264 .credential_help()
265 },
266 codewhale_config::provider::Provider::credential_help,
267 )
268 }
269
270 /// Official provider page for creating or locating credentials.
271 #[must_use]
272 pub fn credential_url(self) -> Option<&'static str> {
273 self.credential_help().credential_url
274 }
275
276 /// All providers including legacy dual-wire / plan-variant kinds.
277 ///
278 /// Prefer [`Self::catalog`] for pickers and other user-facing lists.
279 #[must_use]
280 pub fn all() -> &'static [Self] {
281 &Self::FROM_KIND_LOOKUP
282 }
283
284 /// User-facing catalog surface: one identity per vendor.
285 ///
286 /// Matches `ProviderKind::ALL` — dialect is `providers.<id>.wire`, plan is
287 /// `mode` / base_url (Z.ai / Xiaomi shape), not extra ProviderKinds.
288 #[must_use]
289 pub fn catalog() -> &'static [Self] {
290 static CATALOG: std::sync::OnceLock<Vec<ApiProvider>> = std::sync::OnceLock::new();
291 CATALOG
292 .get_or_init(|| {
293 codewhale_config::ProviderKind::ALL
294 .iter()
295 .copied()
296 .map(Self::from_kind)
297 .collect()
298 })
299 .as_slice()
300 }
301
302 /// Collapse legacy dialect/plan kinds onto the vendor primary for UI.
303 #[must_use]
304 pub fn catalog_identity(self) -> Self {
305 match self {
306 Self::DeepseekAnthropic => Self::Deepseek,
307 Self::MinimaxAnthropic => Self::Minimax,
308 Self::ModelstudioTokenPlanAnthropic
309 | Self::ModelstudioCodingPlan
310 | Self::ModelstudioCodingPlanAnthropic => Self::ModelstudioTokenPlan,
311 other => other,
312 }
313 }
314
315 /// `ApiProvider` discriminant → `ProviderKind` lookup.
316 /// Index 1 is `None` for the legacy `DeepseekCN` variant.
317 const KIND_LOOKUP: [Option<codewhale_config::ProviderKind>; 42] = [
318 Some(codewhale_config::ProviderKind::Deepseek),
319 None, // DeepseekCN
320 Some(codewhale_config::ProviderKind::DeepseekAnthropic),
321 Some(codewhale_config::ProviderKind::NvidiaNim),
322 Some(codewhale_config::ProviderKind::Openai),
323 Some(codewhale_config::ProviderKind::Atlascloud),
324 Some(codewhale_config::ProviderKind::WanjieArk),
325 Some(codewhale_config::ProviderKind::Volcengine),
326 Some(codewhale_config::ProviderKind::Openrouter),
327 Some(codewhale_config::ProviderKind::XiaomiMimo),
328 Some(codewhale_config::ProviderKind::Novita),
329 Some(codewhale_config::ProviderKind::Fireworks),
330 Some(codewhale_config::ProviderKind::Siliconflow),
331 Some(codewhale_config::ProviderKind::SiliconflowCN),
332 Some(codewhale_config::ProviderKind::Arcee),
333 Some(codewhale_config::ProviderKind::Moonshot),
334 Some(codewhale_config::ProviderKind::Sglang),
335 Some(codewhale_config::ProviderKind::Vllm),
336 Some(codewhale_config::ProviderKind::Ollama),
337 Some(codewhale_config::ProviderKind::Huggingface),
338 Some(codewhale_config::ProviderKind::Together),
339 Some(codewhale_config::ProviderKind::Qianfan),
340 Some(codewhale_config::ProviderKind::OpenaiCodex),
341 Some(codewhale_config::ProviderKind::Anthropic),
342 Some(codewhale_config::ProviderKind::Openmodel),
343 Some(codewhale_config::ProviderKind::Zai),
344 Some(codewhale_config::ProviderKind::Stepfun),
345 Some(codewhale_config::ProviderKind::Minimax),
346 Some(codewhale_config::ProviderKind::MinimaxAnthropic),
347 Some(codewhale_config::ProviderKind::Deepinfra),
348 Some(codewhale_config::ProviderKind::Sakana),
349 Some(codewhale_config::ProviderKind::LongCat),
350 Some(codewhale_config::ProviderKind::OpencodeGo),
351 Some(codewhale_config::ProviderKind::OpencodeZen),
352 Some(codewhale_config::ProviderKind::Meta),
353 Some(codewhale_config::ProviderKind::Xai),
354 Some(codewhale_config::ProviderKind::Telecomjs),
355 Some(codewhale_config::ProviderKind::ModelstudioTokenPlan),
356 Some(codewhale_config::ProviderKind::ModelstudioTokenPlanAnthropic),
357 Some(codewhale_config::ProviderKind::ModelstudioCodingPlan),
358 Some(codewhale_config::ProviderKind::ModelstudioCodingPlanAnthropic),
359 Some(codewhale_config::ProviderKind::Custom),
360 ];
361
362 /// `ProviderKind` discriminant → `ApiProvider` lookup.
363 const FROM_KIND_LOOKUP: [Self; 41] = [
364 Self::Deepseek,
365 Self::DeepseekAnthropic,
366 Self::NvidiaNim,
367 Self::Openai,
368 Self::Atlascloud,
369 Self::WanjieArk,
370 Self::Volcengine,
371 Self::Openrouter,
372 Self::XiaomiMimo,
373 Self::Novita,
374 Self::Fireworks,
375 Self::Siliconflow,
376 Self::Arcee,
377 Self::SiliconflowCn,
378 Self::Moonshot,
379 Self::Sglang,
380 Self::Vllm,
381 Self::Ollama,
382 Self::Huggingface,
383 Self::Together,
384 Self::Qianfan,
385 Self::OpenaiCodex,
386 Self::Anthropic,
387 Self::Openmodel,
388 Self::Zai,
389 Self::Stepfun,
390 Self::Minimax,
391 Self::MinimaxAnthropic,
392 Self::Deepinfra,
393 Self::Sakana,
394 Self::LongCat,
395 Self::OpencodeGo,
396 Self::OpencodeZen,
397 Self::Meta,
398 Self::Xai,
399 Self::Telecomjs,
400 Self::ModelstudioTokenPlan,
401 Self::ModelstudioTokenPlanAnthropic,
402 Self::ModelstudioCodingPlan,
403 Self::ModelstudioCodingPlanAnthropic,
404 Self::Custom,
405 ];
406
407 /// Map to the config-level `ProviderKind`.
408 /// Returns `None` for the legacy `DeepseekCN` variant.
409 #[must_use]
410 pub fn kind(self) -> Option<codewhale_config::ProviderKind> {
411 Self::KIND_LOOKUP[self as usize]
412 }
413
414 /// Construct from a config-level `ProviderKind`.
415 #[must_use]
416 pub fn from_kind(kind: codewhale_config::ProviderKind) -> Self {
417 Self::FROM_KIND_LOOKUP[kind as usize]
418 }
419
420 /// Whether this provider is a self-hosted / local runtime.
421 ///
422 /// These run without hosted authentication and keep traffic on the user's
423 /// own infrastructure, so they carry a local/private posture. Used by the
424 /// fallback chain to avoid silently routing a local/private primary out to
425 /// a cloud provider (#2574) and by the `/provider` dashboard's self-hosted
426 /// hint (#3083). Update this list whenever adding a provider whose runtime
427 /// is hosted on the user's own infrastructure.
428 #[must_use]
429 pub fn is_self_hosted(self) -> bool {
430 matches!(self, Self::Sglang | Self::Vllm | Self::Ollama)
431 }
432 }
433
434 fn normalize_subagent_provider_key(value: &str) -> String {
435 value
436 .trim()
437 .to_ascii_lowercase()
438 .chars()
439 .map(|ch| match ch {
440 '-' | '_' | '.' | ' ' => '_',
441 _ => ch,
442 })
443 .collect()
444 }
445
446 fn subagent_provider_key_matches(key: &str, provider: ApiProvider) -> bool {
447 if ApiProvider::parse(key).is_some_and(|candidate| candidate == provider) {
448 return true;
449 }
450
451 let normalized = normalize_subagent_provider_key(key);
452 if normalized == normalize_subagent_provider_key(provider.as_str()) {
453 return true;
454 }
455
456 match provider {
457 ApiProvider::Deepseek => matches!(
458 normalized.as_str(),
459 "deepseek" | "deepseek_api" | "deepseek_official"
460 ),
461 ApiProvider::DeepseekCN => matches!(
462 normalized.as_str(),
463 "deepseek_cn" | "deepseek_china" | "deepseekcn"
464 ),
465 ApiProvider::DeepseekAnthropic => matches!(
466 normalized.as_str(),
467 "deepseek_anthropic" | "deepseek_claude" | "deepseek_anthropic_api"
468 ),
469 ApiProvider::Openrouter => matches!(normalized.as_str(), "openrouter" | "open_router"),
470 ApiProvider::OpenaiCodex => matches!(
471 normalized.as_str(),
472 "openai_codex" | "codex" | "chatgpt" | "openai_chatgpt"
473 ),
474 ApiProvider::Anthropic => {
475 matches!(
476 normalized.as_str(),
477 "anthropic" | "claude" | "anthropic_api"
478 )
479 }
480 ApiProvider::Zai => matches!(
481 normalized.as_str(),
482 "zai"
483 | "z_ai"
484 | "glm"
485 | "zai_glm"
486 | "z_glm"
487 | "zhipu"
488 | "zhipuai"
489 | "bigmodel"
490 | "big_model"
491 | "zhipu_glm"
492 ),
493 ApiProvider::LongCat => matches!(
494 normalized.as_str(),
495 "longcat" | "long_cat" | "meituan_longcat" | "meituan"
496 ),
497 ApiProvider::OpencodeGo => {
498 matches!(normalized.as_str(), "opencode_go" | "opencodego")
499 }
500 ApiProvider::OpencodeZen => matches!(
501 normalized.as_str(),
502 "opencode_zen" | "opencodezen" | "zen" | "opencode"
503 ),
504 ApiProvider::Meta => matches!(
505 normalized.as_str(),
506 "meta" | "meta_ai" | "meta_model_api" | "muse" | "muse_spark"
507 ),
508 ApiProvider::Xai => matches!(normalized.as_str(), "xai" | "x_ai" | "grok"),
509 _ => false,
510 }
511 }
512
513 // ============================================================================
514 // Provider Capability Matrix
515 // ============================================================================
516
517 /// Known capabilities for a provider + resolved-model combination.
518 ///
519 /// Returned by [`provider_capability`] to describe what a given provider
520 /// supports for the resolved model string. All fields are derived from
521 /// static knowledge (release docs, API guides) rather than live API probes.
522 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
523 pub struct ProviderCapability {
524 /// Canonical provider identifier.
525 pub provider: ApiProvider,
526 /// Resolved model identifier that will be sent in the API payload.
527 pub resolved_model: String,
528 /// Context window in tokens (the maximum input the model can accept).
529 pub context_window: u32,
530 /// Known output ceiling for this provider/model metadata path, when one is
531 /// actually known.
532 ///
533 /// `None` means "this route publishes no output maximum we can stand
534 /// behind" — for example the Kimi Code membership ids, whose limits live in
535 /// the membership catalog rather than the static model catalogue. Unknown
536 /// must stay unknown: callers may **not** substitute a placeholder ceiling,
537 /// and in particular [`crate::route_budget`] does not clamp a requested
538 /// `max_tokens` against an unknown compatibility cap.
539 ///
540 /// When `Some`, the value is a documented exact-route maximum or a
541 /// deliberately conservative provider ceiling (Anthropic's 64K floor, the
542 /// Codex OAuth route). It is metadata for diagnostics and CI policy; normal
543 /// turns use a separate, more conservative request cap in the engine.
544 #[serde(skip_serializing_if = "Option::is_none")]
545 pub max_output: Option<u32>,
546 /// Whether the provider+model supports thinking/reasoning mode.
547 pub thinking_supported: bool,
548 /// Whether the provider returns prompt-cache telemetry fields.
549 pub cache_telemetry_supported: bool,
550 /// Which request-payload dialect the provider uses.
551 pub request_payload_mode: RequestPayloadMode,
552 /// Deprecation metadata for compatibility aliases that are still accepted.
553 #[serde(skip_serializing_if = "Option::is_none")]
554 pub alias_deprecation: Option<ModelAliasDeprecation>,
555 }
556
557 pub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = "2026-07-24";
558 pub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = "2026-07-24T15:59:00Z";
559 pub const DEEPSEEK_ALIAS_REPLACEMENT: &str = "deepseek-v4-flash";
560
561 /// Upstream retirement metadata for a model alias that remains compatible.
562 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
563 pub struct ModelAliasDeprecation {
564 pub alias: String,
565 pub replacement: String,
566 pub retirement_date: String,
567 pub retirement_utc: String,
568 pub notice: String,
569 }
570
571 /// Which request-payload dialect the provider speaks.
572 #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
573 pub enum RequestPayloadMode {
574 /// Standard OpenAI-compatible `/v1/chat/completions` payload.
575 ChatCompletions,
576 /// OpenAI Responses API payload.
577 Responses,
578 /// Native Anthropic Messages API `/v1/messages` payload (#3014).
579 AnthropicMessages,
580 }
581
582 /// Resolve the provider capability for a given [`ApiProvider`] and resolved
583 /// model string.
584 ///
585 /// The `resolved_model` should be the final model identifier that will appear
586 /// in the API payload (after normalization / provider-specific mapping).
587 #[must_use]
588 pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {
589 if matches!(
590 provider,
591 ApiProvider::Anthropic | ApiProvider::MinimaxAnthropic | ApiProvider::Openmodel
592 ) {
593 return ProviderCapability {
594 provider,
595 resolved_model: resolved_model.to_string(),
596 // 200K is the conservative Anthropic floor; 4.6+ models resolve
597 // their 1M windows from models.rs rows (#3014).
598 context_window: crate::models::context_window_for_model(resolved_model)
599 .unwrap_or(200_000),
600 // 64K is the documented Anthropic Messages floor, so it stays a
601 // known cap rather than an unknown.
602 max_output: Some(
603 crate::models::max_output_tokens_for_model(resolved_model).unwrap_or(64_000),
604 ),
605 thinking_supported: crate::models::model_supports_reasoning(resolved_model),
606 cache_telemetry_supported: matches!(provider, ApiProvider::Anthropic),
607 request_payload_mode: RequestPayloadMode::AnthropicMessages,
608 alias_deprecation: None,
609 };
610 }
611
612 if matches!(provider, ApiProvider::OpenaiCodex) {
613 return ProviderCapability {
614 provider,
615 resolved_model: resolved_model.to_string(),
616 context_window: OPENAI_CODEX_EFFECTIVE_CONTEXT_WINDOW_TOKENS,
617 // The OAuth cache does not publish an output ceiling. This 4K is a
618 // deliberate, long-standing product decision for the Codex route
619 // (not a fallback): keep the compatibility capability conservative
620 // instead of inheriting the public API model's output limit.
621 max_output: Some(4096),
622 thinking_supported: true,
623 cache_telemetry_supported: false,
624 request_payload_mode: RequestPayloadMode::Responses,
625 alias_deprecation: None,
626 };
627 }
628
629 // #3023: Delete the Openai/Atlascloud/Moonshot early-return so these
630 // providers use the generic model-based path below, which correctly
631 // resolves context windows, output limits, and thinking support from
632 // models.rs lookups. Ollama also falls through to model-based lookups
633 // with 8192 as the last-resort fallback instead of a hardcoded floor.
634 if matches!(provider, ApiProvider::XiaomiMimo) {
635 return ProviderCapability {
636 provider,
637 resolved_model: resolved_model.to_string(),
638 context_window: crate::models::context_window_for_model(resolved_model)
639 .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS),
640 // No documented output maximum for these routes: stay unknown so
641 // no compatibility clamp is applied downstream.
642 max_output: crate::models::max_output_tokens_for_model(resolved_model),
643 thinking_supported: crate::models::model_supports_reasoning(resolved_model),
644 cache_telemetry_supported: false,
645 request_payload_mode: RequestPayloadMode::ChatCompletions,
646 alias_deprecation: None,
647 };
648 }
649
650 if matches!(provider, ApiProvider::Arcee) {
651 return ProviderCapability {
652 provider,
653 resolved_model: resolved_model.to_string(),
654 context_window: crate::models::context_window_for_model(resolved_model)
655 .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS),
656 // No documented output maximum for these routes: stay unknown so
657 // no compatibility clamp is applied downstream.
658 max_output: crate::models::max_output_tokens_for_model(resolved_model),
659 thinking_supported: crate::models::model_supports_reasoning(resolved_model),
660 cache_telemetry_supported: false,
661 request_payload_mode: RequestPayloadMode::ChatCompletions,
662 alias_deprecation: None,
663 };
664 }
665
666 let model_lower = resolved_model.to_ascii_lowercase();
667 let alias_deprecation = if matches!(
668 provider,
669 ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic
670 ) {
671 deepseek_alias_deprecation(&model_lower)
672 } else {
673 None
674 };
675 let is_v4_pro = model_lower.contains("v4-pro") || model_lower == "deepseek-v4pro";
676 let is_v4_flash = model_lower.contains("v4-flash")
677 || model_lower == "deepseek-v4flash"
678 || model_lower == "deepseek-v4"
679 || alias_deprecation.is_some();
680 let is_reasoner = matches!(provider, ApiProvider::WanjieArk)
681 && (model_lower.contains("reasoner") || model_lower.contains("r1"));
682
683 // Context window: V4-class models get 1M, everything else falls through
684 // to the model's own lookup or a default. Ollama defaults to 8192
685 // (conservative for small local models) instead of 128K.
686 let context_window = if is_v4_pro || is_v4_flash {
687 crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
688 } else if let Some(window) = crate::models::context_window_for_model(resolved_model) {
689 window
690 } else if matches!(provider, ApiProvider::Ollama) {
691 8192
692 } else {
693 crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS
694 };
695
696 // Max output tokens: official DeepSeek V4 API metadata lists 384K;
697 // runtime request caps remain separate and more conservative.
698 //
699 // Everything else answers from the static model catalogue, and answers
700 // `None` when the catalogue has no row. That is the truthful state for
701 // membership routes such as the `kimi-for-coding` family, whose ceilings
702 // are owned by the membership catalog. It must not become a placeholder
703 // number: a fabricated 4K here silently clamped offline membership routes
704 // to 4K output via `route_budget`.
705 let max_output = if is_v4_pro || is_v4_flash {
706 Some(384_000)
707 } else {
708 crate::models::max_output_tokens_for_model(resolved_model)
709 };
710
711 // Thinking support: V4 models support thinking on all providers, but
712 // only when the model name matches the V4 family.
713 let thinking_supported = is_v4_pro
714 || is_v4_flash
715 || is_reasoner
716 || crate::models::model_supports_reasoning(resolved_model);
717
718 // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.
719 let cache_telemetry_supported = matches!(
720 provider,
721 ApiProvider::Deepseek
722 | ApiProvider::DeepseekCN
723 | ApiProvider::NvidiaNim
724 | ApiProvider::Volcengine
725 );
726
727 let request_payload_mode = if matches!(
728 provider,
729 ApiProvider::DeepseekAnthropic | ApiProvider::MinimaxAnthropic | ApiProvider::Openmodel
730 ) {
731 RequestPayloadMode::AnthropicMessages
732 } else {
733 RequestPayloadMode::ChatCompletions
734 };
735
736 ProviderCapability {
737 provider,
738 resolved_model: resolved_model.to_string(),
739 context_window,
740 max_output,
741 thinking_supported,
742 cache_telemetry_supported,
743 request_payload_mode,
744 alias_deprecation,
745 }
746 }
747
748 fn deepseek_alias_deprecation(model_lower: &str) -> Option<ModelAliasDeprecation> {
749 match model_lower {
750 "deepseek-chat" | "deepseek-reasoner" => Some(ModelAliasDeprecation {
751 alias: model_lower.to_string(),
752 replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),
753 retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),
754 retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),
755 notice: format!(
756 "{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}."
757 ),
758 }),
759 _ => None,
760 }
761 }
762
763 /// Canonicalize compact DeepSeek model aliases to stable IDs.
764 ///
765 /// Already-valid model IDs pass through unchanged. Only the compact
766 /// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.
767 #[must_use]
768 pub fn canonical_model_name(model: &str) -> Option<&'static str> {
769 match model.trim().to_ascii_lowercase().as_str() {
770 "pro" | "deepseek-v4pro" => Some("deepseek-v4-pro"),
771 "flash" | "deepseek-v4flash" => Some("deepseek-v4-flash"),
772 _ => None,
773 }
774 }
775
776 /// Normalize a configured/runtime model name.
777 ///
778 /// Trims whitespace, preserves caller-provided case for already-valid model
779 /// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.
780 /// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`
781 /// endpoint is the authority on valid model IDs.
782 #[must_use]
783 pub fn normalize_model_name(model: &str) -> Option<String> {
784 let trimmed = model.trim();
785 if trimmed.is_empty() {
786 return None;
787 }
788 if let Some(canonical) = canonical_model_name(trimmed) {
789 return Some(canonical.to_string());
790 }
791
792 let normalized = trimmed.to_ascii_lowercase();
793 if !normalized.starts_with("deepseek") && !normalized.contains("/deepseek") {
794 return None;
795 }
796
797 if trimmed
798 .chars()
799 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))
800 {
801 return Some(trimmed.to_string());
802 }
803
804 None
805 }
806
807 #[must_use]
808 pub(crate) fn normalize_custom_model_id(model: &str) -> Option<String> {
809 let trimmed = model.trim();
810 if trimmed.is_empty() || trimmed.chars().any(char::is_control) {
811 None
812 } else {
813 Some(trimmed.to_string())
814 }
815 }
816
817 /// Validate a user-requested model id against the active provider (#3018).
818 ///
819 /// DeepSeek providers use the strict `normalize_model_name` gate (the official
820 /// API only accepts DeepSeek IDs). OpenCode Go uses its documented Chat
821 /// Completions allowlist because the shared Go roster also contains
822 /// Messages-only models. Other providers pass any non-empty,
823 /// non-control-character string through — the provider API is the authority.
824 #[must_use]
825 pub fn requested_model_for_provider(provider: ApiProvider, model: &str) -> Option<String> {
826 match provider {
827 ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic => {
828 normalize_model_name(model)
829 }
830 ApiProvider::OpencodeGo => opencode_go_chat_model_id(model).map(str::to_string),
831 _ => normalize_custom_model_id(model),
832 }
833 }
834
835 /// Reject a provider/model tuple that we can be confident is invalid *before*
836 /// it reaches the network (#3227).
837 ///
838 /// The route-isolation bug paired a model picked under one provider with a
839 /// different provider's route (model chip `deepseek-v4-pro`, provider badge
840 /// `Z.ai`), producing a `400 Unknown Model` from the upstream. This guard
841 /// catches that locally and names the incompatible pair instead.
842 ///
843 /// We only reject tuples that are *known* to be wrong so legitimate custom
844 /// routing (self-hosted endpoints, OpenAI-compatible aggregators that proxy
845 /// DeepSeek weights, etc.) keeps working:
846 ///
847 /// 1. A DeepSeek-native provider (`deepseek` / `deepseek-cn`) accepts only
848 /// DeepSeek model IDs or `auto` — same gate as [`normalize_model_name`].
849 /// 2. A non-DeepSeek *native* provider (e.g. Z.ai, which serves GLM) must not
850 /// be handed a DeepSeek-only model ID. This reuses the same
851 /// "foreign to a direct provider" classification the model resolver uses,
852 /// so DeepSeek aggregators (NVIDIA NIM, OpenRouter, Fireworks, …) stay
853 /// permissive.
854 /// 3. OpenCode Go accepts only models documented for its Chat Completions
855 /// endpoint; models served only over Anthropic Messages are rejected.
856 ///
857 /// Returns `Ok(())` for any tuple we cannot confidently reject (the provider
858 /// API remains the final authority for those).
859 pub fn validate_route(provider: ApiProvider, model: &str) -> Result<(), String> {
860 let trimmed = model.trim();
861 if trimmed.is_empty() {
862 return Err(format!(
863 "No model selected for provider '{}'.",
864 provider.as_str()
865 ));
866 }
867 if trimmed.eq_ignore_ascii_case("auto") {
868 return Ok(());
869 }
870
871 if provider == ApiProvider::OpencodeGo {
872 return if opencode_go_chat_model_id(trimmed).is_some() {
873 Ok(())
874 } else {
875 Err(format!(
876 "Model '{trimmed}' is not available through OpenCode Go Chat Completions. \
877 Choose one of: {}.",
878 OPENCODE_GO_CHAT_MODELS.join(", ")
879 ))
880 };
881 }
882
883 // Providers whose model id is passed through verbatim (OpenAI-compatible,
884 // Ollama tags, custom base URLs, …) are validated by the upstream service.
885 if provider_passes_model_through(provider) {
886 return Ok(());
887 }
888
889 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
890 if normalize_model_name(trimmed).is_some() {
891 return Ok(());
892 }
893 return Err(format!(
894 "Model '{trimmed}' is not a DeepSeek model, but the active provider is '{}'. \
895 Use a DeepSeek model id (for example {}) or switch providers together with the model.",
896 provider.as_str(),
897 COMMON_DEEPSEEK_MODELS.join(", ")
898 ));
899 }
900
901 // A non-DeepSeek native provider was handed a DeepSeek-only model id: this
902 // is the exact contamination from #3227 (Z.ai + deepseek-v4-pro).
903 if root_deepseek_model_is_foreign_to_direct_provider(provider, trimmed) {
904 return Err(format!(
905 "Model '{trimmed}' is a DeepSeek model and is not compatible with provider '{}'. \
906 Switch the provider and model together, or pick a model this provider serves.",
907 provider.as_str()
908 ));
909 }
910
911 Ok(())
912 }
913
914 fn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {
915 match model.trim().to_ascii_lowercase().as_str() {
916 "deepseek-v4-pro"
917 | "deepseek-v4pro"
918 | "deepseek-ai/deepseek-v4-pro"
919 | "deepseek-ai/deepseek-v4pro"
920 | "deepseek/deepseek-v4-pro"
921 | "deepseek/deepseek-v4pro" => Some("deepseek-v4-pro"),
922 "deepseek-v4-flash"
923 | "deepseek-v4flash"
924 | "deepseek-ai/deepseek-v4-flash"
925 | "deepseek-ai/deepseek-v4flash"
926 | "deepseek/deepseek-v4-flash"
927 | "deepseek/deepseek-v4flash" => Some("deepseek-v4-flash"),
928 _ => None,
929 }
930 }
931
932 /// Resolve model names accepted by DeepSeek's first-party endpoints.
933 ///
934 /// The legacy aliases are intentionally handled only in this direct-provider
935 /// layer. Aggregators and custom endpoints own their model namespaces; for
936 /// example, Wanjie Ark still documents `deepseek-reasoner` as its native id.
937 fn canonical_direct_deepseek_model_id(model: &str) -> Option<&'static str> {
938 match model.trim().to_ascii_lowercase().as_str() {
939 "deepseek-chat" | "deepseek-reasoner" => Some(DEEPSEEK_ALIAS_REPLACEMENT),
940 _ => canonical_official_deepseek_model_id(model),
941 }
942 }
943
944 fn legacy_deepseek_alias_reasoning_effort(model: &str) -> Option<&'static str> {
945 match model.trim().to_ascii_lowercase().as_str() {
946 // DeepSeek documents these retired aliases as the non-thinking and
947 // thinking modes of V4 Flash, respectively. Keep that intent only
948 // when the user has not already chosen an explicit reasoning tier.
949 "deepseek-chat" => Some("off"),
950 "deepseek-reasoner" => Some("high"),
951 _ => None,
952 }
953 }
954
955 fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
956 let normalized = model.trim().to_ascii_lowercase();
957 let normalized = normalized.replace(['_', ' '], "-");
958 match normalized.as_str() {
959 OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
960 | "trinity"
961 | "trinity-large-thinking"
962 | "arcee-trinity"
963 | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
964 OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
965 Some(OPENROUTER_GEMMA_4_31B_MODEL)
966 }
967 OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
968 Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
969 }
970 OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
971 Some(OPENROUTER_GLM_5_1_MODEL)
972 }
973 OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
974 Some(OPENROUTER_GLM_5_2_MODEL)
975 }
976 OPENROUTER_GLM_5_3_MODEL | "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => {
977 Some(OPENROUTER_GLM_5_3_MODEL)
978 }
979 OPENROUTER_GLM_5_TURBO_MODEL | "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => {
980 Some(OPENROUTER_GLM_5_TURBO_MODEL)
981 }
982 OPENROUTER_KIMI_K2_7_CODE_MODEL
983 | "kimi"
984 | "kimi-k2"
985 | "kimi-k2.7"
986 | "kimi-k2-7"
987 | "kimi-k2.7-code"
988 | "kimi-k2-7-code"
989 | "kimi-code"
990 | "moonshot-kimi-k2.7-code"
991 | "openrouter-kimi-k2.7-code" => Some(OPENROUTER_KIMI_K2_7_CODE_MODEL),
992 OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
993 Some(OPENROUTER_KIMI_K2_6_MODEL)
994 }
995 OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => {
996 Some(OPENROUTER_MINIMAX_M3_MODEL)
997 }
998 OPENROUTER_MINIMAX_M2_7_MODEL
999 | "minimax-2.7"
1000 | "minimax-2-7"
1001 | "minimax-m2.7"
1002 | "minimax-m2-7"
1003 | "minimax-m-2.7"
1004 | "minimax-m-2-7" => Some(OPENROUTER_MINIMAX_M2_7_MODEL),
1005 OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
1006 | "nemotron-3-nano-omni"
1007 | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
1008 OPENROUTER_NEMOTRON_3_ULTRA_MODEL
1009 | "nvidia/nemotron-3-ultra"
1010 | "nemotron-3-ultra"
1011 | "nemotron-3-ultra-550b-a55b"
1012 | "nvidia-nemotron-3-ultra"
1013 | "nvidia-nemotron-3-ultra-550b-a55b" => Some(OPENROUTER_NEMOTRON_3_ULTRA_MODEL),
1014 OPENROUTER_QWEN_3_6_35B_A3B_MODEL
1015 | "qwen3.6-35b-a3b"
1016 | "qwen-3.6-35b-a3b"
1017 | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
1018 OPENROUTER_QWEN_3_6_FLASH_MODEL | "qwen3.6-flash" | "qwen-3.6-flash" => {
1019 Some(OPENROUTER_QWEN_3_6_FLASH_MODEL)
1020 }
1021 OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL
1022 | "qwen3.6-max-preview"
1023 | "qwen-3.6-max-preview"
1024 | "qwen-max-preview" => Some(OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
1025 OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
1026 Some(OPENROUTER_QWEN_3_6_27B_MODEL)
1027 }
1028 OPENROUTER_QWEN_3_6_PLUS_MODEL | "qwen3.6-plus" | "qwen-3.6-plus" => {
1029 Some(OPENROUTER_QWEN_3_6_PLUS_MODEL)
1030 }
1031 OPENROUTER_QWEN_3_7_PLUS_MODEL | "qwen3.7-plus" | "qwen-3.7-plus" => {
1032 Some(OPENROUTER_QWEN_3_7_PLUS_MODEL)
1033 }
1034 OPENROUTER_QWEN_3_7_MAX_MODEL | "qwen3.7-max" | "qwen-3.7-max" => {
1035 Some(OPENROUTER_QWEN_3_7_MAX_MODEL)
1036 }
1037 OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
1038 Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
1039 }
1040 OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
1041 | "mimo-v2.5-pro"
1042 | "mimo-v2-5-pro"
1043 | "xiaomi-mimo-v2.5-pro"
1044 | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
1045 OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
1046 | "mimo-v2.5"
1047 | "mimo-v2-5"
1048 | "xiaomi-mimo-v2.5"
1049 | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
1050 _ => None,
1051 }
1052 }
1053
1054 pub(crate) fn opencode_go_chat_model_id(model: &str) -> Option<&'static str> {
1055 codewhale_config::opencode_go_chat_model_id(model)
1056 }
1057
1058 fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> {
1059 let normalized = model.trim().to_ascii_lowercase();
1060 let normalized = normalized.replace(['_', ' '], "-");
1061 match normalized.as_str() {
1062 "mimo"
1063 | DEFAULT_XIAOMI_MIMO_MODEL
1064 | "mimo-v2-5-pro"
1065 | "xiaomi-mimo-v2.5-pro"
1066 | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL),
1067 XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL
1068 | "mimo-v2-5-pro-ultraspeed"
1069 | "xiaomi-mimo-v2.5-pro-ultraspeed"
1070 | "xiaomi-mimo-v2-5-pro-ultraspeed"
1071 | "ultraspeed"
1072 | "pro-ultraspeed" => Some(XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL),
1073 "omni"
1074 | "mimo-omni"
1075 | "v2.5-omni"
1076 | "v25-omni"
1077 | "mimo-v2.5"
1078 | "mimo-v25"
1079 | "mimo-v2-5"
1080 | "mimo-v2.5-omni"
1081 | "mimo-v25-omni"
1082 | "mimo-v2-5-omni"
1083 | "xiaomi-mimo-v2.5"
1084 | "xiaomi-mimo-v2-5"
1085 | "xiaomi-mimo-v2.5-omni"
1086 | "xiaomi-mimo-v2-5-omni" => Some(XIAOMI_MIMO_V2_5_OMNI_MODEL),
1087 "asr" | "mimo-asr" | "mimo-v2.5-asr" | "speech-to-text" | "transcribe" => {
1088 Some(XIAOMI_MIMO_ASR_MODEL)
1089 }
1090 "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => {
1091 Some(XIAOMI_MIMO_TTS_MODEL)
1092 }
1093 "mimo-tts-voicedesign"
1094 | "mimo-voice-design"
1095 | "mimo-v25-tts-voicedesign"
1096 | "mimo-v2.5-tts-voicedesign"
1097 | "voicedesign"
1098 | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL),
1099 "mimo-tts-voiceclone"
1100 | "mimo-voice-clone"
1101 | "mimo-v25-tts-voiceclone"
1102 | "mimo-v2.5-tts-voiceclone"
1103 | "voiceclone"
1104 | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL),
1105 "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL),
1106 _ => None,
1107 }
1108 }
1109
1110 fn canonical_arcee_model_id(model: &str) -> Option<&'static str> {
1111 let normalized = model.trim().to_ascii_lowercase();
1112 let normalized = normalized.replace(['_', ' '], "-");
1113 match normalized.as_str() {
1114 "trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking" => {
1115 Some(DEFAULT_ARCEE_MODEL)
1116 }
1117 "arcee-trinity-mini" | ARCEE_TRINITY_MINI_MODEL => Some(ARCEE_TRINITY_MINI_MODEL),
1118 "arcee-trinity-large-preview" | ARCEE_TRINITY_LARGE_PREVIEW_MODEL => {
1119 Some(ARCEE_TRINITY_LARGE_PREVIEW_MODEL)
1120 }
1121 _ => None,
1122 }
1123 }
1124
1125 fn canonical_moonshot_model_id(model: &str) -> Option<&'static str> {
1126 let normalized = model.trim().to_ascii_lowercase();
1127 let normalized = normalized.replace(['_', ' '], "-");
1128 match normalized.as_str() {
1129 "kimi"
1130 | "kimi-k2"
1131 | "kimi-k2.7"
1132 | "kimi-k2-7"
1133 | "kimi-k2.7-code"
1134 | "kimi-k2-7-code"
1135 | "kimi-code"
1136 | "moonshot-kimi-k2.7-code" => Some(DEFAULT_MOONSHOT_MODEL),
1137 "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => Some(MOONSHOT_KIMI_K2_6_MODEL),
1138 _ => None,
1139 }
1140 }
1141
1142 fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
1143 let normalized = model.trim().to_ascii_lowercase();
1144 let normalized = normalized.replace(['_', ' '], "-");
1145 match normalized.as_str() {
1146 "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
1147 "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(DEFAULT_ZAI_MODEL),
1148 // Resolves to its own constant, never to `DEFAULT_ZAI_MODEL`: adding a
1149 // model must not silently re-point an explicit 5.3 request at the
1150 // default (GLM-5.2).
1151 "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => Some(ZAI_GLM_5_3_MODEL),
1152 "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
1153 _ => None,
1154 }
1155 }
1156
1157 fn canonical_minimax_model_id(model: &str) -> Option<&'static str> {
1158 let normalized = model.trim().to_ascii_lowercase();
1159 let normalized = normalized.replace(['_', ' '], "-");
1160 match normalized.as_str() {
1161 "minimax" | "minimax-m3" | "minimax-m-3" | "minimax-m-3-thinking" => {
1162 Some(DEFAULT_MINIMAX_MODEL)
1163 }
1164 "minimax-m2.7" | "minimax-m2-7" | "minimax-m-2.7" | "minimax-m-2-7" => {
1165 Some(MINIMAX_M2_7_MODEL)
1166 }
1167 "minimax-m2.7-highspeed"
1168 | "minimax-m2-7-highspeed"
1169 | "minimax-m-2.7-highspeed"
1170 | "minimax-m-2-7-highspeed" => Some(MINIMAX_M2_7_HIGHSPEED_MODEL),
1171 "minimax-m2.5" | "minimax-m2-5" | "minimax-m-2.5" | "minimax-m-2-5" => {
1172 Some(MINIMAX_M2_5_MODEL)
1173 }
1174 "minimax-m2.5-highspeed"
1175 | "minimax-m2-5-highspeed"
1176 | "minimax-m-2.5-highspeed"
1177 | "minimax-m-2-5-highspeed" => Some(MINIMAX_M2_5_HIGHSPEED_MODEL),
1178 "minimax-m2.1" | "minimax-m2-1" | "minimax-m-2.1" | "minimax-m-2-1" => {
1179 Some(MINIMAX_M2_1_MODEL)
1180 }
1181 "minimax-m2.1-highspeed"
1182 | "minimax-m2-1-highspeed"
1183 | "minimax-m-2.1-highspeed"
1184 | "minimax-m-2-1-highspeed" => Some(MINIMAX_M2_1_HIGHSPEED_MODEL),
1185 "minimax-m2" | "minimax-m-2" => Some(MINIMAX_M2_MODEL),
1186 _ => None,
1187 }
1188 }
1189
1190 /// Resolve a user-entered model id to the canonical family id a provider
1191 /// understands, without any wire-id translation.
1192 ///
1193 /// Most provider-owned families (GLM via Z.ai/Zhipu, Kimi, Xiaomi MiMo,
1194 /// MiniMax, Arcee, OpenRouter slugs, …) resolve through the same "apply the
1195 /// family's canonical map, else pass the input through" path. OpenCode Go is
1196 /// deliberately stricter because one provider roster spans two incompatible
1197 /// wire protocols; only its Chat Completions rows may resolve here.
1198 ///
1199 /// This is the canonicalization half of what [`normalize_model_name_for_provider`]
1200 /// used to fuse together. Wire-id translation (e.g. `deepseek-v4-pro` → an
1201 /// aggregator's `accounts/…/deepseek-v4-pro` slug) belongs to the route
1202 /// resolver at request time, not to a name typed into `/provider`, so it is
1203 /// deliberately kept out of here.
1204 ///
1205 /// Returns `None` for empty or control-character input and for ids outside the
1206 /// OpenCode Go Chat Completions allowlist. Other provider ids pass through so a
1207 /// custom/self-hosted endpoint is never wrongly rejected.
1208 #[must_use]
1209 pub fn canonical_model_id_for_provider(provider: ApiProvider, model: &str) -> Option<String> {
1210 let trimmed = model.trim();
1211 if trimmed.is_empty() || trimmed.chars().any(char::is_control) {
1212 return None;
1213 }
1214
1215 // OpenCode Go is a strict protocol slice: its live `/models` response also
1216 // advertises Anthropic-Messages-only models, but this provider sends OpenAI
1217 // Chat Completions. Unknown and Messages-only ids must stop here rather
1218 // than falling through to the generic pass-through path below.
1219 if provider == ApiProvider::OpencodeGo {
1220 return opencode_go_chat_model_id(trimmed).map(str::to_string);
1221 }
1222
1223 // Provider-owned model families resolve through their own canonical map,
1224 // which defines the authoritative casing (`glm-5.1` → `GLM-5.1`,
1225 // `minimax-m2.7` → `MiniMax-M2.7`). Each map recognizes only *its own*
1226 // aliases, so an unknown id falls through to passthrough — no family acts
1227 // as a gate against any other.
1228 let family_canonical: Option<&'static str> = match provider {
1229 ApiProvider::Openrouter => canonical_openrouter_recent_model_id(trimmed),
1230 ApiProvider::XiaomiMimo => canonical_xiaomi_mimo_model_id(trimmed),
1231 ApiProvider::Arcee => canonical_arcee_model_id(trimmed),
1232 ApiProvider::Moonshot => canonical_moonshot_model_id(trimmed),
1233 ApiProvider::Zai => canonical_zai_model_id(trimmed),
1234 ApiProvider::Minimax | ApiProvider::MinimaxAnthropic => canonical_minimax_model_id(trimmed),
1235 _ => None,
1236 };
1237 if let Some(canonical) = family_canonical {
1238 return Some(canonical.to_string());
1239 }
1240
1241 // The official DeepSeek API is the one legitimate per-family gate: it serves
1242 // only its own ids (and 400s anything else), so reject an id it does not
1243 // recognize. Compact aliases are rewritten (deepseek-v4pro → deepseek-v4-pro)
1244 // and the caller's casing is kept for an already-valid id (`DeepSeek-V4-Flash`
1245 // stays as-is). Custom/self-hosted DeepSeek endpoints take the
1246 // accepts-custom-model-ids path, so they never reach this gate.
1247 if matches!(
1248 provider,
1249 ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic
1250 ) {
1251 let normalized = normalize_model_name(trimmed)?;
1252 if let Some(canonical) = canonical_direct_deepseek_model_id(&normalized) {
1253 if canonical.eq_ignore_ascii_case(&normalized)
1254 || normalized.to_ascii_lowercase() == canonical
1255 {
1256 return Some(normalized);
1257 }
1258 return Some(canonical.to_string());
1259 }
1260 return Some(normalized);
1261 }
1262
1263 // Aggregators that host DeepSeek (NIM, Novita, Fireworks, SiliconFlow, SGLang,
1264 // vLLM, DeepInfra, Wanjie Ark, Volcengine) canonicalize recognized DeepSeek
1265 // ids but pass everything else through — they serve more than DeepSeek, so
1266 // the upstream API stays the authority. A name is never rejected here.
1267 if matches!(
1268 provider,
1269 ApiProvider::NvidiaNim
1270 | ApiProvider::Novita
1271 | ApiProvider::Fireworks
1272 | ApiProvider::Siliconflow
1273 | ApiProvider::SiliconflowCn
1274 | ApiProvider::Sglang
1275 | ApiProvider::Vllm
1276 | ApiProvider::Deepinfra
1277 | ApiProvider::WanjieArk
1278 | ApiProvider::Volcengine
1279 ) && let Some(canonical) = canonical_official_deepseek_model_id(
1280 &normalize_model_name(trimmed).unwrap_or_else(|| trimmed.to_string()),
1281 ) {
1282 return Some(canonical.to_string());
1283 }
1284
1285 // Everything else (HuggingFace, OpenAI-compatible, Qianfan, StepFun, Codex,
1286 // Anthropic) owns no canonical map — the id the user typed is authoritative.
1287 Some(trimmed.to_string())
1288 }
1289
1290 /// Normalize a model selected through the TUI for the active provider, applying
1291 /// the provider's wire-slug translation on top of the canonical family id.
1292 ///
1293 /// This is the wire-id half of the split (canonicalization lives in
1294 /// [`canonical_model_id_for_provider`]). Used by config-file normalization,
1295 /// where vendor-prefixed ids (e.g. `deepseek-ai/DeepSeek-V4-Pro` on SiliconFlow)
1296 /// are the stored form. `/provider` deliberately uses the canonical half instead.
1297 #[must_use]
1298 pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option<String> {
1299 let canonical = canonical_model_id_for_provider(provider, model)?;
1300 // Translate the canonical family id to the provider's wire slug when the
1301 // provider's API uses vendor-prefixed ids (Together, Siliconflow, NIM, …).
1302 // `model_for_provider` is a no-op for providers without a wire-slug map, so
1303 // this is one uniform layer over the equal-treatment canonical resolver.
1304 Some(model_for_provider(provider, canonical))
1305 }
1306
1307 #[must_use]
1308 pub fn wire_model_for_provider(provider: ApiProvider, model: &str) -> String {
1309 let trimmed = model.trim();
1310 if trimmed.is_empty() {
1311 return trimmed.to_string();
1312 }
1313 if provider == ApiProvider::OpencodeGo {
1314 // Canonicalize known Chat Completions ids only. Never substitute a
1315 // different model for an unknown/Messages-only id — that silently
1316 // changes the request. Keep the caller's spelling so validate_route /
1317 // the route resolver can reject it by name.
1318 return opencode_go_chat_model_id(trimmed)
1319 .map(str::to_string)
1320 .unwrap_or_else(|| trimmed.to_string());
1321 }
1322 if matches!(provider, ApiProvider::XiaomiMimo) {
1323 return normalize_model_name_for_provider(provider, trimmed)
1324 .unwrap_or_else(|| trimmed.to_string());
1325 }
1326 if provider_passes_model_through(provider) {
1327 return trimmed.to_string();
1328 }
1329 normalize_model_name_for_provider(provider, trimmed).unwrap_or_else(|| trimmed.to_string())
1330 }
1331
1332 /// Resolve the final request model while respecting custom endpoint
1333 /// namespaces. Provider-only normalization cannot distinguish DeepSeek's
1334 /// first-party API from a self-hosted OpenAI-compatible endpoint configured
1335 /// under the legacy `deepseek` provider name, so actual HTTP clients use this
1336 /// route-aware boundary.
1337 #[must_use]
1338 pub fn wire_model_for_provider_route(provider: ApiProvider, base_url: &str, model: &str) -> String {
1339 let trimmed = model.trim();
1340 if trimmed.is_empty() {
1341 return trimmed.to_string();
1342 }
1343 // OpenCode Go's provider identity is the Chat Completions protocol
1344 // boundary even when its base URL is overridden. Do not let the generic
1345 // custom-endpoint passthrough re-admit a Messages-only model.
1346 if provider == ApiProvider::OpencodeGo {
1347 return wire_model_for_provider(provider, trimmed);
1348 }
1349 if base_url_is_custom_for_provider(provider, base_url) {
1350 return trimmed.to_string();
1351 }
1352 wire_model_for_provider(provider, trimmed)
1353 }
1354
1355 /// Reconcile a remembered `/model` pick with the model the config file names.
1356 ///
1357 /// `provider_models` in `settings.toml` remembers the last `/model` (or model
1358 /// picker) selection and outranks `config.toml` on the next launch. The picker
1359 /// offers catalog spellings, which are lowercase, so a user whose config names
1360 /// `DeepSeek-V4-Flash` can end up relaunching into `deepseek-v4-flash` — the
1361 /// wrong id for a self-hosted OpenAI-compatible gateway whose model names are
1362 /// case-sensitive, and the wrong id in the header.
1363 ///
1364 /// When the two strings name the *same* model in a different ASCII case, the
1365 /// config file owns the spelling. A remembered pick that names a genuinely
1366 /// different model still wins, so `/model` persistence is unchanged: only the
1367 /// spelling defers, never the selection.
1368 #[must_use]
1369 pub(crate) fn prefer_configured_model_spelling(configured: &str, remembered: String) -> String {
1370 let configured = configured.trim();
1371 if remembered != configured && remembered.eq_ignore_ascii_case(configured) {
1372 return configured.to_string();
1373 }
1374 remembered
1375 }
1376
1377 /// Recover the behavioral intent of a retiring alias only when the selected
1378 /// route is a first-party DeepSeek endpoint. Custom endpoints own both the id
1379 /// and its semantics, so they deliberately return `None` here.
1380 pub(crate) fn legacy_deepseek_alias_effort_for_route(
1381 provider: ApiProvider,
1382 base_url: &str,
1383 model: &str,
1384 ) -> Option<&'static str> {
1385 if !matches!(
1386 provider,
1387 ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic
1388 ) {
1389 return None;
1390 }
1391 let effort = legacy_deepseek_alias_reasoning_effort(model)?;
1392 (wire_model_for_provider_route(provider, base_url, model) != model.trim()).then_some(effort)
1393 }
1394
1395 /// Hardcoded per-provider model id list used **only as a compatibility
1396 /// fallback** (#4188).
1397 ///
1398 /// Preferred sources are the live Models.dev catalog and the offline bundled
1399 /// snapshot via [`crate::provider_lake`]. Call this directly only for
1400 /// Codewhale-only / local providers Models.dev does not represent, or when
1401 /// probing the fallback table in tests. Picker, inventory, and subagent
1402 /// surfaces must go through the provider lake.
1403 #[must_use]
1404 pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> {
1405 match provider {
1406 ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic => {
1407 OFFICIAL_DEEPSEEK_MODELS.to_vec()
1408 }
1409 ApiProvider::NvidiaNim => vec![DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_NVIDIA_NIM_FLASH_MODEL],
1410 ApiProvider::Openrouter => {
1411 let mut models = vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL];
1412 models.extend_from_slice(RECENT_OPENROUTER_LARGE_MODELS);
1413 models
1414 }
1415 ApiProvider::XiaomiMimo => vec![
1416 DEFAULT_XIAOMI_MIMO_MODEL,
1417 XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL,
1418 XIAOMI_MIMO_V2_5_OMNI_MODEL,
1419 ],
1420 ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL],
1421 ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL],
1422 ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => {
1423 vec![DEFAULT_SILICONFLOW_MODEL, DEFAULT_SILICONFLOW_FLASH_MODEL]
1424 }
1425 ApiProvider::Arcee => vec![DEFAULT_ARCEE_MODEL, ARCEE_TRINITY_LARGE_PREVIEW_MODEL],
1426 // Moonshot's direct platform API (the provider's default route) serves
1427 // `kimi-k3`; advertising only `kimi-k2.7-code` is half of why a
1428 // dogfood user reported "I can't find k3" on v0.9.1.
1429 //
1430 // The bare `k3` id and `kimi-for-coding` deliberately stay out: they
1431 // belong to the Kimi Code coding-plan endpoint
1432 // (api.kimi.com/coding/v1), which `validate_kimi_code_api_model_id`
1433 // enforces. A completion list is a per-provider fallback with no
1434 // base-URL context, so offering an id this route would reject would
1435 // just move the surprise later. Kimi Code routes surface their own
1436 // ids through the configured model and the route-aware picker rows.
1437 ApiProvider::Moonshot => vec![
1438 DEFAULT_MOONSHOT_MODEL,
1439 MOONSHOT_KIMI_K3_MODEL,
1440 MOONSHOT_KIMI_K2_6_MODEL,
1441 ],
1442 ApiProvider::Huggingface => {
1443 vec![DEFAULT_HUGGINGFACE_MODEL, DEFAULT_HUGGINGFACE_FLASH_MODEL]
1444 }
1445 ApiProvider::Deepinfra => vec![DEFAULT_DEEPINFRA_MODEL, DEFAULT_DEEPINFRA_FLASH_MODEL],
1446 ApiProvider::WanjieArk => {
1447 vec![
1448 DEFAULT_WANJIE_ARK_MODEL,
1449 "deepseek-v4-pro",
1450 "deepseek-v4-flash",
1451 ]
1452 }
1453 ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL],
1454 ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL],
1455 ApiProvider::Volcengine => vec![DEFAULT_VOLCENGINE_MODEL, DEFAULT_VOLCENGINE_FLASH_MODEL],
1456 ApiProvider::Ollama => Vec::new(),
1457 ApiProvider::Openai | ApiProvider::Atlascloud => OFFICIAL_DEEPSEEK_MODELS.to_vec(),
1458 ApiProvider::Together => vec![DEFAULT_TOGETHER_MODEL, DEFAULT_TOGETHER_FLASH_MODEL],
1459 ApiProvider::Qianfan => vec![DEFAULT_QIANFAN_MODEL],
1460 ApiProvider::OpenaiCodex => vec![DEFAULT_OPENAI_CODEX_MODEL],
1461 ApiProvider::Openmodel => vec![DEFAULT_OPENMODEL_MODEL],
1462 ApiProvider::Zai => vec![
1463 DEFAULT_ZAI_MODEL,
1464 ZAI_GLM_5_3_MODEL,
1465 ZAI_GLM_5_1_MODEL,
1466 ZAI_GLM_5_TURBO_MODEL,
1467 ],
1468 ApiProvider::Stepfun => vec![DEFAULT_STEPFUN_MODEL],
1469 ApiProvider::Anthropic => vec![
1470 ANTHROPIC_OPUS_MODEL,
1471 DEFAULT_ANTHROPIC_MODEL,
1472 ANTHROPIC_HAIKU_MODEL,
1473 ],
1474 ApiProvider::Minimax | ApiProvider::MinimaxAnthropic => vec![
1475 DEFAULT_MINIMAX_MODEL,
1476 MINIMAX_M2_7_MODEL,
1477 MINIMAX_M2_7_HIGHSPEED_MODEL,
1478 MINIMAX_M2_5_MODEL,
1479 MINIMAX_M2_5_HIGHSPEED_MODEL,
1480 MINIMAX_M2_1_MODEL,
1481 MINIMAX_M2_1_HIGHSPEED_MODEL,
1482 MINIMAX_M2_MODEL,
1483 ],
1484 ApiProvider::Sakana => vec![DEFAULT_SAKANA_MODEL, SAKANA_FUGU_ULTRA_MODEL],
1485 ApiProvider::LongCat => vec![DEFAULT_LONGCAT_MODEL],
1486 ApiProvider::OpencodeGo => OPENCODE_GO_CHAT_MODELS.to_vec(),
1487 ApiProvider::OpencodeZen => vec![DEFAULT_OPENCODE_ZEN_MODEL],
1488 ApiProvider::Meta => vec![DEFAULT_META_MODEL],
1489 ApiProvider::Xai => vec![
1490 DEFAULT_XAI_MODEL,
1491 XAI_GROK_4_3_MODEL,
1492 XAI_GROK_BUILD_MODEL,
1493 XAI_GROK_COMPOSER_2_5_FAST_MODEL,
1494 XAI_GROK_4_20_0309_REASONING_MODEL,
1495 XAI_GROK_4_20_0309_NON_REASONING_MODEL,
1496 ],
1497 // Frozen pre-refresh gateway snapshot: these are the rows TelecomJS
1498 // TokenHub advertised when the provider landed (note the still-listed
1499 // `GLM-5.0`). It is only a conservative fallback — a configured key
1500 // replaces it wholesale with the authenticated live `/models` catalog
1501 // (docs/PROVIDERS.md, `telecomjs` row). Do not hand-add newer model
1502 // ids here; refresh the whole snapshot from the gateway instead.
1503 ApiProvider::Telecomjs => vec![
1504 DEFAULT_TELECOMJS_MODEL,
1505 "deepseek-v4-flash",
1506 "DeepSeek-R1",
1507 "qwen3.7-plus",
1508 "qwen3-max",
1509 "glm-5.2",
1510 "glm-5.1",
1511 "GLM-5.0",
1512 "Minimax-M2.5",
1513 "kimi-k2.7-code",
1514 "Doubao-Seed-2.0-Pro",
1515 ],
1516 ApiProvider::ModelstudioTokenPlan
1517 | ApiProvider::ModelstudioTokenPlanAnthropic
1518 | ApiProvider::ModelstudioCodingPlan
1519 | ApiProvider::ModelstudioCodingPlanAnthropic => vec![
1520 DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL,
1521 "qwen3.8-max-preview",
1522 "qwen3.7-plus",
1523 "qwen3.7-max",
1524 "qwen3.6-flash",
1525 "deepseek-v4-pro",
1526 "deepseek-v4-flash-0731",
1527 // No glm-5.3: Model Studio publishes no such row (2026-08-03).
1528 "glm-5.2",
1529 ],
1530 // Custom endpoints expose no built-in completion names; the user
1531 // supplies their own model id (#1519).
1532 ApiProvider::Custom => Vec::new(),
1533 }
1534 }
1535
1536 // === Types ===
1537
1538 /// Raw retry configuration loaded from config files.
1539 #[derive(Debug, Clone, Deserialize)]
1540 pub struct RetryConfig {
1541 pub enabled: Option<bool>,
1542 pub max_retries: Option<u32>,
1543 pub initial_delay: Option<f64>,
1544 pub max_delay: Option<f64>,
1545 pub exponential_base: Option<f64>,
1546 }
1547
1548 /// Deserialize `status_items` tolerantly: skip keys unknown to this build
1549 /// instead of erroring with "unknown variant". This lets a dev build write
1550 /// `"balance"` (or any future item) while the stable build still parses the
1551 /// config file successfully.
1552 fn deser_status_items<'de, D>(deserializer: D) -> Result<Option<Vec<StatusItem>>, D::Error>
1553 where
1554 D: serde::Deserializer<'de>,
1555 {
1556 let raw: Option<Vec<String>> = Option::deserialize(deserializer)?;
1557 Ok(raw.map(|strings| {
1558 strings
1559 .into_iter()
1560 .filter_map(|s| {
1561 StatusItem::from_key(&s).or_else(|| {
1562 tracing::warn!("ignoring unknown status item {s:?} in config");
1563 None
1564 })
1565 })
1566 .collect()
1567 }))
1568 }
1569
1570 /// Deserialize `header_items` tolerantly: skip keys unknown to this build
1571 /// instead of failing with an "unknown variant" error.
1572 ///
1573 /// This keeps configuration files forward-compatible. For example, a newer
1574 /// CodeWhale build may write a header item that an older build does not yet
1575 /// understand; the older build will ignore that item while preserving the
1576 /// remaining supported entries.
1577 fn deser_header_items<'de, D>(deserializer: D) -> Result<Option<Vec<HeaderItem>>, D::Error>
1578 where
1579 D: serde::Deserializer<'de>,
1580 {
1581 let raw: Option<Vec<String>> = Option::deserialize(deserializer)?;
1582 Ok(raw.map(|strings| {
1583 strings
1584 .into_iter()
1585 .filter_map(|s| {
1586 HeaderItem::from_key(&s).or_else(|| {
1587 tracing::warn!("ignoring unknown header item {s:?} in config");
1588 None
1589 })
1590 })
1591 .collect()
1592 }))
1593 }
1594
1595 /// UI configuration loaded from config files.
1596 #[derive(Debug, Clone, Deserialize, Default)]
1597 pub struct TuiConfig {
1598 pub alternate_screen: Option<String>,
1599 pub mouse_capture: Option<bool>,
1600 /// Timeout for startup terminal mode/probe calls in milliseconds.
1601 /// Defaults to 500ms when omitted.
1602 pub terminal_probe_timeout_ms: Option<u64>,
1603 /// Per-SSE-chunk idle timeout in seconds. Defaults to 900 seconds when
1604 /// omitted. `0` maps to the default; values clamp to `1..=3600`.
1605 pub stream_chunk_timeout_secs: Option<u64>,
1606 /// Ordered list of footer items the user wants visible. `None` (the field
1607 /// missing from `config.toml`) means "use the built-in default order"; an
1608 /// empty `Some(vec![])` means "show nothing in the footer".
1609 ///
1610 /// Edited interactively via `/statusline`; persisted to `tui.status_items`
1611 /// in `~/.deepseek/config.toml`.
1612 #[serde(default, deserialize_with = "deser_status_items")]
1613 pub status_items: Option<Vec<StatusItem>>,
1614 /// Ordered list of optional header items the user wants visible.
1615 ///
1616 /// `None` (the field missing from `config.toml`) preserves the built-in
1617 /// header unchanged. An empty `Some(vec![])` likewise enables no additional
1618 /// header items, while configured entries enable their corresponding
1619 /// optional header content.
1620 ///
1621 /// The existing context-utilisation display remains part of the built-in
1622 /// header and is not controlled by this list.
1623 ///
1624 /// Unknown items are ignored during deserialization so configurations written
1625 /// by newer CodeWhale versions remain loadable by older versions.
1626 ///
1627 /// Persisted to `tui.header_items` in `~/.deepseek/config.toml`.
1628 #[serde(default, deserialize_with = "deser_header_items")]
1629 pub header_items: Option<Vec<HeaderItem>>,
1630 /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so
1631 /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,
1632 /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them clickable
1633 /// with the terminal's link gesture (usually Cmd-click on macOS and
1634 /// Ctrl-click on Linux/Windows). Terminals without OSC 8 support render the
1635 /// plain label and ignore the escape. Defaults to on for macOS/Linux and
1636 /// off for Windows legacy consoles; set `false` to suppress everywhere
1637 /// (e.g. for a terminal that misrenders the sequence). OSC 8 escapes are
1638 /// emitted out-of-band, so buffer-column corruption is not a concern.
1639 pub osc8_links: Option<bool>,
1640 /// High-level notification trigger condition. When set, overrides the
1641 /// `[notifications].threshold_secs` gate from the lower-level
1642 /// `[notifications]` block:
1643 ///
1644 /// - `Always` — fire a turn-completion notification on every successful
1645 /// turn regardless of duration. The configured `[notifications].method`
1646 /// and `include_summary` flag are still respected.
1647 /// - `Never` — suppress all turn-completion notifications.
1648 /// - Unset (default) — fall back to the `[notifications]` defaults.
1649 pub notification_condition: Option<NotificationCondition>,
1650 /// When `true`, plain Up/Down on an empty composer scroll the
1651 /// transcript instead of recalling input history. Useful for
1652 /// terminals that map mouse-wheel gestures to arrow keys. Default:
1653 /// `true` only when mouse capture is off; otherwise `false`.
1654 #[serde(default)]
1655 pub composer_arrows_scroll: Option<bool>,
1656 }
1657
1658 /// High-level notification trigger override. See
1659 /// [`TuiConfig::notification_condition`].
1660 #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
1661 #[serde(rename_all = "snake_case")]
1662 pub enum NotificationCondition {
1663 /// Notify on every successful turn (no duration threshold).
1664 Always,
1665 /// Suppress notifications entirely.
1666 Never,
1667 }
1668
1669 /// Notification delivery method (mirrors `tui::notifications::Method`).
1670 #[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
1671 #[serde(rename_all = "kebab-case")]
1672 pub enum NotificationMethod {
1673 /// Auto-detect: picks the best protocol for the current terminal
1674 /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).
1675 #[default]
1676 Auto,
1677 /// OSC 9 escape.
1678 Osc9,
1679 /// Plain BEL character.
1680 Bel,
1681 /// Kitty notification protocol (OSC 99).
1682 Kitty,
1683 /// Ghostty notification protocol (OSC 777).
1684 Ghostty,
1685 /// Disable notifications.
1686 Off,
1687 }
1688
1689 fn default_threshold_secs() -> u64 {
1690 30
1691 }
1692
1693 /// Completion sound options.
1694 #[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
1695 #[serde(rename_all = "kebab-case")]
1696 pub enum CompletionSound {
1697 /// No sound on turn completion.
1698 Off,
1699 /// System notification beep (default). On Windows uses `MessageBeep`.
1700 #[default]
1701 Beep,
1702 /// Terminal BEL character (`\x07`).
1703 Bell,
1704 /// Play a configured WAV sound file.
1705 File,
1706 }
1707
1708 /// Controls when per-subagent completion notifications fire during fleet /
1709 /// workflow runs. Turn-completion notifications are unaffected.
1710 #[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
1711 #[serde(rename_all = "kebab-case")]
1712 pub enum SubagentCompletionNotification {
1713 /// Notify on every subagent completion.
1714 Always,
1715 /// Notify only when the last subagent in a batch finishes — no other
1716 /// subagents running and no workflow run in progress. Default: stays quiet
1717 /// mid-run and fires once when the fleet drains.
1718 #[default]
1719 FinalOnly,
1720 /// Never fire a subagent-completion notification.
1721 Off,
1722 }
1723
1724 /// Desktop-notification configuration (OSC 9 / BEL on turn completion).
1725 #[derive(Debug, Clone, Deserialize, Default)]
1726 pub struct NotificationsConfig {
1727 /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.
1728 /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux
1729 /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); otherwise it
1730 /// falls back to BEL. On Windows the BEL path is routed through
1731 /// `MessageBeep(MB_OK)`.
1732 /// Use `method = "osc9"` explicitly when your terminal is OSC-9 capable
1733 /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).
1734 #[serde(default)]
1735 pub method: NotificationMethod,
1736 /// Only notify when the turn took at least this many seconds. Default: 30.
1737 #[serde(default = "default_threshold_secs")]
1738 pub threshold_secs: u64,
1739 /// Include a short summary (elapsed time + cost) in the notification body.
1740 /// Default: `false`.
1741 #[serde(default)]
1742 pub include_summary: bool,
1743
1744 /// When to fire per-subagent completion notifications during fleet /
1745 /// workflow runs: `always` | `final-only` | `off`. Default: `final-only`
1746 /// (quiet mid-run, one notification when the batch drains). Set `off` to
1747 /// silence subagent notifications entirely.
1748 #[serde(default)]
1749 pub subagent_completion: SubagentCompletionNotification,
1750
1751 /// Completion sound: `"off"` | `"beep"` | `"bell"` | `"file"`. Default: `"beep"`.
1752 /// Plays a sound when every turn finishes (alongside the ✅ marker).
1753 #[serde(default)]
1754 pub completion_sound: CompletionSound,
1755
1756 /// Path to the WAV sound file used when `completion_sound = "file"`.
1757 #[serde(default)]
1758 pub sound_file: Option<PathBuf>,
1759
1760 /// Opt-in per-event sound policy (`[notifications.event_sound]`).
1761 /// Disabled by default; see `tui::sound_policy` for the decision rules.
1762 #[serde(default)]
1763 pub event_sound: EventSoundConfig,
1764
1765 /// Quiet mode: suppress every desktop notification (all categories, all
1766 /// delivery methods) and the paired `[notifications.event_sound]` cues,
1767 /// without editing `method` or the per-category switches under
1768 /// `[notifications.events]`. The turn-completion chime
1769 /// (`completion_sound`) is governed separately. Default: `false`.
1770 #[serde(default)]
1771 pub quiet: bool,
1772
1773 /// Per-category desktop-notification switches
1774 /// (`[notifications.events]`). Every category defaults to enabled; set
1775 /// one to `false` to silence that event kind without touching the rest.
1776 #[serde(default)]
1777 pub events: NotificationEventsConfig,
1778 }
1779
1780 fn default_notification_event_enabled() -> bool {
1781 true
1782 }
1783
1784 /// Per-category desktop-notification switches (`[notifications.events]`).
1785 ///
1786 /// Categories mirror the closed set of notification kinds in
1787 /// `tui::notification_payload::NotificationKind`. Each defaults to `true`;
1788 /// a disabled category is suppressed across every delivery mechanism
1789 /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, BEL, macOS Notification Center).
1790 #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
1791 #[serde(rename_all = "kebab-case")]
1792 pub struct NotificationEventsConfig {
1793 /// An agent turn finished successfully. Default: `true`.
1794 #[serde(default = "default_notification_event_enabled")]
1795 pub turn_complete: bool,
1796 /// A sub-agent reached a terminal status. Default: `true`.
1797 #[serde(default = "default_notification_event_enabled")]
1798 pub subagent_terminal: bool,
1799 /// A tool call is blocked waiting for approval. Default: `true`.
1800 #[serde(default = "default_notification_event_enabled")]
1801 pub approval_needed: bool,
1802 /// The agent asked a question and is blocked on the answer.
1803 /// Default: `true`.
1804 #[serde(default = "default_notification_event_enabled")]
1805 pub input_needed: bool,
1806 /// The sandbox denied an operation and the user must decide.
1807 /// Default: `true`.
1808 #[serde(default = "default_notification_event_enabled")]
1809 pub elevation_needed: bool,
1810 /// The model called the `notify` tool. Default: `true`.
1811 #[serde(default = "default_notification_event_enabled")]
1812 pub model_notify: bool,
1813 }
1814
1815 impl Default for NotificationEventsConfig {
1816 fn default() -> Self {
1817 Self {
1818 turn_complete: true,
1819 subagent_terminal: true,
1820 approval_needed: true,
1821 input_needed: true,
1822 elevation_needed: true,
1823 model_notify: true,
1824 }
1825 }
1826 }
1827
1828 fn default_event_sound_events() -> Vec<String> {
1829 vec!["turn-complete".to_string(), "approval-needed".to_string()]
1830 }
1831
1832 fn default_event_sound_min_interval_ms() -> u64 {
1833 2000
1834 }
1835
1836 /// Opt-in, deterministic per-event sound policy (#4817). Terminal-bell
1837 /// level only: cues are BEL (`\x07`) bytes, a platform-safe no-op on
1838 /// terminals that ignore them. Off by default.
1839 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
1840 pub struct EventSoundConfig {
1841 /// Master switch. Default: `false` (nothing is emitted unless opted in).
1842 #[serde(default)]
1843 pub enabled: bool,
1844 /// Allow-list of event names, kebab-case (`"turn-complete"`,
1845 /// `"subagent-terminal"`, `"approval-needed"`, `"input-needed"`,
1846 /// `"elevation-needed"`, `"model-notify"`). Unknown names are ignored.
1847 /// Default: `["turn-complete", "approval-needed"]`.
1848 #[serde(default = "default_event_sound_events")]
1849 pub events: Vec<String>,
1850 /// Minimum milliseconds between two plays of the same event. Default: 2000.
1851 #[serde(default = "default_event_sound_min_interval_ms")]
1852 pub min_interval_ms: u64,
1853 /// Quiet mode: suppress all event sounds without editing the allow-list.
1854 /// Default: `false`.
1855 #[serde(default)]
1856 pub quiet: bool,
1857 }
1858
1859 impl Default for EventSoundConfig {
1860 fn default() -> Self {
1861 Self {
1862 enabled: false,
1863 events: default_event_sound_events(),
1864 min_interval_ms: default_event_sound_min_interval_ms(),
1865 quiet: false,
1866 }
1867 }
1868 }
1869
1870 fn default_snapshots_enabled() -> bool {
1871 true
1872 }
1873
1874 fn default_snapshot_max_age_days() -> u64 {
1875 crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)
1876 }
1877
1878 fn default_snapshot_max_workspace_gb() -> u64 {
1879 crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)
1880 }
1881
1882 /// Workspace side-git snapshot configuration (#137).
1883 #[derive(Debug, Clone, Deserialize)]
1884 pub struct SnapshotsConfig {
1885 /// Snapshot the workspace before and after each interactive agent turn.
1886 #[serde(default = "default_snapshots_enabled")]
1887 pub enabled: bool,
1888 /// Prune side-git snapshots older than this many days at session boot.
1889 #[serde(default = "default_snapshot_max_age_days")]
1890 pub max_age_days: u64,
1891 /// Maximum non-excluded workspace size (in GB) before the snapshot
1892 /// feature self-disables on first use. Set to `0` to disable the cap
1893 /// and snapshot regardless of size (the v0.8.31 behavior). The walk
1894 /// honors `.gitignore` and the snapshot module's built-in excludes
1895 /// (`node_modules/`, `target/`, ...) so the measured size reflects
1896 /// what would actually land in a snapshot commit.
1897 #[serde(default = "default_snapshot_max_workspace_gb")]
1898 pub max_workspace_gb: u64,
1899 }
1900
1901 impl Default for SnapshotsConfig {
1902 fn default() -> Self {
1903 Self {
1904 enabled: default_snapshots_enabled(),
1905 max_age_days: default_snapshot_max_age_days(),
1906 max_workspace_gb: default_snapshot_max_workspace_gb(),
1907 }
1908 }
1909 }
1910
1911 /// User-level memory configuration (#489).
1912 ///
1913 /// Default is opt-in: when this table is absent or `enabled = false`, the
1914 /// memory file is neither read nor written, and `# foo` quick-adds in the
1915 /// composer fall through to the normal turn-submission path.
1916 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
1917 #[serde(rename_all = "lowercase")]
1918 pub enum MemoryBackend {
1919 Native,
1920 #[default]
1921 Off,
1922 }
1923
1924 #[derive(Debug, Clone, Default, Deserialize)]
1925 pub struct MemoryConfig {
1926 /// When `true`, load the user memory file at `Config::memory_path()`
1927 /// into the system prompt as a `<user_memory>` block, and intercept
1928 /// `# foo` typed in the composer to append to that file. Default `false`.
1929 #[serde(default)]
1930 pub enabled: Option<bool>,
1931 /// Explicit backend selection for the v0.9.2 memory lifecycle.
1932 /// `None` preserves the pre-native opt-in behavior for old configs.
1933 #[serde(default)]
1934 pub backend: Option<MemoryBackend>,
1935 }
1936
1937 /// Xiaomi MiMo speech/TTS output configuration.
1938 #[derive(Debug, Clone, Default, Deserialize)]
1939 pub struct SpeechConfig {
1940 /// Default directory for generated speech/TTS files when no explicit
1941 /// output path is provided.
1942 #[serde(default)]
1943 pub output_dir: Option<String>,
1944 }
1945
1946 impl SnapshotsConfig {
1947 #[must_use]
1948 pub fn max_age(&self) -> std::time::Duration {
1949 std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))
1950 }
1951 }
1952
1953 // Web-search `[search]` table types live in the `search` leaf module and are
1954 // re-exported below so `crate::config::SearchProvider` (and siblings) resolve
1955 // unchanged (#3311).
1956 mod search;
1957 pub use search::*;
1958
1959 /// Model-visible tool catalog controls (`[tools]` table in config.toml).
1960 #[derive(Debug, Clone, Deserialize, Default)]
1961 pub struct ToolsConfig {
1962 /// Native tool names to keep loaded even when they are outside the small
1963 /// default core catalog. Unknown names are harmless and simply never match.
1964 #[serde(default)]
1965 pub always_load: Vec<String>,
1966
1967 /// Optional directory to scan for plugin tool scripts. Scripts with a
1968 /// frontmatter header (`# name:`, `# description:`, `# schema:`) are
1969 /// auto-discovered and registered as tools.
1970 ///
1971 /// Defaults to `~/.codewhale/tools/` when `None`.
1972 #[serde(default)]
1973 pub plugin_dir: Option<String>,
1974
1975 /// Per-tool overrides keyed by built-in tool name.
1976 /// Each override replaces or disables the named tool.
1977 #[serde(default)]
1978 pub overrides: Option<HashMap<String, ToolOverride>>,
1979 }
1980
1981 /// Persistent-goal loop controls (`[goal]` table in config.toml, #5052).
1982 #[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
1983 pub struct GoalConfig {
1984 /// Safety backstop on automatic goal continuation passes. The completion
1985 /// gate and token/time budgets are the real terminal stops; this only
1986 /// halts a pathological loop that never emits a terminal signal.
1987 ///
1988 /// `None` uses the built-in default
1989 /// ([`crate::goal_loop::DEFAULT_MAX_GOAL_CONTINUATIONS`]); `0` disables
1990 /// the backstop entirely so only budget/terminal stops end the run.
1991 #[serde(default)]
1992 pub max_continuations: Option<u32>,
1993 }
1994
1995 /// One configurable footer item.
1996 ///
1997 /// Order in the user's `Vec<StatusItem>` is preserved: items in the left
1998 /// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;
1999 /// right-cluster chips (`Agents`, `ReasoningReplay`, `PrefixStability`,
2000 /// `Cache`, `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`)
2001 /// likewise honour ordering inside their cluster. The split between left and right is deliberate — left holds steady
2002 /// identity (mode/model/cost), right holds transient signals — so we route
2003 /// each variant to the correct side rather than letting users reorder across
2004 /// the spacer.
2005 ///
2006 /// Variants without a current data source (`RateLimit`, `LastToolElapsed`)
2007 /// are intentionally exposed today so the picker is forward-compatible; they
2008 /// render empty until the supporting fields land. Empty spans don't take
2009 /// up footer width, so the user sees no visual artifact.
2010 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
2011 #[serde(rename_all = "snake_case")]
2012 pub enum StatusItem {
2013 /// "act" / "plan" / "operate" chip.
2014 Mode,
2015 /// Model identifier (e.g. `deepseek-v4-pro`).
2016 Model,
2017 /// Session cost in the configured display currency.
2018 Cost,
2019 /// Activity label: "idle" / "busy" / "draft" / "working".
2020 Status,
2021 /// Sub-agent count chip ("3 agents").
2022 Agents,
2023 /// Reasoning-replay token count ("rsn 12.3k").
2024 ReasoningReplay,
2025 /// Prefix stability ("cache prefix 100%").
2026 PrefixStability,
2027 /// Cache hit rate ("cache 73%").
2028 Cache,
2029 /// Context-window utilisation percent ("48%").
2030 ContextPercent,
2031 /// Current git branch name.
2032 GitBranch,
2033 /// Elapsed time of the most recent tool call (placeholder until wired).
2034 LastToolElapsed,
2035 /// Remaining rate-limit budget (placeholder until wired).
2036 RateLimit,
2037 /// Session token usage: input / cache-hit / output.
2038 Tokens,
2039 /// DeepSeek account balance, refreshed once per turn completion.
2040 Balance,
2041 }
2042
2043 impl StatusItem {
2044 /// Default footer composition for the always-on status line. Used when
2045 /// `tui.status_items` is missing from `config.toml` so upgraders see a
2046 /// concise footer by default; diagnostic chips remain available via
2047 /// `/statusline` without crowding the main UI.
2048 #[must_use]
2049 pub fn default_footer() -> Vec<StatusItem> {
2050 vec![
2051 StatusItem::Mode,
2052 StatusItem::Model,
2053 StatusItem::Cost,
2054 StatusItem::Status,
2055 StatusItem::Agents,
2056 StatusItem::ReasoningReplay,
2057 StatusItem::Cache,
2058 StatusItem::GitBranch,
2059 StatusItem::Tokens,
2060 ]
2061 }
2062
2063 /// Stable canonical name used in TOML and the picker label.
2064 #[must_use]
2065 pub fn key(self) -> &'static str {
2066 match self {
2067 StatusItem::Mode => "mode",
2068 StatusItem::Model => "model",
2069 StatusItem::Cost => "cost",
2070 StatusItem::Status => "status",
2071 StatusItem::Agents => "agents",
2072 StatusItem::ReasoningReplay => "reasoning_replay",
2073 StatusItem::PrefixStability => "prefix_stability",
2074 StatusItem::Cache => "cache",
2075 StatusItem::ContextPercent => "context_percent",
2076 StatusItem::GitBranch => "git_branch",
2077 StatusItem::LastToolElapsed => "last_tool_elapsed",
2078 StatusItem::RateLimit => "rate_limit",
2079 StatusItem::Tokens => "tokens",
2080 StatusItem::Balance => "balance",
2081 }
2082 }
2083
2084 /// Reverse of [`key`](Self::key): parse a config string back to a variant.
2085 /// Returns `None` for unknown keys so the config parser can silently skip
2086 /// items added by newer versions rather than crashing with "unknown variant".
2087 #[must_use]
2088 pub fn from_key(key: &str) -> Option<Self> {
2089 match key {
2090 "mode" => Some(Self::Mode),
2091 "model" => Some(Self::Model),
2092 "cost" => Some(Self::Cost),
2093 "status" => Some(Self::Status),
2094 "agents" => Some(Self::Agents),
2095 "reasoning_replay" => Some(Self::ReasoningReplay),
2096 "prefix_stability" => Some(Self::PrefixStability),
2097 "cache" => Some(Self::Cache),
2098 "context_percent" => Some(Self::ContextPercent),
2099 "git_branch" => Some(Self::GitBranch),
2100 "last_tool_elapsed" => Some(Self::LastToolElapsed),
2101 "rate_limit" => Some(Self::RateLimit),
2102 "tokens" => Some(Self::Tokens),
2103 "balance" => Some(Self::Balance),
2104 _ => None,
2105 }
2106 }
2107
2108 /// Human-readable label for the picker.
2109 #[must_use]
2110 pub fn label(self) -> &'static str {
2111 match self {
2112 StatusItem::Mode => "Mode",
2113 StatusItem::Model => "Model",
2114 StatusItem::Cost => "Session cost",
2115 StatusItem::Status => "Activity (idle/busy/draft/working)",
2116 StatusItem::Agents => "Sub-agents in flight",
2117 StatusItem::ReasoningReplay => "Reasoning replay tokens",
2118 StatusItem::PrefixStability => "Prefix stability",
2119 StatusItem::Cache => "Prompt cache hit rate",
2120 StatusItem::ContextPercent => "Context window %",
2121 StatusItem::GitBranch => "Git branch",
2122 StatusItem::LastToolElapsed => "Last tool elapsed",
2123 StatusItem::RateLimit => "Rate-limit remaining",
2124 StatusItem::Tokens => "Session tokens",
2125 StatusItem::Balance => "Account balance",
2126 }
2127 }
2128
2129 /// One-line hint shown beside the label so the user knows what each item
2130 /// surfaces without having to toggle it on first.
2131 #[must_use]
2132 pub fn hint(self) -> &'static str {
2133 match self {
2134 StatusItem::Mode => "plan · act · operate",
2135 StatusItem::Model => "the model id you'll send to",
2136 StatusItem::Cost => "running total for this session",
2137 StatusItem::Status => "what the agent is doing right now",
2138 StatusItem::Agents => "agents or RLM work in progress",
2139 StatusItem::ReasoningReplay => "thinking tokens replayed each turn",
2140 StatusItem::PrefixStability => "whether system/tools stayed cacheable",
2141 StatusItem::Cache => "% of prompt served from cache",
2142 StatusItem::ContextPercent => "tokens used / model context window",
2143 StatusItem::GitBranch => "current workspace branch",
2144 StatusItem::LastToolElapsed => "ms of the most recent tool call (reserved)",
2145 StatusItem::RateLimit => "remaining requests in the budget (reserved)",
2146 StatusItem::Tokens => "input / cache-hit / output token totals",
2147 StatusItem::Balance => "topped-up + granted balance from DeepSeek",
2148 }
2149 }
2150
2151 /// Every variant in display order — used by the picker to enumerate rows.
2152 #[must_use]
2153 pub fn all() -> &'static [StatusItem] {
2154 &[
2155 StatusItem::Mode,
2156 StatusItem::Model,
2157 StatusItem::Cost,
2158 StatusItem::Balance,
2159 StatusItem::Status,
2160 StatusItem::Agents,
2161 StatusItem::ReasoningReplay,
2162 StatusItem::PrefixStability,
2163 StatusItem::Cache,
2164 StatusItem::ContextPercent,
2165 StatusItem::GitBranch,
2166 StatusItem::LastToolElapsed,
2167 StatusItem::RateLimit,
2168 StatusItem::Tokens,
2169 ]
2170 }
2171
2172 /// Whether this item is relevant for `provider`. Provider-specific
2173 /// items return `false` for unsupported providers so the picker doesn't
2174 /// offer toggles that can never show useful data.
2175 #[must_use]
2176 pub fn is_available_for(self, provider: ApiProvider) -> bool {
2177 match self {
2178 StatusItem::Balance => {
2179 matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
2180 }
2181 _ => true,
2182 }
2183 }
2184 }
2185
2186 /// One configurable header item
2187
2188 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
2189 #[serde(rename_all = "snake_case")]
2190 pub enum HeaderItem {
2191 /// Session token usage: input / cache-hit / output.
2192 Tokens,
2193 }
2194
2195 impl HeaderItem {
2196 /// Default header composition for the always-on status line. Used when
2197 /// `tui.header_items` is missing from `config.toml` so upgraders see a
2198 /// concise header by default; diagnostic chips remain available through
2199 /// explicit configuration without crowding the main UI.
2200 #[must_use]
2201 pub fn default_header() -> Vec<HeaderItem> {
2202 Vec::new()
2203 }
2204
2205 /// Stable canonical name used in TOML.
2206 #[must_use]
2207 pub fn key(self) -> &'static str {
2208 match self {
2209 HeaderItem::Tokens => "tokens",
2210 }
2211 }
2212
2213 /// Parse a config string while ignoring unknown items.
2214 #[must_use]
2215 pub fn from_key(key: &str) -> Option<Self> {
2216 match key {
2217 "tokens" => Some(Self::Tokens),
2218 _ => None,
2219 }
2220 }
2221 }
2222
2223 /// Resolved retry policy with defaults applied.
2224 #[derive(Debug, Clone)]
2225 pub struct RetryPolicy {
2226 pub enabled: bool,
2227 pub max_retries: u32,
2228 pub initial_delay: f64,
2229 pub max_delay: f64,
2230 pub exponential_base: f64,
2231 }
2232
2233 /// Context management configuration.
2234 ///
2235 /// The append-only "Flash seam" layered-context system (#159) was removed on
2236 /// 2026-07-23 — it never left its opt-in default and compaction owns context
2237 /// reduction now. Its keys remain parsed-but-ignored so existing config files
2238 /// keep loading; `project_pack` is the only live setting.
2239 #[derive(Debug, Clone, Deserialize, Default)]
2240 pub struct ContextConfig {
2241 /// Ignored (was: master enable for the removed layered-context system).
2242 #[serde(default)]
2243 pub enabled: Option<bool>,
2244 /// Include a deterministic project context pack in the stable prompt
2245 /// prefix. Default: false — the pack is a large pretty-printed directory
2246 /// listing the model can rebuild with one `File` call (#4781). Set
2247 /// `[context] project_pack = true` to opt in (useful for weak tool-calling
2248 /// models).
2249 #[serde(default)]
2250 pub project_pack: Option<bool>,
2251 /// Ignored (was: seam verbatim window).
2252 #[serde(default)]
2253 pub verbatim_window_turns: Option<usize>,
2254 /// Ignored (was: seam thresholds).
2255 #[serde(default)]
2256 pub l1_threshold: Option<usize>,
2257 #[serde(default)]
2258 pub l2_threshold: Option<usize>,
2259 #[serde(default)]
2260 pub l3_threshold: Option<usize>,
2261 /// Ignored (was: seam model).
2262 #[serde(default)]
2263 pub seam_model: Option<String>,
2264 }
2265
2266 /// Fleet-role model overrides for delegated workers. Canonical keys in
2267 /// `models` are `worker`, `scout`, `planner`, `reviewer`, `builder`,
2268 /// `verifier`, and `custom`. Legacy sub-agent type names remain accepted for
2269 /// v0.9.x compatibility. Per-call explicit model choices still win.
2270 #[derive(Debug, Clone, Deserialize, Default)]
2271 pub struct SubagentsConfig {
2272 /// Top-level switch for the model-facing `agent` tool. `None` preserves
2273 /// the feature-flag default; `false` hides/refuses sub-agent spawning
2274 /// without changing the numeric queue/depth knobs.
2275 #[serde(default)]
2276 pub enabled: Option<bool>,
2277 #[serde(default)]
2278 pub default_model: Option<String>,
2279 #[serde(default)]
2280 pub worker_model: Option<String>,
2281 #[serde(default, rename = "scout_model", alias = "explorer_model")]
2282 pub explorer_model: Option<String>,
2283 #[serde(default, rename = "planner_model", alias = "awaiter_model")]
2284 pub awaiter_model: Option<String>,
2285 #[serde(default, rename = "reviewer_model", alias = "review_model")]
2286 pub review_model: Option<String>,
2287 #[serde(default)]
2288 pub custom_model: Option<String>,
2289 #[serde(default)]
2290 pub models: Option<HashMap<String, String>>,
2291 /// Maximum concurrent sub-agents. Overrides the top-level max_subagents
2292 /// setting. Clamped to [1, MAX_SUBAGENTS].
2293 #[serde(default)]
2294 pub max_concurrent: Option<usize>,
2295 /// How many levels of nested sub-agents the interactive `agent` tool may
2296 /// spawn. `0` blocks the model-facing `agent` tool at this runtime depth;
2297 /// use `[subagents] enabled = false` for the clearer durable off switch.
2298 /// `1` allows one level, `2` two, and so on. When unset, defaults to
2299 /// [`codewhale_config::DEFAULT_SPAWN_DEPTH`]; any value is clamped to
2300 /// [`codewhale_config::MAX_SPAWN_DEPTH_CEILING`]. Fleet workers are
2301 /// governed separately by `[fleet.exec] max_spawn_depth`; both share the
2302 /// same default and ceiling so the limit cannot drift.
2303 #[serde(default)]
2304 pub max_depth: Option<u32>,
2305 /// Number of direct (depth-1) sub-agents that may execute concurrently
2306 /// before further launches queue for a launch slot (#3095). When unset,
2307 /// defaults to the full resolved `max_subagents()` (no artificial
2308 /// throttle); explicit values are clamped to [1, max_subagents].
2309 #[serde(default)]
2310 pub launch_concurrency: Option<usize>,
2311 /// Maximum queued + running sub-agents admitted for one session. Defaults
2312 /// to a large bounded queue while `launch_concurrency` keeps instantaneous
2313 /// execution bounded.
2314 #[serde(default, alias = "max_total", alias = "admission_limit")]
2315 pub max_admitted: Option<usize>,
2316 /// Optional aggregate token budget shared by a root `agent` run and its
2317 /// descendants. When unset or 0, sub-agents keep legacy unlimited spend
2318 /// behavior unless an individual `agent` call supplies a per-run override.
2319 #[serde(default)]
2320 pub token_budget: Option<u64>,
2321 /// Deprecated pre-v0.8.61 alias for `launch_concurrency`. Honored only
2322 /// when `launch_concurrency` is unset, so the new key always wins.
2323 #[serde(default, rename = "interactive_max_launch")]
2324 pub interactive_max_launch_legacy: Option<usize>,
2325 /// Per-step DeepSeek API timeout for sub-agent requests, in seconds. The
2326 /// timeout wraps `client.create_message` so a stuck single step cannot
2327 /// pin the parent's parent-completion wakeup channel indefinitely.
2328 /// Defaults to `DEFAULT_SUBAGENT_API_TIMEOUT_SECS` (600) and is clamped
2329 /// to `MIN_SUBAGENT_API_TIMEOUT_SECS..=MAX_SUBAGENT_API_TIMEOUT_SECS`
2330 /// (1..=3600). Zero or unset uses the 600s default (#1806, #1808).
2331 #[serde(default)]
2332 pub api_timeout_secs: Option<u64>,
2333 /// Wall-clock timeout for a running sub-agent that stops making
2334 /// manager-visible progress. Defaults to 5 minutes and is kept above the
2335 /// per-step API timeout so slow but legitimate model calls are not
2336 /// cancelled before their request timeout can fire (#2614).
2337 #[serde(default)]
2338 pub heartbeat_timeout_secs: Option<u64>,
2339 /// Per-provider overrides for sub-agent fanout and budget knobs. Keys are
2340 /// provider names such as `deepseek`, `zai`, `openrouter`, or `anthropic`.
2341 #[serde(default)]
2342 pub providers: Option<HashMap<String, SubagentProviderConfig>>,
2343 }
2344
2345 /// Provider-specific sub-agent limit overrides.
2346 ///
2347 /// Every field inherits from `[subagents]` when unset, so a provider profile
2348 /// can tighten only the knobs that matter for that API's rate limits.
2349 #[derive(Debug, Clone, Deserialize, Default)]
2350 pub struct SubagentProviderConfig {
2351 #[serde(default)]
2352 pub enabled: Option<bool>,
2353 #[serde(default)]
2354 pub max_concurrent: Option<usize>,
2355 #[serde(default)]
2356 pub max_depth: Option<u32>,
2357 #[serde(default)]
2358 pub launch_concurrency: Option<usize>,
2359 #[serde(default, alias = "max_total", alias = "admission_limit")]
2360 pub max_admitted: Option<usize>,
2361 #[serde(default)]
2362 pub token_budget: Option<u64>,
2363 #[serde(default)]
2364 pub api_timeout_secs: Option<u64>,
2365 #[serde(default)]
2366 pub heartbeat_timeout_secs: Option<u64>,
2367 }
2368
2369 /// `[auto]` table — knobs for the `--model auto` / `/model auto` router.
2370 ///
2371 /// `cost_saving` (#1207): when `true`, the auto-mode router prefers the
2372 /// active provider's known fast sibling for ambiguous requests, only using
2373 /// its strong tier when the task clearly benefits from deeper reasoning.
2374 /// Providers without a validated sibling stay on the active model. Default
2375 /// is `false` (balanced — match the existing routing voice).
2376 ///
2377 /// `cross_provider` (#4411): Auto routing is scoped to the active provider
2378 /// unless this persisted opt-in is set to `true`. Without it, neither the
2379 /// classifier inventory nor the local heuristic may leave the provider the
2380 /// session is actually configured to use.
2381 #[derive(Debug, Clone, Deserialize, Default)]
2382 pub struct AutoConfig {
2383 #[serde(default)]
2384 pub cost_saving: Option<bool>,
2385 /// Persisted opt-in for cross-provider Auto routing (`[auto]
2386 /// cross_provider = true`). Default `false`: active provider only.
2387 #[serde(default)]
2388 pub cross_provider: Option<bool>,
2389 /// Optional explicit auto-router classifier route (`[auto.router]`).
2390 #[serde(default)]
2391 pub router: Option<AutoRouterConfig>,
2392 }
2393
2394 /// Explicit classifier route for Auto model mode (`[auto.router]`).
2395 ///
2396 /// When `provider` + `model` are set, Auto mode's classifier call goes to that
2397 /// route. When unset, Auto stays local and free: it uses the heuristic and
2398 /// makes no classifier call at all.
2399 ///
2400 /// There is deliberately no implicit default. Holding a DeepSeek key used to
2401 /// elect `deepseek-v4-flash` as the classifier for every Auto turn, which spent
2402 /// a user's tokens on a route they never chose and privileged one provider over
2403 /// the rest. Electing a network classifier is now something the operator writes
2404 /// down.
2405 #[derive(Debug, Clone, Default, Deserialize)]
2406 pub struct AutoRouterConfig {
2407 /// Provider id for the classifier route (e.g. `"deepseek"`, `"zai"`).
2408 #[serde(default)]
2409 pub provider: Option<String>,
2410 /// Model id on that provider (e.g. `"deepseek-v4-flash"`).
2411 #[serde(default)]
2412 pub model: Option<String>,
2413 /// Thinking tier for the classifier call (e.g. `"off"`). Defaults to off.
2414 #[serde(default)]
2415 pub thinking: Option<String>,
2416 }
2417
2418 fn default_update_check_for_updates() -> bool {
2419 true
2420 }
2421
2422 fn default_update_check_interval_hours() -> u64 {
2423 codewhale_release::check::DEFAULT_CHECK_INTERVAL_HOURS
2424 }
2425
2426 /// Startup update-check configuration (`[update]` table in config.toml).
2427 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
2428 pub struct UpdateConfig {
2429 /// When false, skip the TUI startup background update check entirely.
2430 #[serde(default = "default_update_check_for_updates")]
2431 pub check_for_updates: bool,
2432 /// Hours between network checks. The answer is cached on disk in between,
2433 /// so the notice still appears on every launch — only the request is
2434 /// throttled. `0` disables caching and checks on every launch.
2435 #[serde(default = "default_update_check_interval_hours")]
2436 pub check_interval_hours: u64,
2437 /// Optional GitHub-compatible latest-release JSON endpoint.
2438 #[serde(default)]
2439 pub update_uri: Option<String>,
2440 }
2441
2442 impl Default for UpdateConfig {
2443 fn default() -> Self {
2444 Self {
2445 check_for_updates: true,
2446 check_interval_hours: default_update_check_interval_hours(),
2447 update_uri: None,
2448 }
2449 }
2450 }
2451
2452 impl UpdateConfig {
2453 #[must_use]
2454 pub fn update_uri(&self) -> Option<&str> {
2455 self.update_uri
2456 .as_deref()
2457 .map(str::trim)
2458 .filter(|value| !value.is_empty())
2459 }
2460 }
2461
2462 /// Resolved CLI configuration, including defaults and environment overrides.
2463 #[derive(Debug, Clone, Default, Deserialize)]
2464 pub struct Config {
2465 /// Single-token inputs that cancel the active turn before dispatch.
2466 #[serde(default)]
2467 pub stop_words: Option<Vec<String>>,
2468 pub provider: Option<String>,
2469 #[serde(alias = "apiKey")]
2470 pub api_key: Option<String>,
2471 #[serde(alias = "baseUrl")]
2472 pub base_url: Option<String>,
2473 /// Optional extra HTTP headers sent to model API requests.
2474 #[serde(alias = "httpHeaders")]
2475 pub http_headers: Option<HashMap<String, String>>,
2476 #[serde(alias = "defaultTextModel")]
2477 pub default_text_model: Option<String>,
2478 #[serde(alias = "authMode")]
2479 pub auth_mode: Option<String>,
2480 /// DeepSeek reasoning-effort tier: `"off" | "low" | "medium" | "high" | "max"`.
2481 /// Defaults to `"max"` at runtime if unset.
2482 pub reasoning_effort: Option<String>,
2483 /// True only when compatibility migration inferred `reasoning_effort`
2484 /// from a retiring DeepSeek alias. This distinguishes that inferred value
2485 /// from a user override during an in-session provider switch.
2486 #[serde(skip)]
2487 pub(crate) reasoning_effort_inferred_from_legacy_alias: bool,
2488 /// Original first-party DeepSeek alias captured before model normalization.
2489 /// This runtime-only receipt lets diagnostics explain why the resolved
2490 /// model changed without persisting compatibility state back to config.
2491 #[serde(skip)]
2492 pub(crate) migrated_deepseek_model_alias: Option<String>,
2493 /// Native tool catalog controls. This table controls built-in
2494 /// tool loading policy.
2495 #[serde(default)]
2496 pub tools: Option<ToolsConfig>,
2497 pub skills_dir: Option<String>,
2498 pub mcp_config_path: Option<String>,
2499 pub mcp_oauth_callback_port: Option<u16>,
2500 pub mcp_oauth_callback_url: Option<String>,
2501 pub notes_path: Option<String>,
2502 pub memory_path: Option<String>,
2503 /// When true, set `tool_choice: "required"` and opt compatible function
2504 /// schemas into DeepSeek beta strict mode. Schemas with root alternatives
2505 /// stay non-strict to avoid changing optional/one-of tool semantics.
2506 pub strict_tool_mode: Option<bool>,
2507 /// Additional user-owned system-prompt sources concatenated in declared
2508 /// order (#454). Paths are expanded via `expand_path` so `~` and env vars
2509 /// work. Project-scope config is not allowed to set this field; the TUI
2510 /// project overlay ignores `instructions` so a cloned repo cannot choose
2511 /// arbitrary local files to place into the prompt. Each configured file is
2512 /// loaded, capped at 100 KiB, and skipped (with a warning) on read errors so
2513 /// a missing optional file doesn't fail the launch.
2514 pub instructions: Option<Vec<String>>,
2515 pub allow_shell: Option<bool>,
2516 /// Opt-in ghost-text follow-up prompt suggestion after each completed turn.
2517 /// Default: false — the user must explicitly set this to true to enable.
2518 pub prompt_suggestion: Option<bool>,
2519 #[serde(alias = "approvalPolicy")]
2520 pub approval_policy: Option<String>,
2521 #[serde(alias = "sandboxMode")]
2522 pub sandbox_mode: Option<String>,
2523 #[serde(default, alias = "fallbackProviders")]
2524 pub fallback_providers: Vec<codewhale_config::ProviderKind>,
2525 pub yolo: Option<bool>,
2526 pub verbosity: Option<String>,
2527 /// External sandbox backend: `"none"` or `"opensandbox"`.
2528 /// When set, exec_shell routes commands through the backend's HTTP API
2529 /// instead of spawning a local process.
2530 #[serde(alias = "sandboxBackend")]
2531 pub sandbox_backend: Option<String>,
2532 /// Base URL for the external sandbox backend (default: `"http://localhost:8080"`).
2533 #[serde(alias = "sandboxUrl")]
2534 pub sandbox_url: Option<String>,
2535 /// Optional API key for the external sandbox backend (sent as Bearer token).
2536 #[serde(alias = "sandboxApiKey")]
2537 pub sandbox_api_key: Option<String>,
2538 /// When true and `/usr/bin/bwrap` is executable on Linux, route exec_shell
2539 /// through bubblewrap (#2184).
2540 /// Defaults to false. Requires the `bubblewrap` package to be installed
2541 /// separately — we do NOT vendor bwrap.
2542 #[serde(alias = "preferBwrap")]
2543 pub prefer_bwrap: Option<bool>,
2544 #[serde(alias = "managedConfigPath")]
2545 pub managed_config_path: Option<String>,
2546 #[serde(alias = "requirementsPath")]
2547 pub requirements_path: Option<String>,
2548 #[serde(alias = "maxSubagents")]
2549 pub max_subagents: Option<usize>,
2550 pub retry: Option<RetryConfig>,
2551 pub features: Option<FeaturesToml>,
2552
2553 /// Deterministic user-level auto-review policy for tool calls. The engine
2554 /// applies these rules after built-in safety floors, so config cannot
2555 /// bypass publish/destructive-background holds.
2556 #[serde(default)]
2557 pub auto_review: Option<AutoReviewConfig>,
2558
2559 /// TUI configuration (alternate screen, etc.)
2560 pub tui: Option<TuiConfig>,
2561
2562 /// Lifecycle hooks configuration
2563 #[serde(default)]
2564 pub hooks: Option<HooksConfig>,
2565
2566 /// Provider-specific credentials and defaults shared with the `codewhale` facade.
2567 #[serde(default)]
2568 pub providers: Option<ProvidersConfig>,
2569
2570 /// Desktop notification settings (OSC 9 / BEL on long turn completion).
2571 #[serde(default)]
2572 pub notifications: Option<NotificationsConfig>,
2573
2574 /// Per-domain network policy (#135). When absent, network tools fall back
2575 /// to a permissive default that mirrors pre-v0.7.0 behavior.
2576 #[serde(default)]
2577 pub network: Option<NetworkPolicyToml>,
2578
2579 /// Verifier-preview behavior (#2093). When absent, automatic verifier
2580 /// preview stays off and verifier verdicts use the hunt policy.
2581 #[serde(default)]
2582 pub verifier: Option<codewhale_config::VerifierConfigToml>,
2583
2584 /// Background advisor watcher (#3982). When absent, the advisor is off
2585 /// by default. Enable with `[advisor] enabled = true` or `/advisor on`.
2586 #[serde(default)]
2587 pub advisor: Option<codewhale_config::AdvisorConfigToml>,
2588
2589 /// Community skill installer settings (#140). When absent, installer
2590 /// commands fall back to the bundled defaults
2591 /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +
2592 /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).
2593 #[serde(default)]
2594 pub skills: Option<SkillsConfig>,
2595
2596 /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day
2597 /// retention when the table is absent.
2598 #[serde(default)]
2599 pub snapshots: Option<SnapshotsConfig>,
2600
2601 /// Web search provider configuration. When absent, defaults to DuckDuckGo.
2602 /// Set `provider` to another supported backend such as `bing`, `tavily`,
2603 /// `bocha`, `metaso`, `searxng`, `baidu`, `volcengine`, or `sofya`.
2604 /// API-backed services require provider-specific credentials; SearXNG
2605 /// requires a trusted `base_url`.
2606 #[serde(default)]
2607 pub search: Option<SearchConfig>,
2608
2609 /// Persistent-goal loop controls (#5052). When absent, the continuation
2610 /// backstop falls back to
2611 /// [`crate::goal_loop::DEFAULT_MAX_GOAL_CONTINUATIONS`].
2612 #[serde(default)]
2613 pub goal: Option<GoalConfig>,
2614
2615 /// User-level memory (#489). Default behaviour is **opt-in**:
2616 /// loading + injection happens only when `[memory] enabled = true` or
2617 /// `DEEPSEEK_MEMORY=on` is set. The surviving store is the native
2618 /// Markdown + SQLite FTS5 system (`memory/global/MEMORY.md`).
2619 #[serde(default)]
2620 pub memory: Option<MemoryConfig>,
2621
2622 /// Xiaomi MiMo speech/TTS defaults.
2623 #[serde(default)]
2624 pub speech: Option<SpeechConfig>,
2625
2626 /// Tunables for `--model auto` (#1207). When absent, the auto router
2627 /// keeps its existing balanced behaviour.
2628 #[serde(default)]
2629 pub auto: Option<AutoConfig>,
2630
2631 /// Optional 1-8 hotbar slot bindings (#2064). When absent, hotbar UI and
2632 /// dispatch layers use the built-in defaults from `codewhale_config`.
2633 #[serde(default)]
2634 pub hotbar: Option<Vec<codewhale_config::HotbarBindingToml>>,
2635
2636 /// Startup update-check behavior. When absent, the TUI keeps the default
2637 /// fire-and-forget latest-release check.
2638 #[serde(default)]
2639 pub update: Option<UpdateConfig>,
2640
2641 /// Post-edit LSP diagnostics injection (#136). When absent, the engine
2642 /// applies the defaults documented in [`LspConfigToml`].
2643 #[serde(default)]
2644 pub lsp: Option<LspConfigToml>,
2645
2646 /// Context configuration (project context pack; legacy seam keys are
2647 /// parsed but ignored since the 2026-07-23 removal).
2648 #[serde(default)]
2649 pub context: ContextConfig,
2650
2651 /// Agent Fleet trust/security/role/exec config.
2652 #[serde(default)]
2653 pub fleet: Option<codewhale_config::FleetConfigToml>,
2654
2655 /// Workflow automatic-launch, approval, isolation, and activity
2656 /// persistence knobs (#4128). When absent, consumers use
2657 /// [`codewhale_config::WorkflowConfigToml::default`] via
2658 /// [`Self::workflow_config`].
2659 #[serde(default)]
2660 pub workflow: Option<codewhale_config::WorkflowConfigToml>,
2661
2662 /// Sub-agent model overrides.
2663 #[serde(default)]
2664 pub subagents: Option<SubagentsConfig>,
2665
2666 /// Runtime API server tuning (`codewhale serve --http`). Currently only
2667 /// hosts the CORS allow-list extension (whalescale#255 / #561). When the
2668 /// table is absent, the daemon ships with localhost:3000 / localhost:1420
2669 /// / tauri://localhost as the only allowed dev origins.
2670 #[serde(default)]
2671 pub runtime_api: Option<RuntimeApiConfig>,
2672
2673 /// Workshop / large-tool-output routing (#548). When absent, the global
2674 /// default threshold of 4 096 tokens applies and routing is active.
2675 #[serde(default)]
2676 pub workshop: Option<crate::tools::large_output_router::WorkshopConfig>,
2677
2678 /// Vision model configuration for the `image_analyze` tool.
2679 #[serde(default)]
2680 pub vision_model: Option<VisionModelConfig>,
2681
2682 /// Sibling `permissions.toml` ask-rules compiled for runtime checks.
2683 ///
2684 /// This is deliberately not part of `config.toml`; it is loaded from the
2685 /// companion permissions file after profile/env/managed config resolution.
2686 #[serde(skip)]
2687 pub exec_policy_engine: ExecPolicyEngine,
2688
2689 /// Receipt describing what the environment layer did to this config's
2690 /// effective base URL.
2691 ///
2692 /// This provenance cannot be reconstructed from the merged provider table:
2693 /// environment overrides are written into the same `base_url` field as
2694 /// file-owned routes. Keep the receipt so a saved provider/root key (or a
2695 /// configured `api_key_env`) cannot silently follow an env-selected custom
2696 /// host, and so a cross-provider child cannot borrow an ambient generic
2697 /// host that was never addressed to it.
2698 #[serde(skip)]
2699 pub(crate) base_url_env_receipt: BaseUrlEnvReceipt,
2700
2701 /// Who owns the legacy root `base_url` field.
2702 ///
2703 /// `Deepseek` and `DeepseekCN` are two identities that share one legacy
2704 /// root field, so the field alone cannot say whether it is a user's
2705 /// file-owned endpoint (shared by both, as it always has been) or a
2706 /// `CODEWHALE_BASE_URL`/`DEEPSEEK_BASE_URL` value that
2707 /// [`apply_env_overrides`] addressed to exactly one of them.
2708 ///
2709 /// [`BaseUrlEnvReceipt::Unrecorded`] is the file-owned case and keeps the
2710 /// legacy shared behavior.
2711 #[serde(skip)]
2712 pub(crate) root_base_url_owner: BaseUrlEnvReceipt,
2713 }
2714
2715 /// What the environment layer decided about the generic
2716 /// `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL` override.
2717 ///
2718 /// The distinction that matters is between "no receipt" and "a receipt saying
2719 /// nobody owns it". They are not the same state and must not collapse: a
2720 /// missing receipt is a config that never passed through the environment
2721 /// layer, while [`BaseUrlEnvReceipt::NoOwner`] is a positive statement that a
2722 /// higher-precedence layer took the endpoint away from the environment.
2723 #[derive(Debug, Clone, Default, PartialEq, Eq)]
2724 pub(crate) enum BaseUrlEnvReceipt {
2725 /// The environment layer never ran for this config — directly constructed
2726 /// configs, embedded profiles, and unit-test fixtures. These keep the
2727 /// established global fallback: the generic override applies to whatever
2728 /// route is asked about.
2729 #[default]
2730 Unrecorded,
2731 /// The environment layer ran and no route owns the generic override —
2732 /// either it was absent, or a higher-precedence file layer (a managed
2733 /// overlay) supplied/reselected the effective route's endpoint. No route,
2734 /// active or pinned, may borrow the ambient generic host.
2735 NoOwner,
2736 /// The environment layer ran and addressed the override to exactly this
2737 /// `(provider, identity)`. Only that route resolves it; every other route
2738 /// falls through to its own default.
2739 Route(ApiProvider, String),
2740 }
2741
2742 impl BaseUrlEnvReceipt {
2743 /// Whether `(provider, identity)` is the route this receipt names.
2744 fn owns(&self, provider: ApiProvider, identity: &str) -> bool {
2745 match self {
2746 Self::Route(owner, owner_identity) => *owner == provider && owner_identity == identity,
2747 Self::Unrecorded | Self::NoOwner => false,
2748 }
2749 }
2750 }
2751
2752 #[derive(Debug, Clone, Default, Deserialize)]
2753 pub struct AutoReviewConfig {
2754 #[serde(default, alias = "guidance", alias = "naturalLanguageGuidance")]
2755 pub natural_language_guidance: Option<String>,
2756 #[serde(default)]
2757 pub allow: Vec<AutoReviewRuleConfig>,
2758 #[serde(default)]
2759 pub block: Vec<AutoReviewRuleConfig>,
2760 }
2761
2762 #[derive(Debug, Clone, Default, Deserialize)]
2763 pub struct AutoReviewRuleConfig {
2764 pub id: Option<String>,
2765 #[serde(default, alias = "toolName", alias = "tool_name")]
2766 pub tool: Option<String>,
2767 #[serde(default, alias = "actionKind", alias = "action_kind")]
2768 pub action_kind: Option<String>,
2769 #[serde(default, alias = "textContains", alias = "text_contains")]
2770 pub text_contains: Option<String>,
2771 pub reason: Option<String>,
2772 }
2773
2774 impl AutoReviewConfig {
2775 fn to_runtime_policy(&self) -> crate::tui::auto_review::AutoReviewPolicy {
2776 crate::tui::auto_review::AutoReviewPolicy {
2777 allow_rules: self
2778 .allow
2779 .iter()
2780 .enumerate()
2781 .map(|(index, rule)| {
2782 rule.to_runtime_rule(index, crate::tui::auto_review::AutoReviewAction::Allow)
2783 })
2784 .collect(),
2785 block_rules: self
2786 .block
2787 .iter()
2788 .enumerate()
2789 .map(|(index, rule)| {
2790 rule.to_runtime_rule(index, crate::tui::auto_review::AutoReviewAction::Block)
2791 })
2792 .collect(),
2793 natural_language_guidance: self
2794 .natural_language_guidance
2795 .as_ref()
2796 .map(|value| value.trim().to_string())
2797 .filter(|value| !value.is_empty()),
2798 }
2799 }
2800
2801 fn validate(&self) -> Result<()> {
2802 validate_auto_review_rules("allow", &self.allow)?;
2803 validate_auto_review_rules("block", &self.block)?;
2804 Ok(())
2805 }
2806 }
2807
2808 impl AutoReviewRuleConfig {
2809 fn to_runtime_rule(
2810 &self,
2811 index: usize,
2812 action: crate::tui::auto_review::AutoReviewAction,
2813 ) -> crate::tui::auto_review::AutoReviewRule {
2814 let id_prefix = match action {
2815 crate::tui::auto_review::AutoReviewAction::Allow => "allow",
2816 crate::tui::auto_review::AutoReviewAction::Block => "block",
2817 crate::tui::auto_review::AutoReviewAction::AskUser => "ask",
2818 crate::tui::auto_review::AutoReviewAction::HoldForReview => "hold",
2819 };
2820 let id = self
2821 .id
2822 .as_deref()
2823 .map(str::trim)
2824 .filter(|value| !value.is_empty())
2825 .map(ToOwned::to_owned)
2826 .unwrap_or_else(|| format!("config-{id_prefix}-{index}"));
2827 let reason = self
2828 .reason
2829 .as_deref()
2830 .map(str::trim)
2831 .filter(|value| !value.is_empty())
2832 .map(ToOwned::to_owned)
2833 .unwrap_or_else(|| format!("configured auto-review {id_prefix} rule"));
2834 let mut rule = match action {
2835 crate::tui::auto_review::AutoReviewAction::Allow => {
2836 crate::tui::auto_review::AutoReviewRule::allow(id, reason)
2837 }
2838 crate::tui::auto_review::AutoReviewAction::Block => {
2839 crate::tui::auto_review::AutoReviewRule::block(id, reason)
2840 }
2841 crate::tui::auto_review::AutoReviewAction::AskUser
2842 | crate::tui::auto_review::AutoReviewAction::HoldForReview => {
2843 crate::tui::auto_review::AutoReviewRule::block(id, reason)
2844 }
2845 };
2846
2847 if let Some(tool) = self
2848 .tool
2849 .as_deref()
2850 .map(str::trim)
2851 .filter(|value| !value.is_empty())
2852 {
2853 rule = rule.tool_name(tool.to_string());
2854 }
2855 if let Some(action_kind) = self
2856 .action_kind
2857 .as_deref()
2858 .map(str::trim)
2859 .filter(|value| !value.is_empty())
2860 .and_then(parse_auto_review_action_kind)
2861 {
2862 rule = rule.action_kind(action_kind);
2863 }
2864 if let Some(text) = self
2865 .text_contains
2866 .as_deref()
2867 .map(str::trim)
2868 .filter(|value| !value.is_empty())
2869 {
2870 rule = rule.text_contains(text.to_string());
2871 }
2872
2873 rule
2874 }
2875
2876 fn has_matcher(&self) -> bool {
2877 self.tool
2878 .as_deref()
2879 .is_some_and(|value| !value.trim().is_empty())
2880 || self
2881 .action_kind
2882 .as_deref()
2883 .is_some_and(|value| !value.trim().is_empty())
2884 || self
2885 .text_contains
2886 .as_deref()
2887 .is_some_and(|value| !value.trim().is_empty())
2888 }
2889 }
2890
2891 fn validate_auto_review_rules(kind: &str, rules: &[AutoReviewRuleConfig]) -> Result<()> {
2892 for (index, rule) in rules.iter().enumerate() {
2893 if !rule.has_matcher() {
2894 anyhow::bail!(
2895 "Invalid auto_review.{kind}[{index}]: set at least one of tool, action_kind, or text_contains."
2896 );
2897 }
2898 if let Some(action_kind) = rule.action_kind.as_deref()
2899 && parse_auto_review_action_kind(action_kind.trim()).is_none()
2900 {
2901 anyhow::bail!(
2902 "Invalid auto_review.{kind}[{index}].action_kind '{action_kind}': expected read, write, shell, network, git, mcp_read, mcp_action, browser, secret, publish, destructive, or unknown."
2903 );
2904 }
2905 }
2906 Ok(())
2907 }
2908
2909 fn parse_auto_review_action_kind(raw: &str) -> Option<crate::tui::auto_review::ToolActionKind> {
2910 match raw.trim().to_ascii_lowercase().replace('-', "_").as_str() {
2911 "read" => Some(crate::tui::auto_review::ToolActionKind::Read),
2912 "write" => Some(crate::tui::auto_review::ToolActionKind::Write),
2913 "shell" => Some(crate::tui::auto_review::ToolActionKind::Shell),
2914 "network" => Some(crate::tui::auto_review::ToolActionKind::Network),
2915 "git" => Some(crate::tui::auto_review::ToolActionKind::Git),
2916 "mcp_read" => Some(crate::tui::auto_review::ToolActionKind::McpRead),
2917 "mcp_action" => Some(crate::tui::auto_review::ToolActionKind::McpAction),
2918 "browser" => Some(crate::tui::auto_review::ToolActionKind::Browser),
2919 "secret" => Some(crate::tui::auto_review::ToolActionKind::Secret),
2920 "publish" => Some(crate::tui::auto_review::ToolActionKind::Publish),
2921 "destructive" => Some(crate::tui::auto_review::ToolActionKind::Destructive),
2922 "unknown" => Some(crate::tui::auto_review::ToolActionKind::Unknown),
2923 _ => None,
2924 }
2925 }
2926
2927 /// How a user wants to replace or disable a built-in tool.
2928 #[derive(Debug, Clone, Deserialize)]
2929 #[serde(tag = "type", rename_all = "snake_case")]
2930 pub enum ToolOverride {
2931 /// Run a local script file. The script receives the tool's JSON input
2932 /// on stdin and must return a JSON `ToolResult` on stdout.
2933 Script {
2934 /// Path to the script (absolute, or relative to `~/.codewhale/tools/`).
2935 path: String,
2936 /// Optional static arguments prepended before the tool's JSON input.
2937 #[serde(default)]
2938 args: Option<Vec<String>>,
2939 },
2940 /// Run an external command. The command receives the tool's JSON input
2941 /// on stdin and must return a JSON `ToolResult` on stdout.
2942 Command {
2943 /// The command to run (binary name or absolute path).
2944 command: String,
2945 /// Optional static arguments prepended before the tool's JSON input.
2946 #[serde(default)]
2947 args: Option<Vec<String>>,
2948 },
2949 /// Completely disable a built-in tool. The tool will not appear in the
2950 /// model-visible catalog and cannot be called.
2951 Disabled,
2952 }
2953
2954 /// Vision model configuration for the `image_analyze` tool.
2955 /// Uses an OpenAI-compatible vision model API.
2956 #[derive(Debug, Clone, Deserialize)]
2957 pub struct VisionModelConfig {
2958 /// Model identifier (e.g., "gemini-3.1-flash-lite-preview").
2959 pub model: String,
2960 /// API key for the vision model. Inherits from main config if not specified.
2961 #[serde(default)]
2962 pub api_key: Option<String>,
2963 /// Base URL for the vision model API. Defaults to OpenAI.
2964 #[serde(default)]
2965 pub base_url: Option<String>,
2966 }
2967
2968 /// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.
2969 #[derive(Debug, Clone, Deserialize, Default)]
2970 pub struct RuntimeApiConfig {
2971 /// Additional CORS origins to allow on top of the built-in defaults
2972 /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,
2973 /// `tauri://localhost`). Useful when developing a UI against a non-default
2974 /// dev server port (e.g. Vite's default `:5173`).
2975 ///
2976 /// Resolution order (highest priority first): `--cors-origin` CLI flag,
2977 /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.
2978 #[serde(default)]
2979 pub cors_origins: Option<Vec<String>>,
2980 }
2981
2982 /// `[skills]` table — knobs for the community-skill installer.
2983 #[derive(Debug, Clone, Deserialize, Default)]
2984 pub struct SkillsConfig {
2985 /// Curated registry index. `/skill install <name>` looks up the spec here.
2986 /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].
2987 #[serde(default)]
2988 pub registry_url: Option<String>,
2989 /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed
2990 /// this limit are rejected during validation. Defaults to 5 MiB.
2991 #[serde(default)]
2992 pub max_install_size_bytes: Option<u64>,
2993 /// When true, skill discovery scans only Codewhale-owned skill roots
2994 /// (plus any explicit `skills_dir`) instead of importing compatible
2995 /// directories from other AI tools such as Claude, OpenCode, or Cursor.
2996 #[serde(default, alias = "scanCodewhaleOnly")]
2997 pub scan_codewhale_only: Option<bool>,
2998 }
2999
3000 impl SkillsConfig {
3001 /// Resolve whether session-time discovery should ignore cross-tool skill
3002 /// directories. Defaults to the compatibility-preserving broad scan.
3003 #[must_use]
3004 pub fn scan_codewhale_only(&self) -> bool {
3005 self.scan_codewhale_only.unwrap_or(false)
3006 }
3007 }
3008
3009 /// `[network]` table — mirrors `codewhale_config::NetworkPolicyToml` so the live
3010 /// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]
3011 /// without reaching into the workspace config crate. See `config.example.toml`
3012 /// for documentation.
3013 #[derive(Debug, Clone, Deserialize)]
3014 pub struct NetworkPolicyToml {
3015 /// Decision for hosts that are not in `allow` or `deny`. One of
3016 /// `"allow" | "deny" | "prompt"`. Defaults to `"prompt"`.
3017 #[serde(default = "default_network_decision")]
3018 pub default: String,
3019 /// Hosts that are always allowed. Subdomain rules: a leading dot
3020 /// (`.example.com`) matches subdomains but not the apex.
3021 #[serde(default)]
3022 pub allow: Vec<String>,
3023 /// Hosts that are always denied. Deny entries win over allow entries.
3024 #[serde(default)]
3025 pub deny: Vec<String>,
3026 /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an
3027 /// explicitly trusted proxy setup. Literal IP URLs remain blocked.
3028 #[serde(default)]
3029 pub proxy: Vec<String>,
3030 /// Explicit fake-IP placeholder CIDRs for those proxy hosts. Only subnets
3031 /// within `198.18.0.0/15` are accepted by the runtime SSRF guard.
3032 #[serde(default)]
3033 pub proxy_fake_ip_cidrs: Vec<String>,
3034 /// Whether to record one audit-log line per outbound network call.
3035 #[serde(default = "default_network_audit")]
3036 pub audit: bool,
3037 }
3038
3039 fn default_network_decision() -> String {
3040 "prompt".to_string()
3041 }
3042
3043 fn default_network_audit() -> bool {
3044 true
3045 }
3046
3047 impl Default for NetworkPolicyToml {
3048 fn default() -> Self {
3049 Self {
3050 default: default_network_decision(),
3051 allow: Vec::new(),
3052 deny: Vec::new(),
3053 proxy: Vec::new(),
3054 proxy_fake_ip_cidrs: Vec::new(),
3055 audit: default_network_audit(),
3056 }
3057 }
3058 }
3059
3060 impl NetworkPolicyToml {
3061 /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the
3062 /// on-disk schema.
3063 #[must_use]
3064 pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {
3065 crate::network_policy::NetworkPolicy {
3066 default: crate::network_policy::Decision::parse(&self.default).into(),
3067 allow: self.allow,
3068 deny: self.deny,
3069 proxy: self.proxy,
3070 proxy_fake_ip_cidrs: self.proxy_fake_ip_cidrs,
3071 audit: self.audit,
3072 }
3073 }
3074 }
3075
3076 /// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in
3077 /// `config.example.toml`. When omitted, defaults from `LspConfig::default()`
3078 /// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).
3079 #[derive(Debug, Clone, Deserialize, Default)]
3080 pub struct LspConfigToml {
3081 /// Master switch. Defaults to `true`.
3082 #[serde(default)]
3083 pub enabled: Option<bool>,
3084 /// How long to wait for the LSP server to publish diagnostics after a
3085 /// `didOpen`/`didChange`. Defaults to 5000 ms.
3086 #[serde(default)]
3087 pub poll_after_edit_ms: Option<u64>,
3088 /// Cap on diagnostics surfaced per file. Defaults to 20.
3089 #[serde(default)]
3090 pub max_diagnostics_per_file: Option<usize>,
3091 /// Whether to surface warnings in addition to errors. Defaults to `false`.
3092 #[serde(default)]
3093 pub include_warnings: Option<bool>,
3094 /// Optional override for the `Language -> [cmd, ...args]` table. Keys
3095 /// are language slugs (`"rust"`, `"go"`, etc.).
3096 #[serde(default)]
3097 pub servers: Option<HashMap<String, Vec<String>>>,
3098 /// User-defined LSP servers for file extensions not in the built-in
3099 /// registry. Keyed by extension (e.g. `"php"`, `"rb"`).
3100 #[serde(default)]
3101 pub custom: Option<HashMap<String, crate::lsp::CustomLspDef>>,
3102 }
3103
3104 impl LspConfigToml {
3105 /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,
3106 /// falling back to defaults for any unset fields.
3107 #[must_use]
3108 pub fn into_runtime(self) -> crate::lsp::LspConfig {
3109 let defaults = crate::lsp::LspConfig::default();
3110 crate::lsp::LspConfig {
3111 enabled: self.enabled.unwrap_or(defaults.enabled),
3112 poll_after_edit_ms: self
3113 .poll_after_edit_ms
3114 .unwrap_or(defaults.poll_after_edit_ms),
3115 max_diagnostics_per_file: self
3116 .max_diagnostics_per_file
3117 .unwrap_or(defaults.max_diagnostics_per_file),
3118 include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),
3119 servers: self.servers.unwrap_or_default(),
3120 custom: self.custom.unwrap_or_default(),
3121 }
3122 }
3123 }
3124
3125 #[derive(Debug, Clone, Default, Deserialize)]
3126 pub struct ProviderConfig {
3127 #[serde(alias = "apiKey")]
3128 pub api_key: Option<String>,
3129 #[serde(alias = "baseUrl")]
3130 pub base_url: Option<String>,
3131 pub model: Option<String>,
3132 #[serde(
3133 default,
3134 alias = "contextWindow",
3135 alias = "context_window_tokens",
3136 alias = "contextWindowTokens",
3137 alias = "context_length",
3138 alias = "contextLength"
3139 )]
3140 pub context_window: Option<u32>,
3141 pub mode: Option<String>,
3142 /// Dual-wire dialect toggle: `openai` (default) or `anthropic`.
3143 /// Not a separate catalog provider — config only (DeepSeek / MiniMax /
3144 /// Model Studio).
3145 #[serde(
3146 default,
3147 alias = "apiStyle",
3148 alias = "api_style",
3149 alias = "protocol",
3150 alias = "wire_format",
3151 alias = "wireFormat",
3152 alias = "dialect"
3153 )]
3154 pub wire: Option<String>,
3155 #[serde(alias = "authMode")]
3156 pub auth_mode: Option<String>,
3157 /// Validated basename of the active Codewhale-owned xAI OAuth generation.
3158 /// The file always lives below Codewhale's private credentials directory.
3159 #[serde(default, alias = "oauthCredentialGeneration")]
3160 pub oauth_credential_generation: Option<String>,
3161 #[serde(alias = "insecureSkipTlsVerify")]
3162 pub insecure_skip_tls_verify: Option<bool>,
3163 #[serde(alias = "httpHeaders")]
3164 pub http_headers: Option<HashMap<String, String>>,
3165 #[serde(alias = "pathSuffix")]
3166 pub path_suffix: Option<String>,
3167 #[serde(alias = "reasoningStyle", alias = "reasoningStreamStyle")]
3168 pub reasoning_stream_style: Option<String>,
3169 #[serde(
3170 default,
3171 alias = "max-concurrency",
3172 alias = "maxConcurrency",
3173 alias = "concurrency"
3174 )]
3175 pub max_concurrency: Option<usize>,
3176 pub auth: Option<codewhale_config::ProviderAuthSourceToml>,
3177 /// Explicit, provider-scoped consent for one credential file owned by
3178 /// another CLI. Absence is the disabled default.
3179 #[serde(default, alias = "externalCredentials")]
3180 pub external_credentials: Option<codewhale_config::ExternalCredentialConsentToml>,
3181 /// Wire-protocol selector for a custom `[providers.<name>]` entry (#1519).
3182 ///
3183 /// Only `"openai-compatible"` is accepted for now; any other value is
3184 /// rejected at selection time so unsupported wire formats fail loudly rather
3185 /// than silently routing as OpenAI. Built-in providers leave this unset.
3186 #[serde(default)]
3187 pub kind: Option<String>,
3188 /// Name of the environment variable holding this custom provider's API key
3189 /// (#1519), e.g. `api_key_env = "EXAMPLE_API_KEY"`. The key value itself is
3190 /// never stored in config; only the env var name is.
3191 #[serde(default, alias = "apiKeyEnv")]
3192 pub api_key_env: Option<String>,
3193 }
3194
3195 impl ProviderConfig {
3196 /// True when this entry selects the OpenAI-compatible custom wire protocol.
3197 ///
3198 /// `kind` is matched case-insensitively against `openai-compatible` (and the
3199 /// `openai_compatible` underscore spelling). Returns `false` when `kind` is
3200 /// unset (built-in providers) or names any other value.
3201 #[must_use]
3202 pub fn is_openai_compatible_custom(&self) -> bool {
3203 self.kind.as_deref().is_some_and(|kind| {
3204 let normalized = kind.trim().to_ascii_lowercase().replace('_', "-");
3205 normalized == "openai-compatible"
3206 })
3207 }
3208 }
3209
3210 #[derive(Debug, Clone, Default, Deserialize)]
3211 pub struct ProvidersConfig {
3212 #[serde(default)]
3213 pub deepseek: ProviderConfig,
3214 #[serde(default, alias = "deepseekCn")]
3215 pub deepseek_cn: ProviderConfig,
3216 #[serde(
3217 default,
3218 alias = "deepseek-anthropic",
3219 alias = "deepseekAnthropic",
3220 alias = "deepseek-claude",
3221 alias = "deepseek_claude"
3222 )]
3223 pub deepseek_anthropic: ProviderConfig,
3224 #[serde(default, alias = "nvidiaNim")]
3225 pub nvidia_nim: ProviderConfig,
3226 #[serde(default)]
3227 pub openai: ProviderConfig,
3228 #[serde(default)]
3229 pub atlascloud: ProviderConfig,
3230 #[serde(default, alias = "wanjieArk")]
3231 pub wanjie_ark: ProviderConfig,
3232 #[serde(default)]
3233 pub volcengine: ProviderConfig,
3234 #[serde(default)]
3235 pub openrouter: ProviderConfig,
3236 #[serde(
3237 default,
3238 alias = "xiaomi",
3239 alias = "mimo",
3240 alias = "xiaomimimo",
3241 alias = "xiaomiMimo"
3242 )]
3243 pub xiaomi_mimo: ProviderConfig,
3244 #[serde(default)]
3245 pub novita: ProviderConfig,
3246 #[serde(default)]
3247 pub fireworks: ProviderConfig,
3248 #[serde(default)]
3249 pub siliconflow: ProviderConfig,
3250 #[serde(
3251 default,
3252 alias = "siliconflow-CN",
3253 alias = "siliconflow-cn",
3254 alias = "siliconflowCn"
3255 )]
3256 pub siliconflow_cn: ProviderConfig,
3257 #[serde(default)]
3258 pub arcee: ProviderConfig,
3259 #[serde(default)]
3260 pub moonshot: ProviderConfig,
3261 #[serde(default)]
3262 pub sglang: ProviderConfig,
3263 #[serde(default)]
3264 pub vllm: ProviderConfig,
3265 #[serde(default)]
3266 pub ollama: ProviderConfig,
3267 #[serde(default, alias = "hugging-face", alias = "hf")]
3268 pub huggingface: ProviderConfig,
3269 #[serde(default, alias = "deep-infra", alias = "deep_infra")]
3270 pub deepinfra: ProviderConfig,
3271 #[serde(default, alias = "together-ai")]
3272 pub together: ProviderConfig,
3273 #[serde(
3274 default,
3275 alias = "baidu-qianfan",
3276 alias = "baidu_qianfan",
3277 alias = "baidu"
3278 )]
3279 pub qianfan: ProviderConfig,
3280 #[serde(
3281 default,
3282 alias = "openai-codex",
3283 alias = "openaiCodex",
3284 alias = "codex",
3285 alias = "chatgpt"
3286 )]
3287 pub openai_codex: ProviderConfig,
3288 #[serde(default, alias = "claude")]
3289 pub anthropic: ProviderConfig,
3290 #[serde(default, alias = "open-model", alias = "open_model")]
3291 pub openmodel: ProviderConfig,
3292 #[serde(
3293 default,
3294 alias = "zhipu",
3295 alias = "zhipuai",
3296 alias = "bigmodel",
3297 alias = "big-model"
3298 )]
3299 pub zai: ProviderConfig,
3300 #[serde(default)]
3301 pub stepfun: ProviderConfig,
3302 #[serde(default)]
3303 pub minimax: ProviderConfig,
3304 #[serde(
3305 default,
3306 alias = "minimax-anthropic",
3307 alias = "minimaxAnthropic",
3308 alias = "mini-max-anthropic",
3309 alias = "mini_max_anthropic"
3310 )]
3311 pub minimax_anthropic: ProviderConfig,
3312 #[serde(default, alias = "sakana-ai", alias = "sakana_ai", alias = "fugu")]
3313 pub sakana: ProviderConfig,
3314 #[serde(
3315 default,
3316 alias = "long-cat",
3317 alias = "meituan-longcat",
3318 alias = "meituan"
3319 )]
3320 pub longcat: ProviderConfig,
3321 #[serde(default, alias = "opencode-go", alias = "opencodego")]
3322 pub opencode_go: ProviderConfig,
3323 #[serde(
3324 default,
3325 alias = "opencode-zen",
3326 alias = "opencodezen",
3327 alias = "zen",
3328 alias = "opencode"
3329 )]
3330 pub opencode_zen: ProviderConfig,
3331 #[serde(
3332 default,
3333 alias = "meta-ai",
3334 alias = "meta_ai",
3335 alias = "meta-model-api",
3336 alias = "meta_model_api",
3337 alias = "muse",
3338 alias = "muse-spark"
3339 )]
3340 pub meta: ProviderConfig,
3341 #[serde(default, alias = "x-ai", alias = "x_ai", alias = "grok")]
3342 pub xai: ProviderConfig,
3343 #[serde(
3344 default,
3345 alias = "telecom-js",
3346 alias = "telecom_js",
3347 alias = "telecomjs-cn",
3348 alias = "tokenhub"
3349 )]
3350 pub telecomjs: ProviderConfig,
3351 /// Alibaba Cloud Model Studio — Token Plan (OpenAI-compatible Chat Completions).
3352 #[serde(default, alias = "modelstudio-token-plan")]
3353 pub modelstudio_token_plan: ProviderConfig,
3354 /// Alibaba Cloud Model Studio — Token Plan Anthropic-compatible endpoint.
3355 #[serde(default, alias = "modelstudio-token-plan-anthropic")]
3356 pub modelstudio_token_plan_anthropic: ProviderConfig,
3357 /// Alibaba Cloud Model Studio — Coding Plan (OpenAI-compatible Chat Completions).
3358 #[serde(default, alias = "modelstudio-coding-plan")]
3359 pub modelstudio_coding_plan: ProviderConfig,
3360 /// Alibaba Cloud Model Studio — Coding Plan Anthropic-compatible endpoint.
3361 #[serde(default, alias = "modelstudio-coding-plan-anthropic")]
3362 pub modelstudio_coding_plan_anthropic: ProviderConfig,
3363 /// Arbitrary user-named custom providers (#1519).
3364 ///
3365 /// Captures every `[providers.<name>]` table whose key is not one of the
3366 /// built-in providers above. Each entry is an OpenAI-compatible custom
3367 /// endpoint selected via `provider = "<name>"`; routing reads its
3368 /// `base_url` / `model` / `api_key_env` through [`ApiProvider::Custom`].
3369 #[serde(flatten, default)]
3370 pub custom: HashMap<String, ProviderConfig>,
3371 }
3372
3373 impl ProvidersConfig {
3374 /// Look up a user-defined custom provider table by its `[providers.<name>]`
3375 /// key (#1519). Returns `None` when no entry with that exact name exists.
3376 #[must_use]
3377 pub fn custom_provider_config(&self, name: &str) -> Option<&ProviderConfig> {
3378 self.custom.get(name)
3379 }
3380
3381 fn validate(&self) -> Result<()> {
3382 let builtins = [
3383 ("providers.deepseek", &self.deepseek),
3384 ("providers.deepseek_cn", &self.deepseek_cn),
3385 ("providers.deepseek_anthropic", &self.deepseek_anthropic),
3386 ("providers.nvidia_nim", &self.nvidia_nim),
3387 ("providers.openai", &self.openai),
3388 ("providers.atlascloud", &self.atlascloud),
3389 ("providers.wanjie_ark", &self.wanjie_ark),
3390 ("providers.volcengine", &self.volcengine),
3391 ("providers.openrouter", &self.openrouter),
3392 ("providers.xiaomi_mimo", &self.xiaomi_mimo),
3393 ("providers.novita", &self.novita),
3394 ("providers.fireworks", &self.fireworks),
3395 ("providers.siliconflow", &self.siliconflow),
3396 ("providers.siliconflow_cn", &self.siliconflow_cn),
3397 ("providers.arcee", &self.arcee),
3398 ("providers.moonshot", &self.moonshot),
3399 ("providers.sglang", &self.sglang),
3400 ("providers.vllm", &self.vllm),
3401 ("providers.ollama", &self.ollama),
3402 ("providers.huggingface", &self.huggingface),
3403 ("providers.deepinfra", &self.deepinfra),
3404 ("providers.together", &self.together),
3405 ("providers.qianfan", &self.qianfan),
3406 ("providers.openai_codex", &self.openai_codex),
3407 ("providers.anthropic", &self.anthropic),
3408 ("providers.openmodel", &self.openmodel),
3409 ("providers.zai", &self.zai),
3410 ("providers.stepfun", &self.stepfun),
3411 ("providers.minimax", &self.minimax),
3412 ("providers.minimax_anthropic", &self.minimax_anthropic),
3413 ("providers.sakana", &self.sakana),
3414 ("providers.opencode_go", &self.opencode_go),
3415 ("providers.opencode_zen", &self.opencode_zen),
3416 ("providers.meta", &self.meta),
3417 ("providers.xai", &self.xai),
3418 ];
3419 for (name, config) in builtins {
3420 validate_provider_context_window(name, config.context_window)?;
3421 }
3422 for (name, config) in &self.custom {
3423 validate_provider_context_window(&format!("providers.{name}"), config.context_window)?;
3424 }
3425 Ok(())
3426 }
3427 }
3428
3429 fn validate_provider_context_window(name: &str, value: Option<u32>) -> Result<()> {
3430 if value == Some(0) {
3431 anyhow::bail!("{name}.context_window must be greater than 0");
3432 }
3433 Ok(())
3434 }
3435
3436 #[derive(Debug, Clone, Deserialize, Default)]
3437 struct ConfigFile {
3438 #[serde(flatten)]
3439 base: Config,
3440 profiles: Option<HashMap<String, Config>>,
3441 }
3442
3443 #[derive(Debug, Clone, Deserialize, Default)]
3444 struct RequirementsFile {
3445 #[serde(default)]
3446 allowed_approval_policies: Vec<String>,
3447 #[serde(default)]
3448 allowed_sandbox_modes: Vec<String>,
3449 }
3450
3451 /// The highest-precedence source that can currently own approval policy.
3452 ///
3453 /// The resolved [`Config`] historically retained only the final string, which
3454 /// made an in-session editor unable to distinguish a user-owned root key from
3455 /// a profile, environment, managed, requirements, or project constraint. The
3456 /// destructive Full Access preset uses this classification to fail closed
3457 /// unless it can prove that removing the root key is the operation requested.
3458 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3459 pub(crate) enum ApprovalPolicyControl {
3460 Unset,
3461 RootConfig,
3462 Profile,
3463 Environment,
3464 ManagedConfig,
3465 ProjectConfig,
3466 Requirements,
3467 Ambiguous,
3468 }
3469
3470 impl ApprovalPolicyControl {
3471 #[must_use]
3472 pub(crate) fn editable_root(self) -> bool {
3473 matches!(self, Self::Unset | Self::RootConfig)
3474 }
3475
3476 #[must_use]
3477 pub(crate) fn label(self) -> &'static str {
3478 match self {
3479 Self::Unset => "saved TUI posture",
3480 Self::RootConfig => "the root config.toml approval_policy",
3481 Self::Profile => "the active config profile",
3482 Self::Environment => "DEEPSEEK_APPROVAL_POLICY",
3483 Self::ManagedConfig => "managed configuration",
3484 Self::ProjectConfig => "project configuration",
3485 Self::Requirements => "managed approval requirements",
3486 Self::Ambiguous => "an unresolved configuration source",
3487 }
3488 }
3489 }
3490
3491 /// Highest-precedence source that owns the interactive shell availability
3492 /// switch. Project/profile/environment/managed constraints are intentionally
3493 /// read-only from the root settings editor.
3494 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3495 pub(crate) enum ShellAccessControl {
3496 Unset,
3497 RootConfig,
3498 Profile,
3499 Environment,
3500 ManagedConfig,
3501 ProjectConfig,
3502 Ambiguous,
3503 }
3504
3505 impl ShellAccessControl {
3506 #[must_use]
3507 pub(crate) fn editable_root(self) -> bool {
3508 matches!(self, Self::Unset | Self::RootConfig)
3509 }
3510
3511 #[must_use]
3512 pub(crate) fn label(self) -> &'static str {
3513 match self {
3514 Self::Unset => "the session default",
3515 Self::RootConfig => "the root config.toml allow_shell",
3516 Self::Profile => "the active config profile",
3517 Self::Environment => "DEEPSEEK_ALLOW_SHELL",
3518 Self::ManagedConfig => "managed configuration",
3519 Self::ProjectConfig => "project configuration",
3520 Self::Ambiguous => "an unresolved configuration source",
3521 }
3522 }
3523 }
3524
3525 fn approval_policy_env_is_set() -> bool {
3526 let read = || {
3527 std::env::var_os("CODEWHALE_APPROVAL_POLICY").is_some()
3528 || std::env::var_os("DEEPSEEK_APPROVAL_POLICY").is_some()
3529 };
3530 #[cfg(test)]
3531 {
3532 crate::test_support::with_test_env_lock(read)
3533 }
3534 #[cfg(not(test))]
3535 {
3536 read()
3537 }
3538 }
3539
3540 fn allow_shell_env_is_set() -> bool {
3541 let read = || {
3542 std::env::var_os("CODEWHALE_ALLOW_SHELL").is_some()
3543 || std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_some()
3544 };
3545 #[cfg(test)]
3546 {
3547 crate::test_support::with_test_env_lock(read)
3548 }
3549 #[cfg(not(test))]
3550 {
3551 read()
3552 }
3553 }
3554
3555 fn project_config_root_bool(workspace: &Path, key: &str) -> Option<bool> {
3556 [
3557 workspace
3558 .join(codewhale_config::CODEWHALE_APP_DIR)
3559 .join("config.toml"),
3560 workspace
3561 .join(codewhale_config::LEGACY_APP_DIR)
3562 .join("config.toml"),
3563 ]
3564 .into_iter()
3565 .find(|path| path.exists())
3566 .and_then(|path| std::fs::read_to_string(path).ok())
3567 .and_then(|raw| toml::from_str::<toml::Value>(&raw).ok())
3568 .and_then(|document| document.get(key).and_then(toml::Value::as_bool))
3569 }
3570
3571 /// Map the saved TUI permission posture onto the approval-policy ordering used
3572 /// by project config. Full Access is looser than every project policy, so its
3573 /// baseline is the loosest ranked policy (`auto`).
3574 #[must_use]
3575 pub(crate) fn approval_policy_baseline_from_permission_posture(
3576 posture: Option<&str>,
3577 ) -> Option<&'static str> {
3578 posture.and_then(
3579 |posture| match posture.trim().to_ascii_lowercase().as_str() {
3580 "ask" | "suggest" | "on-request" | "untrusted" => Some("on-request"),
3581 "auto" | "auto-review" | "auto_review" => Some("auto"),
3582 "full" | "full-access" | "full_access" | "bypass" => Some("auto"),
3583 _ => None,
3584 },
3585 )
3586 }
3587
3588 // === Config Loading ===
3589
3590 impl Config {
3591 #[must_use]
3592 pub fn stop_words(&self) -> Vec<String> {
3593 self.stop_words.clone().unwrap_or_else(default_stop_words)
3594 }
3595
3596 /// Structural external-credential status for user-facing inventory. This
3597 /// resolves only environment/config strings and performs no filesystem or
3598 /// network access.
3599 pub(crate) fn external_credential_consent_status(
3600 &self,
3601 provider: ApiProvider,
3602 ) -> Option<codewhale_config::ExternalCredentialConsentStatus> {
3603 let (kind, source, path) = match provider {
3604 ApiProvider::OpenaiCodex => (
3605 codewhale_config::ProviderKind::OpenaiCodex,
3606 codewhale_config::ExternalCredentialSource::CodexCli,
3607 crate::oauth::auth_file_path(),
3608 ),
3609 ApiProvider::Xai => (
3610 codewhale_config::ProviderKind::Xai,
3611 codewhale_config::ExternalCredentialSource::GrokCli,
3612 crate::xai_oauth::auth_file_path(),
3613 ),
3614 _ => return None,
3615 };
3616 let active_kind = self
3617 .api_provider()
3618 .kind()
3619 .unwrap_or(codewhale_config::ProviderKind::Deepseek);
3620 let consent = self
3621 .provider_config_for(provider)
3622 .and_then(|entry| entry.external_credentials.as_ref());
3623 Some(codewhale_config::external_credential_consent_status(
3624 consent,
3625 kind,
3626 source,
3627 &path,
3628 active_kind,
3629 ))
3630 }
3631
3632 /// Return the non-root source that prevents an interactive runtime preset
3633 /// from safely rewriting approval, shell, and sandbox posture. Presets may
3634 /// edit user-owned root keys, but must never overwrite a profile, env,
3635 /// managed, requirements, or project constraint in the live merged Config.
3636 #[must_use]
3637 pub(crate) fn runtime_preset_blocker(
3638 &self,
3639 config_path: Option<&Path>,
3640 profile: Option<&str>,
3641 workspace: &Path,
3642 ) -> Option<&'static str> {
3643 let requirements_path = self
3644 .requirements_path
3645 .as_deref()
3646 .map(expand_path)
3647 .or_else(default_requirements_path);
3648 if let Some(path) = requirements_path
3649 && path.exists()
3650 {
3651 let controlled = std::fs::read_to_string(path)
3652 .ok()
3653 .and_then(|raw| toml::from_str::<RequirementsFile>(&raw).ok())
3654 .is_none_or(|requirements| {
3655 !requirements.allowed_approval_policies.is_empty()
3656 || !requirements.allowed_sandbox_modes.is_empty()
3657 });
3658 if controlled {
3659 return Some("managed runtime requirements");
3660 }
3661 }
3662
3663 let workspace_is_home = effective_home_dir().is_some_and(|home| {
3664 let workspace = workspace
3665 .canonicalize()
3666 .unwrap_or_else(|_| workspace.to_path_buf());
3667 let home = home.canonicalize().unwrap_or(home);
3668 workspace == home
3669 });
3670 let project_controls_runtime = || {
3671 let saved_approval_baseline = crate::settings::Settings::load_persisted()
3672 .ok()
3673 .and_then(|settings| settings.permission_posture)
3674 .and_then(|posture| {
3675 approval_policy_baseline_from_permission_posture(Some(&posture))
3676 });
3677 let approval_baseline = self.approval_policy.as_deref().or(saved_approval_baseline);
3678 let parsed_controls =
3679 codewhale_config::load_project_config(workspace).is_some_and(|project| {
3680 project.approval_policy.as_deref().is_some_and(|policy| {
3681 codewhale_config::project_approval_policy_is_allowed(
3682 approval_baseline,
3683 policy,
3684 )
3685 }) || project.sandbox_mode.as_deref().is_some_and(|sandbox| {
3686 codewhale_config::project_sandbox_mode_is_allowed(
3687 self.sandbox_mode.as_deref(),
3688 sandbox,
3689 )
3690 })
3691 });
3692 parsed_controls || project_config_root_bool(workspace, "allow_shell") == Some(false)
3693 };
3694 if !workspace_is_home && project_controls_runtime() {
3695 return Some("project runtime configuration");
3696 }
3697
3698 let managed_path = self
3699 .managed_config_path
3700 .as_deref()
3701 .map(expand_path)
3702 .or_else(default_managed_config_path);
3703 if let Some(path) = managed_path
3704 && path.exists()
3705 {
3706 match load_single_config_file(&path) {
3707 Ok(managed)
3708 if managed.approval_policy.is_some()
3709 || managed.sandbox_mode.is_some()
3710 || managed.allow_shell.is_some() =>
3711 {
3712 return Some("managed runtime configuration");
3713 }
3714 Err(_) => return Some("an unreadable managed runtime configuration"),
3715 Ok(_) => {}
3716 }
3717 }
3718
3719 if [
3720 "DEEPSEEK_APPROVAL_POLICY",
3721 "DEEPSEEK_SANDBOX_MODE",
3722 "DEEPSEEK_ALLOW_SHELL",
3723 ]
3724 .into_iter()
3725 .any(|name| std::env::var_os(name).is_some())
3726 {
3727 return Some("environment-controlled runtime posture");
3728 }
3729
3730 if let Some(profile) = profile {
3731 let path = match resolve_load_config_path(config_path.map(Path::to_path_buf)) {
3732 Ok(Some(path)) => path,
3733 Ok(None) => return Some("an unresolved active config profile"),
3734 Err(_) => return Some("an invalid active config path override"),
3735 };
3736 let Some(parsed) = std::fs::read_to_string(path)
3737 .ok()
3738 .and_then(|raw| toml::from_str::<ConfigFile>(&raw).ok())
3739 else {
3740 return Some("an unreadable active config profile");
3741 };
3742 if parsed
3743 .profiles
3744 .as_ref()
3745 .and_then(|profiles| profiles.get(profile))
3746 .is_some_and(|profile| {
3747 profile.approval_policy.is_some()
3748 || profile.sandbox_mode.is_some()
3749 || profile.allow_shell.is_some()
3750 })
3751 {
3752 return Some("the active config profile");
3753 }
3754 }
3755
3756 None
3757 }
3758
3759 /// Identify whether the effective approval policy can safely be edited by
3760 /// changing the root user config. Sources applied later in the load chain
3761 /// are deliberately treated as controlling even when their value happens
3762 /// to equal the root value; equality is not provenance.
3763 #[must_use]
3764 pub(crate) fn approval_policy_control(
3765 &self,
3766 config_path: Option<&Path>,
3767 profile: Option<&str>,
3768 workspace: &Path,
3769 ) -> ApprovalPolicyControl {
3770 if self.approval_policy_is_requirements_managed() {
3771 return ApprovalPolicyControl::Requirements;
3772 }
3773
3774 let workspace_is_home = effective_home_dir().is_some_and(|home| {
3775 let workspace = workspace
3776 .canonicalize()
3777 .unwrap_or_else(|_| workspace.to_path_buf());
3778 let home = home.canonicalize().unwrap_or(home);
3779 workspace == home
3780 });
3781 if !workspace_is_home {
3782 let saved_approval_baseline = crate::settings::Settings::load_persisted()
3783 .ok()
3784 .and_then(|settings| settings.permission_posture)
3785 .and_then(|posture| {
3786 approval_policy_baseline_from_permission_posture(Some(&posture))
3787 });
3788 let approval_baseline = self.approval_policy.as_deref().or(saved_approval_baseline);
3789 if codewhale_config::load_project_config(workspace)
3790 .and_then(|project| project.approval_policy)
3791 .is_some_and(|policy| {
3792 codewhale_config::project_approval_policy_is_allowed(approval_baseline, &policy)
3793 })
3794 {
3795 return ApprovalPolicyControl::ProjectConfig;
3796 }
3797 }
3798
3799 let managed_path = self
3800 .managed_config_path
3801 .as_deref()
3802 .map(expand_path)
3803 .or_else(default_managed_config_path);
3804 if let Some(path) = managed_path
3805 && path.exists()
3806 {
3807 match load_single_config_file(&path) {
3808 Ok(managed) if managed.approval_policy.is_some() => {
3809 return ApprovalPolicyControl::ManagedConfig;
3810 }
3811 Err(_) => return ApprovalPolicyControl::Ambiguous,
3812 Ok(_) => {}
3813 }
3814 }
3815
3816 if approval_policy_env_is_set() {
3817 return ApprovalPolicyControl::Environment;
3818 }
3819
3820 let path = match resolve_load_config_path(config_path.map(Path::to_path_buf)) {
3821 Ok(Some(path)) => path,
3822 Ok(None) | Err(_) => {
3823 return if self.approval_policy.is_some() {
3824 ApprovalPolicyControl::Ambiguous
3825 } else {
3826 ApprovalPolicyControl::Unset
3827 };
3828 }
3829 };
3830 let parsed = std::fs::read_to_string(path)
3831 .ok()
3832 .and_then(|raw| toml::from_str::<ConfigFile>(&raw).ok());
3833 let Some(parsed) = parsed else {
3834 return if self.approval_policy.is_some() {
3835 ApprovalPolicyControl::Ambiguous
3836 } else {
3837 ApprovalPolicyControl::Unset
3838 };
3839 };
3840 if let Some(profile) = profile
3841 && parsed
3842 .profiles
3843 .as_ref()
3844 .and_then(|profiles| profiles.get(profile))
3845 .is_some_and(|profile| profile.approval_policy.is_some())
3846 {
3847 return ApprovalPolicyControl::Profile;
3848 }
3849 if parsed.base.approval_policy.is_some() {
3850 ApprovalPolicyControl::RootConfig
3851 } else if self.approval_policy.is_some() {
3852 ApprovalPolicyControl::Ambiguous
3853 } else {
3854 ApprovalPolicyControl::Unset
3855 }
3856 }
3857
3858 /// Identify whether shell availability can safely be edited through the
3859 /// user-owned root config. Later sources are controlling even when their
3860 /// effective value happens to match the root value.
3861 #[must_use]
3862 pub(crate) fn allow_shell_control(
3863 &self,
3864 config_path: Option<&Path>,
3865 profile: Option<&str>,
3866 workspace: &Path,
3867 ) -> ShellAccessControl {
3868 let workspace_is_home = effective_home_dir().is_some_and(|home| {
3869 let workspace = workspace
3870 .canonicalize()
3871 .unwrap_or_else(|_| workspace.to_path_buf());
3872 let home = home.canonicalize().unwrap_or(home);
3873 workspace == home
3874 });
3875 if !workspace_is_home && project_config_root_bool(workspace, "allow_shell") == Some(false) {
3876 return ShellAccessControl::ProjectConfig;
3877 }
3878
3879 let managed_path = self
3880 .managed_config_path
3881 .as_deref()
3882 .map(expand_path)
3883 .or_else(default_managed_config_path);
3884 if let Some(path) = managed_path
3885 && path.exists()
3886 {
3887 match load_single_config_file(&path) {
3888 Ok(managed) if managed.allow_shell.is_some() => {
3889 return ShellAccessControl::ManagedConfig;
3890 }
3891 Err(_) => return ShellAccessControl::Ambiguous,
3892 Ok(_) => {}
3893 }
3894 }
3895
3896 if allow_shell_env_is_set() {
3897 return ShellAccessControl::Environment;
3898 }
3899
3900 let path = match resolve_load_config_path(config_path.map(Path::to_path_buf)) {
3901 Ok(Some(path)) => path,
3902 Ok(None) | Err(_) => {
3903 return if self.allow_shell.is_some() {
3904 ShellAccessControl::Ambiguous
3905 } else {
3906 ShellAccessControl::Unset
3907 };
3908 }
3909 };
3910 let parsed = std::fs::read_to_string(path)
3911 .ok()
3912 .and_then(|raw| toml::from_str::<ConfigFile>(&raw).ok());
3913 let Some(parsed) = parsed else {
3914 return if self.allow_shell.is_some() {
3915 ShellAccessControl::Ambiguous
3916 } else {
3917 ShellAccessControl::Unset
3918 };
3919 };
3920 if let Some(profile) = profile
3921 && parsed
3922 .profiles
3923 .as_ref()
3924 .and_then(|profiles| profiles.get(profile))
3925 .is_some_and(|profile| profile.allow_shell.is_some())
3926 {
3927 return ShellAccessControl::Profile;
3928 }
3929 if parsed.base.allow_shell.is_some() {
3930 ShellAccessControl::RootConfig
3931 } else if self.allow_shell.is_some() {
3932 ShellAccessControl::Ambiguous
3933 } else {
3934 ShellAccessControl::Unset
3935 }
3936 }
3937
3938 /// Whether an explicit config or requirements file owns approval posture.
3939 /// TUI preferences may supply a default only when this is false.
3940 #[must_use]
3941 pub fn approval_policy_is_managed(&self) -> bool {
3942 if self.approval_policy.is_some() {
3943 return true;
3944 }
3945 self.approval_policy_is_requirements_managed()
3946 }
3947
3948 /// Whether organization requirements, rather than a user-editable config
3949 /// key, own approval posture. User config still outranks TUI settings, but
3950 /// `/config approval_mode ... --save` may edit that user-owned key.
3951 #[must_use]
3952 pub fn approval_policy_is_requirements_managed(&self) -> bool {
3953 let path = self
3954 .requirements_path
3955 .as_deref()
3956 .map(expand_path)
3957 .or_else(default_requirements_path);
3958 let Some(path) = path else {
3959 return false;
3960 };
3961 if !path.exists() {
3962 return false;
3963 }
3964 // Fail closed if a present requirements file becomes unreadable or
3965 // malformed between Config::load and App::new.
3966 std::fs::read_to_string(path)
3967 .ok()
3968 .and_then(|contents| toml::from_str::<RequirementsFile>(&contents).ok())
3969 .is_none_or(|requirements| !requirements.allowed_approval_policies.is_empty())
3970 }
3971
3972 #[must_use]
3973 pub fn search_provider_resolution(&self) -> SearchProviderResolution {
3974 if let Ok(raw) = std::env::var("CODEWHALE_SEARCH_PROVIDER")
3975 .or_else(|_| std::env::var("DEEPSEEK_SEARCH_PROVIDER"))
3976 && let Some(provider) = SearchProvider::parse(&raw)
3977 {
3978 return SearchProviderResolution {
3979 provider,
3980 source: SearchProviderSource::EnvOverride,
3981 };
3982 }
3983
3984 if let Some(provider) = self.search.as_ref().and_then(|search| search.provider) {
3985 return SearchProviderResolution {
3986 provider,
3987 source: SearchProviderSource::Config,
3988 };
3989 }
3990
3991 SearchProviderResolution {
3992 provider: SearchProvider::default(),
3993 source: SearchProviderSource::Default,
3994 }
3995 }
3996
3997 #[must_use]
3998 pub fn search_provider(&self) -> SearchProvider {
3999 self.search_provider_resolution().provider
4000 }
4001
4002 /// Return `true` if the `[auto] cost_saving = true` opt-in is set
4003 /// (#1207). When true, the auto-mode router biases toward the active
4004 /// provider's validated fast sibling for ambiguous requests instead of
4005 /// its strong tier. Providers without a known sibling stay on the active
4006 /// model. Default: `false` (balanced behaviour).
4007 #[must_use]
4008 pub fn auto_cost_saving(&self) -> bool {
4009 self.auto
4010 .as_ref()
4011 .and_then(|a| a.cost_saving)
4012 .unwrap_or(false)
4013 }
4014
4015 /// Return `true` only when `[auto] cross_provider = true` is persisted in
4016 /// config (#4411). Auto mode otherwise stays on the active provider: the
4017 /// classifier never sees other providers' routes, and the local heuristic
4018 /// never selects one. There is no interactive toggle — enabling
4019 /// cross-provider Auto is an explicit, durable config edit.
4020 #[must_use]
4021 pub fn auto_cross_provider(&self) -> bool {
4022 self.auto
4023 .as_ref()
4024 .and_then(|a| a.cross_provider)
4025 .unwrap_or(false)
4026 }
4027
4028 #[must_use]
4029 pub fn tools_always_load(&self) -> std::collections::HashSet<String> {
4030 self.tools
4031 .as_ref()
4032 .map(|tools| {
4033 tools
4034 .always_load
4035 .iter()
4036 .map(|name| name.trim())
4037 .filter(|name| !name.is_empty())
4038 .map(ToOwned::to_owned)
4039 .collect()
4040 })
4041 .unwrap_or_default()
4042 }
4043
4044 #[must_use]
4045 pub fn auto_review_policy(&self) -> crate::tui::auto_review::AutoReviewPolicy {
4046 self.auto_review
4047 .as_ref()
4048 .map(AutoReviewConfig::to_runtime_policy)
4049 .unwrap_or_default()
4050 }
4051
4052 /// Load configuration from disk and merge with environment overrides.
4053 ///
4054 /// # Examples
4055 ///
4056 /// ```ignore
4057 /// # use crate::config::Config;
4058 /// let config = Config::load(None, None)?;
4059 /// # Ok::<(), anyhow::Error>(())
4060 /// ```
4061 pub fn load(path: Option<PathBuf>, profile: Option<&str>) -> Result<Self> {
4062 Self::load_with_environment_policy(path, profile, ConfigEnvironmentPolicy::Runtime)
4063 }
4064
4065 /// Load configuration for a structural diagnostic without materializing
4066 /// secret-bearing environment values into the returned configuration.
4067 ///
4068 /// This still applies the ordinary safe routing, model, and policy
4069 /// overrides so doctor describes the runtime the user selected. Provider
4070 /// credentials are resolved only inside an explicit live-probe boundary.
4071 pub(crate) fn load_structural(path: Option<PathBuf>, profile: Option<&str>) -> Result<Self> {
4072 Self::load_with_environment_policy(
4073 path,
4074 profile,
4075 ConfigEnvironmentPolicy::StructuralDiagnostic,
4076 )
4077 }
4078
4079 fn load_with_environment_policy(
4080 path: Option<PathBuf>,
4081 profile: Option<&str>,
4082 environment_policy: ConfigEnvironmentPolicy,
4083 ) -> Result<Self> {
4084 let path = resolve_load_config_path(path)?;
4085 let mut config = if let Some(path) = path.as_ref() {
4086 if path.exists() {
4087 let contents = fs::read_to_string(path)
4088 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
4089 let parsed: ConfigFile = toml::from_str(&contents).map_err(|_| {
4090 anyhow::anyhow!(
4091 "Failed to parse config file {}; file contents were omitted",
4092 codewhale_config::quote_os_path(path)
4093 )
4094 })?;
4095 if let Some(msg) = warn_on_misplaced_top_level_keys(&contents) {
4096 tracing::warn!("{msg}");
4097 }
4098 apply_profile(parsed, profile)?
4099 } else {
4100 Config::default()
4101 }
4102 } else {
4103 Config::default()
4104 };
4105
4106 apply_env_overrides(&mut config, environment_policy);
4107 apply_managed_overrides(&mut config)?;
4108 apply_requirements(&mut config)?;
4109 normalize_model_config(&mut config);
4110 config.exec_policy_engine = load_sibling_exec_policy_engine(path.as_deref())?;
4111 config.validate()?;
4112 config.warn_on_misplaced_root_base_url();
4113 Ok(config)
4114 }
4115
4116 /// Surface a one-line warning when the user has set the legacy root
4117 /// `base_url` field but their active provider does not read it. DeepSeek,
4118 /// the NvidiaNim compatibility sniff, and the literal legacy `custom`
4119 /// route are the exceptions. Common confusion: users add a top-level
4120 /// `base_url = "..."` to `~/.deepseek/config.toml` for ollama / vllm /
4121 /// named OpenAI-compatible servers and wonder why it is ignored (#1308).
4122 fn warn_on_misplaced_root_base_url(&self) {
4123 let Some(root_base) = self.base_url.as_deref().map(str::trim) else {
4124 return;
4125 };
4126 if root_base.is_empty() {
4127 return;
4128 }
4129 let provider = self.api_provider();
4130 if matches!(
4131 provider,
4132 ApiProvider::Deepseek
4133 | ApiProvider::DeepseekCN
4134 | ApiProvider::XiaomiMimo
4135 | ApiProvider::OpenaiCodex
4136 ) {
4137 return;
4138 }
4139 if matches!(provider, ApiProvider::NvidiaNim)
4140 && root_base.contains("integrate.api.nvidia.com")
4141 {
4142 return;
4143 }
4144 if provider == ApiProvider::Custom && self.uses_legacy_literal_custom_route() {
4145 return;
4146 }
4147 // Only warn if the per-provider table doesn't have an explicit
4148 // `base_url`, because if it does, the per-provider one wins and the
4149 // root field is just dead config — no behavior surprise.
4150 let has_provider_base = self
4151 .provider_config_for(provider)
4152 .and_then(|p| p.base_url.as_deref().map(str::trim))
4153 .is_some_and(|s| !s.is_empty());
4154 if has_provider_base {
4155 return;
4156 }
4157 let Ok(table) = provider_config_table_name(provider) else {
4158 return;
4159 };
4160 tracing::warn!(
4161 "Top-level `base_url = \"{root_base}\"` is ignored for the {provider:?} provider. \
4162 Move it under `[{table}]` (e.g. `[{table}]\\nbase_url = \"...\"`) \
4163 or set the corresponding `*_BASE_URL` env var. (#1308)"
4164 );
4165 }
4166
4167 /// Validate that critical config fields are present.
4168 pub fn validate(&self) -> Result<()> {
4169 if let Some(provider) = self.provider.as_deref()
4170 && ApiProvider::parse(provider).is_none()
4171 && self
4172 .providers
4173 .as_ref()
4174 .and_then(|providers| providers.custom_provider_config(provider))
4175 .is_none()
4176 {
4177 anyhow::bail!(
4178 "Invalid provider '{provider}': expected {}.",
4179 ApiProvider::names_hint()
4180 );
4181 }
4182 let active_provider = self.api_provider();
4183 match validate_kimi_code_api_model_id(
4184 active_provider,
4185 &self.deepseek_base_url(),
4186 &self.default_model(),
4187 ) {
4188 Err(error) if error == KIMI_CODE_CLAUDE_ALIAS_GUIDANCE => {
4189 return Err(SafeConfigDiagnostic::KimiCodeClaudeAlias.into());
4190 }
4191 result => result.map_err(anyhow::Error::msg)?,
4192 }
4193 if let Some(ref key) = self.api_key
4194 && key.trim().is_empty()
4195 {
4196 anyhow::bail!("api_key cannot be empty string");
4197 }
4198 if let Some(features) = &self.features {
4199 for key in features.entries.keys() {
4200 if !is_known_feature_key(key) {
4201 anyhow::bail!("Unknown feature flag: {key}");
4202 }
4203 }
4204 }
4205 // Validate the model against the *active provider's* name space, not
4206 // against DeepSeek's. `canonical_model_id_for_provider` is the
4207 // equal-treatment resolver: it applies each family's own canonical map
4208 // (GLM via Z.ai, Kimi, MiniMax, …) and passes unknown ids through, so
4209 // it rejects only what the provider genuinely cannot serve. Validating
4210 // with the DeepSeek-only `normalize_model_name` bricked every config
4211 // whose provider owns a non-DeepSeek family — including ones our own
4212 // setup wizard writes (`provider = "zai"`, `GLM-5.2`). (#4829)
4213 if let Some(model) = self.default_text_model.as_deref()
4214 && !model.trim().eq_ignore_ascii_case("auto")
4215 && !provider_passes_model_through(self.api_provider())
4216 && !self.active_provider_preserves_custom_base_url_model()
4217 && canonical_model_id_for_provider(self.api_provider(), model).is_none()
4218 {
4219 let provider = self.api_provider();
4220 let known = model_completion_names_for_provider(provider);
4221 let hint = if known.is_empty() {
4222 String::new()
4223 } else {
4224 format!(" (for example: {})", known.join(", "))
4225 };
4226 anyhow::bail!(
4227 "Invalid default_text_model '{model}' for provider '{}': expected auto or a model ID this provider serves{hint}.",
4228 provider.as_str()
4229 );
4230 }
4231 if let Some(policy) = self.approval_policy.as_deref() {
4232 let normalized = policy.trim().to_ascii_lowercase();
4233 if !matches!(
4234 normalized.as_str(),
4235 "on-request" | "untrusted" | "never" | "auto" | "suggest"
4236 ) {
4237 anyhow::bail!(
4238 "Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest."
4239 );
4240 }
4241 }
4242 if let Some(v) = self.verbosity.as_deref() {
4243 let normalized = v.trim().to_ascii_lowercase();
4244 if !matches!(normalized.as_str(), "normal" | "concise") {
4245 anyhow::bail!("Invalid verbosity '{v}': expected normal or concise.");
4246 }
4247 }
4248 if let Some(mode) = self.sandbox_mode.as_deref() {
4249 let normalized = mode.trim().to_ascii_lowercase();
4250 if !matches!(
4251 normalized.as_str(),
4252 "read-only" | "workspace-write" | "danger-full-access" | "external-sandbox"
4253 ) {
4254 anyhow::bail!(
4255 "Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox."
4256 );
4257 }
4258 }
4259 if let Some(tui) = &self.tui
4260 && let Some(mode) = tui.alternate_screen.as_deref()
4261 {
4262 let mode = mode.to_ascii_lowercase();
4263 if !matches!(mode.as_str(), "auto" | "always" | "never") {
4264 anyhow::bail!(
4265 "Invalid tui.alternate_screen '{mode}': expected auto, always, or never."
4266 );
4267 }
4268 }
4269 if let Some(auto_review) = &self.auto_review {
4270 auto_review.validate()?;
4271 }
4272 if let Some(providers) = &self.providers {
4273 providers.validate()?;
4274 }
4275 Ok(())
4276 }
4277
4278 #[must_use]
4279 pub fn api_provider(&self) -> ApiProvider {
4280 // #1519 safety fix: when `provider = "<name>"` is not a built-in provider
4281 // but names a `[providers.<name>]` custom table, route as the dynamic
4282 // custom identity. Exact configured keys win even when their spelling
4283 // collides case-insensitively with a built-in slug.
4284 if let Some(name) = self.provider.as_deref()
4285 && self
4286 .providers
4287 .as_ref()
4288 .and_then(|providers| providers.custom_provider_config(name))
4289 .is_some()
4290 {
4291 return ApiProvider::Custom;
4292 }
4293 if let Some(provider) = self.provider.as_deref().and_then(ApiProvider::parse) {
4294 return provider;
4295 }
4296 self.base_url
4297 .as_deref()
4298 .filter(|base| base.contains("integrate.api.nvidia.com"))
4299 .map(|_| ApiProvider::NvidiaNim)
4300 .or_else(|| {
4301 self.base_url
4302 .as_deref()
4303 .filter(|base| base.contains("api.deepseeki.com"))
4304 .map(|_| ApiProvider::DeepseekCN)
4305 })
4306 .unwrap_or(ApiProvider::Deepseek)
4307 }
4308
4309 /// Return the exact non-secret key for an active provider route.
4310 #[must_use]
4311 pub(crate) fn provider_identity_for(&self, provider: ApiProvider) -> String {
4312 if provider == ApiProvider::Custom
4313 && let Some(name) = self
4314 .provider
4315 .as_deref()
4316 .map(str::trim)
4317 .filter(|name| !name.is_empty())
4318 && (self
4319 .providers
4320 .as_ref()
4321 .and_then(|providers| providers.custom_provider_config(name))
4322 .is_some()
4323 || ApiProvider::parse(name).is_none())
4324 {
4325 return name.to_string();
4326 }
4327 provider.as_str().to_string()
4328 }
4329
4330 /// Resolve the currently selected live route while retaining whether the
4331 /// literal custom key came from the legacy root fields or an exact table.
4332 pub(crate) fn active_provider_identity(
4333 &self,
4334 provider: ApiProvider,
4335 ) -> std::result::Result<ProviderIdentity, String> {
4336 self.resolve_provider_identity(&self.provider_identity_for(provider))
4337 }
4338
4339 /// Resolve a persisted provider key against the current live config.
4340 ///
4341 /// Named custom providers are exact and fail closed: a removed, renamed,
4342 /// or malformed table can never fall through to DeepSeek or whichever
4343 /// provider happens to be selected now. The literal legacy value `custom`
4344 /// remains loadable only for the old root-field config shape where the live
4345 /// provider is also literally `custom` and both `base_url` and
4346 /// `default_text_model` identify one valid route.
4347 pub(crate) fn resolve_provider_identity(
4348 &self,
4349 persisted: &str,
4350 ) -> std::result::Result<ProviderIdentity, String> {
4351 let key = persisted.trim();
4352 if key.is_empty() {
4353 return Err(
4354 "saved session has an empty provider identity; choose a valid session or repair its `metadata.model_provider` field"
4355 .to_string(),
4356 );
4357 }
4358
4359 let has_exact_custom_table = self
4360 .providers
4361 .as_ref()
4362 .and_then(|providers| providers.custom_provider_config(key))
4363 .is_some();
4364
4365 if !has_exact_custom_table
4366 && let Some(provider) = ApiProvider::parse(key)
4367 && provider != ApiProvider::Custom
4368 {
4369 return Ok(ProviderIdentity {
4370 provider,
4371 key: provider.as_str().to_string(),
4372 exact_id: Some(provider.as_str().to_string()),
4373 });
4374 }
4375
4376 if !has_exact_custom_table && key.eq_ignore_ascii_case(ApiProvider::Custom.as_str()) {
4377 if self.selects_literal_custom_provider() {
4378 // The historical literal `provider = "custom"` can mean
4379 // either the legacy root-field route or an exact
4380 // `[providers.custom]` table. Prefer the table when it exists;
4381 // otherwise validate the legacy root shape. This keeps old
4382 // save/resume records deterministic without treating the
4383 // literal key as a wildcard for some other named provider.
4384 if !has_exact_custom_table {
4385 self.validate_legacy_literal_custom_route()?;
4386 return Ok(ProviderIdentity {
4387 provider: ApiProvider::Custom,
4388 key: ApiProvider::Custom.as_str().to_string(),
4389 exact_id: None,
4390 });
4391 }
4392 }
4393
4394 // Pre-exact releases persisted every named custom route as the
4395 // generic literal `custom`. Migrate that record only when the live
4396 // config selects the sole valid named custom table; otherwise the
4397 // old value is genuinely ambiguous and must fail closed.
4398 if !self.selects_literal_custom_provider() {
4399 let selected = self.provider.as_deref().map(str::trim).unwrap_or_default();
4400 let valid_named = self
4401 .providers
4402 .as_ref()
4403 .map(|providers| {
4404 providers
4405 .custom
4406 .keys()
4407 .filter(|name| {
4408 !name.eq_ignore_ascii_case(ApiProvider::Custom.as_str())
4409 && ApiProvider::parse(name).is_none()
4410 && self.resolve_provider_identity(name).is_ok()
4411 })
4412 .cloned()
4413 .collect::<Vec<_>>()
4414 })
4415 .unwrap_or_default();
4416 if let [name] = valid_named.as_slice()
4417 && selected == name
4418 {
4419 return self.resolve_provider_identity(name);
4420 }
4421 return Err(format!(
4422 "legacy session records only the generic `custom` provider kind, but the live config does not select exactly one valid named custom route (selected '{}', valid named routes: {}). Restore the original single `[providers.<name>]` route or repair the saved provider identity; Codewhale will not guess or fall back",
4423 if selected.is_empty() {
4424 "<unset>"
4425 } else {
4426 selected
4427 },
4428 valid_named.len()
4429 ));
4430 }
4431 }
4432
4433 let exact_key = key;
4434
4435 let entry = self
4436 .providers
4437 .as_ref()
4438 .and_then(|providers| providers.custom_provider_config(exact_key))
4439 .ok_or_else(|| {
4440 format!(
4441 "saved session requires custom provider '{exact_key}', but `[providers.{exact_key}]` is missing from the live config. Restore that exact table and retry; Codewhale will not fall back"
4442 )
4443 })?;
4444 if !entry.is_openai_compatible_custom() {
4445 return Err(format!(
4446 "saved session requires custom provider '{exact_key}', but `[providers.{exact_key}]` must set `kind = \"openai-compatible\"`. Fix the live config and retry; Codewhale will not fall back"
4447 ));
4448 }
4449 let base_url = entry
4450 .base_url
4451 .as_deref()
4452 .map(str::trim)
4453 .filter(|base_url| !base_url.is_empty())
4454 .ok_or_else(|| {
4455 format!(
4456 "saved session requires custom provider '{exact_key}', but `[providers.{exact_key}]` has no `base_url`. Fix the live config and retry; Codewhale will not fall back"
4457 )
4458 })?;
4459 let parsed = reqwest::Url::parse(base_url).map_err(|err| {
4460 format!(
4461 "saved session requires custom provider '{exact_key}', but `[providers.{exact_key}].base_url` is invalid: {err}. Fix the live config and retry; Codewhale will not fall back"
4462 )
4463 })?;
4464 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
4465 return Err(format!(
4466 "saved session requires custom provider '{exact_key}', but `[providers.{exact_key}].base_url` must be an http(s) URL with a host. Fix the live config and retry; Codewhale will not fall back"
4467 ));
4468 }
4469
4470 Ok(ProviderIdentity {
4471 provider: ApiProvider::Custom,
4472 key: exact_key.to_string(),
4473 exact_id: Some(exact_key.to_string()),
4474 })
4475 }
4476
4477 /// Resolve an additive exact provider id. Unlike raw selector resolution,
4478 /// this never interprets the literal id `custom` as the legacy root route:
4479 /// an id means the record requires that exact `[providers.<id>]` table.
4480 fn resolve_exact_provider_identity(
4481 &self,
4482 persisted: &str,
4483 ) -> std::result::Result<ProviderIdentity, String> {
4484 let id = persisted.trim();
4485 if id.is_empty() {
4486 return Err(
4487 "persisted provider route has an empty exact provider id; Codewhale will not guess or fall back"
4488 .to_string(),
4489 );
4490 }
4491 let has_exact_custom_table = self
4492 .providers
4493 .as_ref()
4494 .and_then(|providers| providers.custom_provider_config(id))
4495 .is_some();
4496 if id.eq_ignore_ascii_case(ApiProvider::Custom.as_str()) && !has_exact_custom_table {
4497 return Err(format!(
4498 "persisted provider route requires exact custom provider '{id}', but `[providers.{id}]` is missing from the live config. Restore that exact table and retry; Codewhale will not fall back"
4499 ));
4500 }
4501
4502 let identity = self.resolve_provider_identity(id)?;
4503 if identity.provider == ApiProvider::Custom && identity.persisted_id() != Some(id) {
4504 return Err(format!(
4505 "persisted provider route requires exact custom provider '{id}', but the live config only provides the legacy root-level custom route. Restore `[providers.{id}]` and retry; Codewhale will not fall back"
4506 ));
4507 }
4508 Ok(identity)
4509 }
4510
4511 /// Resolve the two-field provider route written by current session/thread
4512 /// schemas without erasing which field supplied the identity.
4513 ///
4514 /// `provider_kind` is the generic wire/provider class (`custom` for every
4515 /// named OpenAI-compatible endpoint); `provider_id` is the additive exact
4516 /// configured key. Older records have no id and may have overloaded the
4517 /// kind field with an exact custom name. Keeping those cases distinct is
4518 /// security-sensitive: a legacy built-in record must never be captured by
4519 /// a later same-key custom table, while a current `custom` + exact-id pair
4520 /// must retain that user-owned table identity.
4521 pub(crate) fn resolve_persisted_provider_identity(
4522 &self,
4523 provider_kind: Option<&str>,
4524 provider_id: Option<&str>,
4525 ) -> std::result::Result<ProviderIdentity, String> {
4526 let kind = provider_kind
4527 .map(str::trim)
4528 .filter(|value| !value.is_empty());
4529 // Missing and malformed are different security states. An explicitly
4530 // persisted empty id must reach `resolve_exact_provider_identity` so
4531 // it fails closed instead of being reinterpreted as an id-less legacy
4532 // root route.
4533 let id = provider_id.map(str::trim);
4534
4535 let Some(kind) = kind else {
4536 return id.map_or_else(
4537 || {
4538 Err(
4539 "persisted provider route has neither a provider kind nor an exact provider id; Codewhale will not guess or fall back"
4540 .to_string(),
4541 )
4542 },
4543 |id| self.resolve_exact_provider_identity(id),
4544 );
4545 };
4546
4547 let Some(provider) = ApiProvider::parse(kind) else {
4548 // Pre-additive releases sometimes wrote an exact named custom key
4549 // into `model_provider`. Preserve that shape, but reject a
4550 // contradictory additive id instead of silently choosing one.
4551 if let Some(id) = id
4552 && id != kind
4553 {
4554 return Err(format!(
4555 "persisted provider route has legacy identity '{kind}' but exact provider id '{id}'; repair the mismatched fields because Codewhale will not guess or fall back"
4556 ));
4557 }
4558 return match id {
4559 Some(id) => self.resolve_exact_provider_identity(id),
4560 None => self.resolve_provider_identity(kind),
4561 };
4562 };
4563
4564 if provider == ApiProvider::Custom {
4565 if let Some(id) = id {
4566 let identity = self.resolve_exact_provider_identity(id)?;
4567 if identity.provider != ApiProvider::Custom {
4568 return Err(format!(
4569 "persisted provider route declares generic kind 'custom' but exact provider id '{id}' resolves as built-in '{}'; use the matching built-in kind or restore `[providers.{id}]`. Codewhale will not guess or fall back",
4570 identity.provider.as_str()
4571 ));
4572 }
4573 return Ok(identity);
4574 }
4575
4576 // The absence of the additive id is itself provenance. Released
4577 // id-less `custom` records belong to the root-level route only;
4578 // they must not be captured by a table added under the same key.
4579 self.validate_legacy_literal_custom_root_route()?;
4580 return Ok(ProviderIdentity {
4581 provider: ApiProvider::Custom,
4582 key: ApiProvider::Custom.as_str().to_string(),
4583 exact_id: None,
4584 });
4585 }
4586
4587 if let Some(id) = id
4588 && ApiProvider::parse(id) != Some(provider)
4589 {
4590 return Err(format!(
4591 "persisted provider route declares built-in kind '{}' but exact provider id '{id}' names a different route; repair the mismatched fields because Codewhale will not guess or fall back",
4592 provider.as_str()
4593 ));
4594 }
4595
4596 // Exact custom keys normally win raw string resolution. A persisted
4597 // built-in kind is stronger evidence than that raw key, but Config's
4598 // single selector cannot represent both routes simultaneously. Fail
4599 // closed instead of constructing a descriptor whose client would read
4600 // credentials/settings from the shadowing custom table.
4601 if self
4602 .providers
4603 .as_ref()
4604 .and_then(|providers| providers.custom_provider_config(provider.as_str()))
4605 .is_some()
4606 {
4607 return Err(format!(
4608 "persisted provider route requires built-in '{}', but an exact `[providers.{}]` custom route shadows the same selector. Rename the custom route or update the saved provider kind/id pair; Codewhale will not guess or fall back",
4609 provider.as_str(),
4610 provider.as_str()
4611 ));
4612 }
4613
4614 Ok(ProviderIdentity {
4615 provider,
4616 key: provider.as_str().to_string(),
4617 exact_id: Some(provider.as_str().to_string()),
4618 })
4619 }
4620
4621 /// Scope a cloned runtime config to one already-resolved identity. This is
4622 /// required only for the root-literal custom route: when a later
4623 /// `[providers.custom]` table coexists, ordinary selector lookup would
4624 /// otherwise capture the table. Removing it from the scoped clone keeps
4625 /// the root endpoint authoritative without mutating the live registry.
4626 pub(crate) fn scope_to_provider_identity(&mut self, identity: &ProviderIdentity) {
4627 self.provider = Some(identity.key.clone());
4628 if identity.provider == ApiProvider::Custom
4629 && identity.persisted_id().is_none()
4630 && let Some(providers) = self.providers.as_mut()
4631 {
4632 providers.custom.retain(|name, _| {
4633 !name
4634 .trim()
4635 .eq_ignore_ascii_case(ApiProvider::Custom.as_str())
4636 });
4637 }
4638 }
4639
4640 fn validate_legacy_literal_custom_route(&self) -> std::result::Result<(), String> {
4641 if self.has_literal_custom_provider_table() {
4642 return Err(
4643 "legacy `provider = \"custom\"` is ambiguous because `[providers.custom]` is also present. Move the route to one named `[providers.<name>]` table and update the saved provider identity; Codewhale will not guess or fall back"
4644 .to_string(),
4645 );
4646 }
4647
4648 self.validate_legacy_literal_custom_root_route()
4649 }
4650
4651 fn validate_legacy_literal_custom_root_route(&self) -> std::result::Result<(), String> {
4652 let selected = self.provider.as_deref().map(str::trim).unwrap_or_default();
4653 if !self.selects_literal_custom_provider() {
4654 return Err(format!(
4655 "legacy session records only the generic `custom` provider kind, but the live config selects '{}'. Only an unchanged legacy config with `provider = \"custom\"` and root-level `base_url`/`default_text_model` can load this session; Codewhale will not guess or fall back",
4656 if selected.is_empty() {
4657 "<unset>"
4658 } else {
4659 selected
4660 }
4661 ));
4662 }
4663
4664 let base_url = self
4665 .base_url
4666 .as_deref()
4667 .map(str::trim)
4668 .filter(|base_url| !base_url.is_empty())
4669 .ok_or_else(|| {
4670 "legacy `provider = \"custom\"` requires a non-empty root-level `base_url` to load a saved session; Codewhale will not use the custom-provider placeholder or fall back"
4671 .to_string()
4672 })?;
4673 let parsed = reqwest::Url::parse(base_url).map_err(|err| {
4674 format!(
4675 "legacy `provider = \"custom\"` has an invalid root-level `base_url`: {err}. Fix the live config and retry; Codewhale will not fall back"
4676 )
4677 })?;
4678 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
4679 return Err(
4680 "legacy `provider = \"custom\"` requires a root-level `base_url` with an http(s) scheme and host; Codewhale will not fall back"
4681 .to_string(),
4682 );
4683 }
4684
4685 let model = self
4686 .default_text_model
4687 .as_deref()
4688 .map(str::trim)
4689 .filter(|model| !model.is_empty())
4690 .ok_or_else(|| {
4691 "legacy `provider = \"custom\"` requires a non-empty root-level `default_text_model` to load a saved session; Codewhale will not guess or fall back"
4692 .to_string()
4693 })?;
4694 if model.eq_ignore_ascii_case("auto") || normalize_custom_model_id(model).is_none() {
4695 return Err(
4696 "legacy `provider = \"custom\"` requires one explicit, valid root-level `default_text_model` (not `auto`) to load a saved session; Codewhale will not guess or fall back"
4697 .to_string(),
4698 );
4699 }
4700
4701 Ok(())
4702 }
4703
4704 fn selects_literal_custom_provider(&self) -> bool {
4705 self.provider
4706 .as_deref()
4707 .map(str::trim)
4708 .is_some_and(|name| name.eq_ignore_ascii_case(ApiProvider::Custom.as_str()))
4709 }
4710
4711 fn has_literal_custom_provider_table(&self) -> bool {
4712 self.providers.as_ref().is_some_and(|providers| {
4713 providers.custom.keys().any(|name| {
4714 name.trim()
4715 .eq_ignore_ascii_case(ApiProvider::Custom.as_str())
4716 })
4717 })
4718 }
4719
4720 pub(crate) fn uses_legacy_literal_custom_route(&self) -> bool {
4721 self.selects_literal_custom_provider() && !self.has_literal_custom_provider_table()
4722 }
4723
4724 /// Whether `identity` names a custom route that this config can resolve.
4725 ///
4726 /// Either an exact `[providers.<name>]` custom table, or the legacy
4727 /// root-field literal `custom` route. Anything else — an empty key, a
4728 /// removed table, a built-in provider name — is an unresolvable custom
4729 /// identity and endpoint resolution must fail closed on it.
4730 ///
4731 /// The predicate that pins that contract for the regression suite; the
4732 /// resolver itself fails closed without consulting it.
4733 #[cfg(test)]
4734 pub(crate) fn custom_identity_is_resolvable(&self, identity: &str) -> bool {
4735 self.custom_provider_entry_for_identity(identity).is_some()
4736 || (identity_is_literal_custom(identity) && self.uses_legacy_literal_custom_route())
4737 }
4738
4739 pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {
4740 let providers = self.providers.as_ref()?;
4741 // The custom provider's config lives in the flatten map, keyed by the
4742 // selected `provider = "<name>"` value, not in a fixed field (#1519).
4743 // Resolve it by name so every existing reader (auth, headers, base_url)
4744 // transparently sees the named table.
4745 if provider == ApiProvider::Custom {
4746 return self
4747 .provider
4748 .as_deref()
4749 .and_then(|name| providers.custom_provider_config(name));
4750 }
4751 Some(match provider {
4752 ApiProvider::Deepseek => &providers.deepseek,
4753 ApiProvider::DeepseekCN => &providers.deepseek_cn,
4754 ApiProvider::DeepseekAnthropic => &providers.deepseek_anthropic,
4755 ApiProvider::NvidiaNim => &providers.nvidia_nim,
4756 ApiProvider::Openai => &providers.openai,
4757 ApiProvider::Atlascloud => &providers.atlascloud,
4758 ApiProvider::WanjieArk => &providers.wanjie_ark,
4759 ApiProvider::Openrouter => &providers.openrouter,
4760 ApiProvider::XiaomiMimo => &providers.xiaomi_mimo,
4761 ApiProvider::Novita => &providers.novita,
4762 ApiProvider::Fireworks => &providers.fireworks,
4763 ApiProvider::Siliconflow => &providers.siliconflow,
4764 ApiProvider::SiliconflowCn => &providers.siliconflow_cn,
4765 ApiProvider::Arcee => &providers.arcee,
4766 ApiProvider::Moonshot => &providers.moonshot,
4767 ApiProvider::Sglang => &providers.sglang,
4768 ApiProvider::Vllm => &providers.vllm,
4769 ApiProvider::Ollama => &providers.ollama,
4770 ApiProvider::Volcengine => &providers.volcengine,
4771 ApiProvider::Huggingface => &providers.huggingface,
4772 ApiProvider::Deepinfra => &providers.deepinfra,
4773 ApiProvider::Together => &providers.together,
4774 ApiProvider::Qianfan => &providers.qianfan,
4775 ApiProvider::OpenaiCodex => &providers.openai_codex,
4776 ApiProvider::Anthropic => &providers.anthropic,
4777 ApiProvider::Openmodel => &providers.openmodel,
4778 ApiProvider::Zai => &providers.zai,
4779 ApiProvider::Stepfun => &providers.stepfun,
4780 ApiProvider::Minimax => &providers.minimax,
4781 ApiProvider::MinimaxAnthropic => &providers.minimax_anthropic,
4782 ApiProvider::Sakana => &providers.sakana,
4783 ApiProvider::LongCat => &providers.longcat,
4784 ApiProvider::OpencodeGo => &providers.opencode_go,
4785 ApiProvider::OpencodeZen => &providers.opencode_zen,
4786 ApiProvider::Meta => &providers.meta,
4787 ApiProvider::Xai => &providers.xai,
4788 ApiProvider::Telecomjs => &providers.telecomjs,
4789 ApiProvider::ModelstudioTokenPlan => &providers.modelstudio_token_plan,
4790 ApiProvider::ModelstudioTokenPlanAnthropic => {
4791 &providers.modelstudio_token_plan_anthropic
4792 }
4793 ApiProvider::ModelstudioCodingPlan => &providers.modelstudio_coding_plan,
4794 ApiProvider::ModelstudioCodingPlanAnthropic => {
4795 &providers.modelstudio_coding_plan_anthropic
4796 }
4797 // Handled by the name-keyed early return above (#1519).
4798 ApiProvider::Custom => unreachable!("custom provider resolved by name above"),
4799 })
4800 }
4801
4802 pub(crate) fn subagent_provider_config(
4803 &self,
4804 provider: ApiProvider,
4805 ) -> Option<&SubagentProviderConfig> {
4806 let providers = self.subagents.as_ref()?.providers.as_ref()?;
4807 providers.iter().find_map(|(key, config)| {
4808 subagent_provider_key_matches(key, provider).then_some(config)
4809 })
4810 }
4811
4812 pub(crate) fn provider_config_for_mut(&mut self, provider: ApiProvider) -> &mut ProviderConfig {
4813 // The custom provider's mutable slot is keyed by the selected
4814 // `provider = "<name>"` value in the flatten map (#1519). Capture the
4815 // name before borrowing `providers` mutably; fall back to a private
4816 // sentinel key so the accessor stays total when no name is set.
4817 let custom_key = (provider == ApiProvider::Custom).then(|| {
4818 self.provider
4819 .clone()
4820 .unwrap_or_else(|| "__custom__".to_string())
4821 });
4822 let providers = self.providers.get_or_insert_with(ProvidersConfig::default);
4823 if let Some(key) = custom_key {
4824 return providers.custom.entry(key).or_default();
4825 }
4826 match provider {
4827 ApiProvider::Deepseek => &mut providers.deepseek,
4828 ApiProvider::DeepseekCN => &mut providers.deepseek_cn,
4829 ApiProvider::DeepseekAnthropic => &mut providers.deepseek_anthropic,
4830 ApiProvider::NvidiaNim => &mut providers.nvidia_nim,
4831 ApiProvider::Openai => &mut providers.openai,
4832 ApiProvider::Atlascloud => &mut providers.atlascloud,
4833 ApiProvider::WanjieArk => &mut providers.wanjie_ark,
4834 ApiProvider::Openrouter => &mut providers.openrouter,
4835 ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo,
4836 ApiProvider::Novita => &mut providers.novita,
4837 ApiProvider::Fireworks => &mut providers.fireworks,
4838 ApiProvider::Siliconflow => &mut providers.siliconflow,
4839 ApiProvider::SiliconflowCn => &mut providers.siliconflow_cn,
4840 ApiProvider::Arcee => &mut providers.arcee,
4841 ApiProvider::Moonshot => &mut providers.moonshot,
4842 ApiProvider::Sglang => &mut providers.sglang,
4843 ApiProvider::Vllm => &mut providers.vllm,
4844 ApiProvider::Ollama => &mut providers.ollama,
4845 ApiProvider::Volcengine => &mut providers.volcengine,
4846 ApiProvider::Huggingface => &mut providers.huggingface,
4847 ApiProvider::Deepinfra => &mut providers.deepinfra,
4848 ApiProvider::Together => &mut providers.together,
4849 ApiProvider::Qianfan => &mut providers.qianfan,
4850 ApiProvider::OpenaiCodex => &mut providers.openai_codex,
4851 ApiProvider::Anthropic => &mut providers.anthropic,
4852 ApiProvider::Openmodel => &mut providers.openmodel,
4853 ApiProvider::Zai => &mut providers.zai,
4854 ApiProvider::Stepfun => &mut providers.stepfun,
4855 ApiProvider::Minimax => &mut providers.minimax,
4856 ApiProvider::MinimaxAnthropic => &mut providers.minimax_anthropic,
4857 ApiProvider::Sakana => &mut providers.sakana,
4858 ApiProvider::LongCat => &mut providers.longcat,
4859 ApiProvider::OpencodeGo => &mut providers.opencode_go,
4860 ApiProvider::OpencodeZen => &mut providers.opencode_zen,
4861 ApiProvider::Meta => &mut providers.meta,
4862 ApiProvider::Xai => &mut providers.xai,
4863 ApiProvider::Telecomjs => &mut providers.telecomjs,
4864 ApiProvider::ModelstudioTokenPlan => &mut providers.modelstudio_token_plan,
4865 ApiProvider::ModelstudioTokenPlanAnthropic => {
4866 &mut providers.modelstudio_token_plan_anthropic
4867 }
4868 ApiProvider::ModelstudioCodingPlan => &mut providers.modelstudio_coding_plan,
4869 ApiProvider::ModelstudioCodingPlanAnthropic => {
4870 &mut providers.modelstudio_coding_plan_anthropic
4871 }
4872 // Handled by the name-keyed early return above (#1519).
4873 ApiProvider::Custom => unreachable!("custom provider resolved by name above"),
4874 }
4875 }
4876
4877 /// Apply a runtime model override without migrating a released
4878 /// root-literal custom route into an ambiguous `[providers.custom]` table.
4879 pub(crate) fn set_provider_model_override(
4880 &mut self,
4881 provider: ApiProvider,
4882 model: Option<String>,
4883 ) {
4884 if provider == ApiProvider::Custom && self.uses_legacy_literal_custom_route() {
4885 self.default_text_model = model;
4886 } else {
4887 self.provider_config_for_mut(provider).model = model;
4888 }
4889 }
4890
4891 /// Apply a runtime endpoint override while preserving the storage shape of
4892 /// a released root-literal custom route.
4893 pub(crate) fn set_provider_base_url_override(
4894 &mut self,
4895 provider: ApiProvider,
4896 base_url: Option<String>,
4897 ) {
4898 if provider == ApiProvider::Custom && self.uses_legacy_literal_custom_route() {
4899 self.base_url = base_url;
4900 } else {
4901 self.provider_config_for_mut(provider).base_url = base_url;
4902 }
4903 }
4904
4905 /// Apply an in-memory credential update without creating a named custom
4906 /// table for the legacy root-literal route.
4907 pub(crate) fn set_provider_api_key_override(
4908 &mut self,
4909 provider: ApiProvider,
4910 api_key: Option<String>,
4911 ) {
4912 if provider == ApiProvider::Custom && self.uses_legacy_literal_custom_route() {
4913 self.api_key = api_key;
4914 } else {
4915 self.provider_config_for_mut(provider).api_key = api_key;
4916 }
4917 }
4918
4919 /// Mirror a successful native xAI login into the live route config.
4920 /// Codewhale-owned OAuth storage supersedes any dormant Grok CLI consent.
4921 pub(crate) fn mark_codewhale_owned_xai_oauth(&mut self, generation: String) {
4922 let entry = self.provider_config_for_mut(ApiProvider::Xai);
4923 entry.auth_mode = Some("oauth".to_string());
4924 entry.oauth_credential_generation = Some(generation);
4925 entry.external_credentials = None;
4926 }
4927
4928 /// Refresh only model-provider route material from a newly loaded disk
4929 /// snapshot. The receiver is the already-effective interactive Config,
4930 /// including CLI feature toggles and workspace/project permission overlays;
4931 /// replacing it wholesale during `/load` could silently loosen those
4932 /// controls. Provider tables carry their endpoint, auth, headers, TLS,
4933 /// model-passthrough, and per-route limits as one atomic registry.
4934 pub(crate) fn refresh_provider_routes_from(&mut self, fresh: &Self) {
4935 self.provider.clone_from(&fresh.provider);
4936 self.api_key.clone_from(&fresh.api_key);
4937 self.base_url.clone_from(&fresh.base_url);
4938 self.http_headers.clone_from(&fresh.http_headers);
4939 self.default_text_model
4940 .clone_from(&fresh.default_text_model);
4941 self.auth_mode.clone_from(&fresh.auth_mode);
4942 self.fallback_providers
4943 .clone_from(&fresh.fallback_providers);
4944 self.retry.clone_from(&fresh.retry);
4945 self.providers.clone_from(&fresh.providers);
4946 self.base_url_env_receipt
4947 .clone_from(&fresh.base_url_env_receipt);
4948 self.root_base_url_owner
4949 .clone_from(&fresh.root_base_url_owner);
4950 self.reasoning_effort_inferred_from_legacy_alias =
4951 fresh.reasoning_effort_inferred_from_legacy_alias;
4952 self.migrated_deepseek_model_alias
4953 .clone_from(&fresh.migrated_deepseek_model_alias);
4954 }
4955
4956 /// Return the configured provider request concurrency cap.
4957 ///
4958 /// `None` means the client does not apply an extra in-flight request
4959 /// semaphore. Z.ai/GLM gets a conservative default because its SSE endpoint
4960 /// times out under sustained parallel stream opens well below the advertised
4961 /// service concurrency (#3496). Operators can raise it with
4962 /// `[providers.zai] max_concurrency = N`; `0` explicitly disables the
4963 /// client-side cap for that provider.
4964 #[must_use]
4965 pub fn provider_max_concurrency(&self, provider: ApiProvider) -> Option<usize> {
4966 let configured = self
4967 .provider_config_for(provider)
4968 .and_then(|entry| entry.max_concurrency);
4969 match configured {
4970 Some(0) => None,
4971 Some(limit) => Some(limit.clamp(1, MAX_PROVIDER_REQUEST_CONCURRENCY)),
4972 None if provider == ApiProvider::Zai => Some(DEFAULT_ZAI_PROVIDER_MAX_CONCURRENCY),
4973 None => None,
4974 }
4975 }
4976
4977 pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {
4978 self.provider_config_for(self.api_provider())
4979 }
4980
4981 fn provider_config_string_with_runtime_fallback<F>(
4982 &self,
4983 provider: ApiProvider,
4984 get: F,
4985 ) -> Option<String>
4986 where
4987 F: Fn(&ProviderConfig) -> Option<String>,
4988 {
4989 if let Some(value) = self.provider_config_for(provider).and_then(&get) {
4990 return Some(value);
4991 }
4992 if provider == ApiProvider::SiliconflowCn {
4993 return self
4994 .provider_config_for(ApiProvider::Siliconflow)
4995 .and_then(get);
4996 }
4997 None
4998 }
4999
5000 #[must_use]
5001 pub fn insecure_skip_tls_verify(&self) -> bool {
5002 self.provider_config()
5003 .and_then(|provider| provider.insecure_skip_tls_verify)
5004 .unwrap_or(false)
5005 }
5006
5007 #[must_use]
5008 pub(crate) fn context_window_for_provider_config(&self, provider: ApiProvider) -> Option<u32> {
5009 if let Some(window) = self
5010 .provider_config_for(provider)
5011 .and_then(|entry| entry.context_window)
5012 .filter(|window| *window > 0)
5013 {
5014 return Some(window);
5015 }
5016 if provider == ApiProvider::SiliconflowCn {
5017 return self
5018 .provider_config_for(ApiProvider::Siliconflow)
5019 .and_then(|entry| entry.context_window)
5020 .filter(|window| *window > 0);
5021 }
5022 None
5023 }
5024
5025 #[must_use]
5026 pub fn http_headers(&self) -> HashMap<String, String> {
5027 let provider = self.api_provider();
5028 let mut headers = self.http_headers.clone().unwrap_or_default();
5029 if let Some(provider_headers) = self
5030 .provider_config_for(provider)
5031 .and_then(|provider| provider.http_headers.as_ref())
5032 {
5033 headers.extend(provider_headers.clone());
5034 }
5035 headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
5036 if auth_mode_disables_api_key(self.auth_mode_for_provider(provider).as_deref()) {
5037 headers.retain(|name, _| !codewhale_config::is_upstream_auth_header(name));
5038 }
5039 headers
5040 }
5041
5042 fn active_configured_model_id(&self) -> Option<&str> {
5043 self.provider_config_for(self.api_provider())
5044 .and_then(|entry| entry.model.as_deref())
5045 .map(str::trim)
5046 .filter(|model| !model.is_empty())
5047 .or_else(|| {
5048 self.default_text_model
5049 .as_deref()
5050 .map(str::trim)
5051 .filter(|model| !model.is_empty())
5052 })
5053 }
5054
5055 /// Describe a first-party DeepSeek alias that was migrated for the active
5056 /// route. Custom endpoints retain ownership of the same model strings and
5057 /// must not receive DeepSeek's deprecation claim.
5058 pub(crate) fn active_deepseek_alias_deprecation(&self) -> Option<ModelAliasDeprecation> {
5059 let provider = self.api_provider();
5060 if !matches!(
5061 provider,
5062 ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic
5063 ) {
5064 return None;
5065 }
5066
5067 let alias = self
5068 .migrated_deepseek_model_alias
5069 .as_deref()
5070 .or_else(|| self.active_configured_model_id())?
5071 .trim()
5072 .to_ascii_lowercase();
5073 let base_url = self.deepseek_base_url();
5074 if wire_model_for_provider_route(provider, &base_url, &alias) == alias {
5075 return None;
5076 }
5077
5078 deepseek_alias_deprecation(&alias)
5079 }
5080
5081 #[must_use]
5082 pub fn default_model(&self) -> String {
5083 let provider = self.api_provider();
5084 if let Some(model) =
5085 self.provider_config_string_with_runtime_fallback(provider, |entry| entry.model.clone())
5086 {
5087 let model = model.trim();
5088 if provider_passes_model_through(provider)
5089 || self.active_provider_preserves_custom_base_url_model()
5090 {
5091 return model.to_string();
5092 }
5093 if let Some(normalized) = normalize_model_for_provider(provider, model) {
5094 return normalized;
5095 }
5096 // An explicit provider-scoped model that is not a recognized
5097 // DeepSeek alias is a deliberate custom choice for a non-DeepSeek
5098 // provider (e.g. `MiniMax-M2.7` on an OpenAI-compatible endpoint).
5099 // It must pass through verbatim rather than fall back to a
5100 // DeepSeek/provider default (issue #1714).
5101 if !matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
5102 && !model.is_empty()
5103 {
5104 return model.to_string();
5105 }
5106 }
5107 let moonshot_config = (provider == ApiProvider::Moonshot)
5108 .then(|| self.provider_config())
5109 .flatten();
5110 let moonshot_uses_kimi_code = moonshot_config.is_some_and(|config| {
5111 provider_config_uses_kimi_imported_token(config)
5112 || config
5113 .base_url
5114 .as_deref()
5115 .is_some_and(moonshot_base_url_uses_kimi_code)
5116 });
5117 if moonshot_uses_kimi_code {
5118 return DEFAULT_KIMI_CODE_MODEL.to_string();
5119 }
5120 if let Some(model) = self.default_text_model.as_deref()
5121 && model.trim().eq_ignore_ascii_case("auto")
5122 {
5123 return "auto".to_string();
5124 }
5125 // A root DeepSeek-family default must not leak onto a vendor-locked
5126 // official endpoint that can never serve it (the provider then
5127 // rejects every request, e.g. `deepseek-v4-pro` on api.x.ai). Custom
5128 // base URLs keep full pass-through: a compatible proxy may
5129 // legitimately serve any model id.
5130 let foreign_root_default = |model: &str| {
5131 !self.active_provider_preserves_custom_base_url_model()
5132 && matches!(
5133 provider,
5134 ApiProvider::Xai | ApiProvider::Openai | ApiProvider::Moonshot
5135 )
5136 && normalize_model_name(model).is_some()
5137 };
5138 // Xiaomi MiMo: honour a root `default_text_model` that names a MiMo id
5139 // (canonical aliases or a custom account id). Do not silently drop it
5140 // for the provider seed default.
5141 if provider == ApiProvider::XiaomiMimo
5142 && let Some(model) = self.default_text_model.as_deref()
5143 {
5144 if let Some(canonical) = canonical_xiaomi_mimo_model_id(model) {
5145 return canonical.to_string();
5146 }
5147 // Non-empty root value that is not a known foreign DeepSeek id is
5148 // a deliberate custom MiMo choice — apply it. A stale DeepSeek id
5149 // still falls through to the provider default below rather than
5150 // being forwarded to Xiaomi's endpoint.
5151 let trimmed = model.trim();
5152 if !trimmed.is_empty() && normalize_model_name(trimmed).is_none() {
5153 return trimmed.to_string();
5154 }
5155 }
5156 if let Some(model) = self.default_text_model.as_deref()
5157 && (provider_passes_model_through(provider)
5158 || self.active_provider_preserves_custom_base_url_model())
5159 && !foreign_root_default(model)
5160 // Xiaomi was handled above so a stale DeepSeek root id does not
5161 // pass through merely because the provider is pass-through.
5162 && provider != ApiProvider::XiaomiMimo
5163 {
5164 return model.trim().to_string();
5165 }
5166 if let Some(model) = self.default_text_model.as_deref()
5167 && provider != ApiProvider::XiaomiMimo
5168 && !root_deepseek_model_is_foreign_to_direct_provider(provider, model)
5169 && let Some(normalized) = normalize_model_name_for_provider(provider, model)
5170 // A wire-slug translation (e.g. the Moonshot map) resolves the
5171 // foreign default to a native model; an identity result does not.
5172 && (!foreign_root_default(model) || !normalized.eq_ignore_ascii_case(model.trim()))
5173 {
5174 return normalized;
5175 }
5176
5177 match provider {
5178 ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,
5179 ApiProvider::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_MODEL,
5180 ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
5181 ApiProvider::Openai => DEFAULT_OPENAI_MODEL,
5182 ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
5183 ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
5184 ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,
5185 ApiProvider::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
5186 ApiProvider::Novita => DEFAULT_NOVITA_MODEL,
5187 ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,
5188 ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_MODEL,
5189 ApiProvider::Arcee => DEFAULT_ARCEE_MODEL,
5190 ApiProvider::Moonshot => DEFAULT_MOONSHOT_MODEL,
5191 ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,
5192 ApiProvider::Vllm => DEFAULT_VLLM_MODEL,
5193 ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,
5194 ApiProvider::Volcengine => DEFAULT_VOLCENGINE_MODEL,
5195 ApiProvider::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
5196 ApiProvider::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
5197 ApiProvider::Together => DEFAULT_TOGETHER_MODEL,
5198 ApiProvider::Qianfan => DEFAULT_QIANFAN_MODEL,
5199 // Prefer the live Codex roster head over the static seed so a
5200 // provider switch lands on the current flagship model instead of
5201 // a stale constant (#5034). Missing/stale/invalid rosters keep
5202 // the seed default. An explicit root `default_text_model` that is
5203 // not a foreign DeepSeek id is honoured above this fallback.
5204 ApiProvider::OpenaiCodex => {
5205 if let Some(preferred) =
5206 crate::codex_model_cache::model_roster().preferred_model_id()
5207 {
5208 return preferred.to_string();
5209 }
5210 DEFAULT_OPENAI_CODEX_MODEL
5211 }
5212 ApiProvider::Openmodel => DEFAULT_OPENMODEL_MODEL,
5213 ApiProvider::Zai => DEFAULT_ZAI_MODEL,
5214 ApiProvider::Stepfun => DEFAULT_STEPFUN_MODEL,
5215 ApiProvider::Anthropic => DEFAULT_ANTHROPIC_MODEL,
5216 ApiProvider::Minimax | ApiProvider::MinimaxAnthropic => DEFAULT_MINIMAX_MODEL,
5217 ApiProvider::Sakana => DEFAULT_SAKANA_MODEL,
5218 ApiProvider::LongCat => DEFAULT_LONGCAT_MODEL,
5219 ApiProvider::OpencodeGo => DEFAULT_OPENCODE_GO_MODEL,
5220 ApiProvider::OpencodeZen => DEFAULT_OPENCODE_ZEN_MODEL,
5221 ApiProvider::Meta => DEFAULT_META_MODEL,
5222 ApiProvider::Xai => DEFAULT_XAI_MODEL,
5223 ApiProvider::Telecomjs => DEFAULT_TELECOMJS_MODEL,
5224 ApiProvider::ModelstudioTokenPlan
5225 | ApiProvider::ModelstudioTokenPlanAnthropic
5226 | ApiProvider::ModelstudioCodingPlan
5227 | ApiProvider::ModelstudioCodingPlanAnthropic => DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL,
5228 // Custom endpoints have no built-in default model; pass through the
5229 // descriptor placeholder when nothing is configured (#1519).
5230 ApiProvider::Custom => codewhale_config::ProviderKind::Custom
5231 .provider()
5232 .default_model(),
5233 }
5234 .to_string()
5235 }
5236
5237 /// Return the configured API base URL (normalized) for the selected route.
5238 #[must_use]
5239 pub fn deepseek_base_url(&self) -> String {
5240 self.base_url_for_route(self.api_provider())
5241 }
5242
5243 /// Resolve `provider`'s endpoint from the layers that provider actually
5244 /// owns, in precedence order:
5245 ///
5246 /// 1. its own `[providers.<table>]` entry (including in-memory runtime
5247 /// overrides), plus the legacy root `base_url` where that field still
5248 /// belongs to the route;
5249 /// 2. its provider-specific environment contract (`MOONSHOT_BASE_URL`,
5250 /// `OPENAI_BASE_URL`, ...), which names exactly one provider and is
5251 /// therefore sound to read for a route that is not the session's;
5252 /// 3. the generic `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL` override, but
5253 /// only when this config is still the route that override selected;
5254 /// 4. the provider's canonical default endpoint.
5255 ///
5256 /// Step 3 is why this is identity-aware instead of a bare env read.
5257 /// `CODEWHALE_BASE_URL` is documented as "base URL for the active
5258 /// provider", and [`apply_env_overrides`] writes it onto exactly one
5259 /// provider entry. Every cross-provider construction seam — a pinned
5260 /// subagent/fleet child, the per-turn auto-router, tool routing, a picker
5261 /// preview — works by cloning the session config and re-pointing
5262 /// `provider`, so without the ownership check a Moonshot/Z.ai/MiniMax
5263 /// child in a DeepSeek session would silently inherit the DeepSeek host
5264 /// and dispatch a pinned model to the wrong vendor.
5265 pub(crate) fn base_url_for_route(&self, provider: ApiProvider) -> String {
5266 self.base_url_for_route_identity(provider, &self.provider_identity_for(provider))
5267 }
5268
5269 /// [`Config::base_url_for_route`] for an explicitly named identity.
5270 ///
5271 /// Named custom routes are resolved by this `identity` — the
5272 /// `[providers.<name>]` table key — and never by whichever custom route
5273 /// the session happens to be on. An identity that names no custom table
5274 /// fails closed to the descriptor placeholder rather than borrowing the
5275 /// active custom host.
5276 pub(crate) fn base_url_for_route_identity(
5277 &self,
5278 provider: ApiProvider,
5279 identity: &str,
5280 ) -> String {
5281 let provider_base = if provider == ApiProvider::Custom {
5282 self.custom_provider_entry_for_identity(identity)
5283 .and_then(|entry| entry.base_url.clone())
5284 } else {
5285 self.provider_config_string_with_runtime_fallback(provider, |entry| {
5286 entry.base_url.clone()
5287 })
5288 };
5289 // Root `base_url` is normally the legacy DeepSeek field. Xiaomi MiMo
5290 // also reads it when its table has no endpoint. OpenAI Codex must not:
5291 // a legacy DeepSeek endpoint would otherwise turn a normal Codex OAuth
5292 // switch into a custom route and make the saved Codex CLI login unusable.
5293 // NvidiaNim has a back-compat sniff (integrate.api.nvidia.com), and the
5294 // literal `provider = "custom"` legacy shape retains its root endpoint.
5295 // Named custom providers always read their own `[providers.<name>]`
5296 // table.
5297 let root_base = match provider {
5298 ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
5299 self.route_owned_root_base_url(provider, identity)
5300 }
5301 // Xiaomi MiMo honours a root `base_url` when the per-provider table
5302 // has none — otherwise a minimal top-level config silently falls
5303 // back to the official host.
5304 ApiProvider::XiaomiMimo => self.route_owned_root_base_url(provider, identity),
5305 ApiProvider::DeepseekAnthropic => None,
5306 ApiProvider::NvidiaNim => self
5307 .route_owned_root_base_url(provider, identity)
5308 .filter(|base| base.contains("integrate.api.nvidia.com")),
5309 ApiProvider::Openai
5310 | ApiProvider::Anthropic
5311 | ApiProvider::Openmodel
5312 | ApiProvider::Atlascloud
5313 | ApiProvider::WanjieArk
5314 | ApiProvider::Openrouter
5315 | ApiProvider::OpenaiCodex
5316 | ApiProvider::Novita
5317 | ApiProvider::Fireworks
5318 | ApiProvider::Siliconflow
5319 | ApiProvider::SiliconflowCn
5320 | ApiProvider::Arcee
5321 | ApiProvider::Moonshot
5322 | ApiProvider::Sglang
5323 | ApiProvider::Vllm
5324 | ApiProvider::Ollama
5325 | ApiProvider::Volcengine
5326 | ApiProvider::Huggingface
5327 | ApiProvider::Deepinfra
5328 | ApiProvider::Together
5329 | ApiProvider::Qianfan
5330 | ApiProvider::Zai
5331 | ApiProvider::Stepfun
5332 | ApiProvider::Minimax
5333 | ApiProvider::MinimaxAnthropic
5334 | ApiProvider::Sakana
5335 | ApiProvider::LongCat
5336 | ApiProvider::OpencodeGo
5337 | ApiProvider::OpencodeZen
5338 | ApiProvider::Meta
5339 | ApiProvider::Xai
5340 | ApiProvider::Telecomjs
5341 | ApiProvider::ModelstudioTokenPlan
5342 | ApiProvider::ModelstudioTokenPlanAnthropic
5343 | ApiProvider::ModelstudioCodingPlan
5344 | ApiProvider::ModelstudioCodingPlanAnthropic => None,
5345 // The legacy root endpoint belongs to the literal `custom`
5346 // identity only. A named custom child asking about its own table
5347 // must not inherit it.
5348 ApiProvider::Custom
5349 if identity_is_literal_custom(identity)
5350 && self.uses_legacy_literal_custom_route() =>
5351 {
5352 self.route_owned_root_base_url(provider, identity)
5353 }
5354 // Named custom routes read their base URL from `provider_base`.
5355 ApiProvider::Custom => None,
5356 };
5357 // A provider-scoped endpoint variable names exactly one provider, so it
5358 // resolves for the selected identity whether or not that identity is
5359 // the session route. `apply_env_overrides` only merges these into the
5360 // active provider's table, which is why a non-active route has to read
5361 // them here instead of relying on the merged config.
5362 let configured_base_url = provider_base
5363 .or(root_base)
5364 .or_else(|| provider_env_base_url_override(provider));
5365 let entry = self.provider_config_for(provider);
5366 let mode = entry.and_then(|e| e.mode.as_deref());
5367 let wire = entry.and_then(|e| e.wire.as_deref());
5368 let base = if provider == ApiProvider::XiaomiMimo {
5369 let config_api_key = entry.and_then(|e| e.api_key.as_deref()).filter(|value| {
5370 classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal
5371 });
5372 let env_api_key =
5373 xiaomi_mimo_env_api_key_for_runtime(mode, configured_base_url.as_deref());
5374 let api_key = config_api_key.or(env_api_key.as_deref());
5375 resolve_xiaomi_mimo_base_url(configured_base_url, api_key, mode)
5376 } else if matches!(
5377 provider,
5378 ApiProvider::ModelstudioTokenPlan
5379 | ApiProvider::ModelstudioTokenPlanAnthropic
5380 | ApiProvider::ModelstudioCodingPlan
5381 | ApiProvider::ModelstudioCodingPlanAnthropic
5382 ) {
5383 resolve_modelstudio_base_url_for_tui(configured_base_url, provider, mode, wire)
5384 } else if matches!(
5385 provider,
5386 ApiProvider::Minimax | ApiProvider::MinimaxAnthropic
5387 ) {
5388 resolve_minimax_base_url_for_tui(configured_base_url, provider, wire)
5389 } else if matches!(
5390 provider,
5391 ApiProvider::Deepseek | ApiProvider::DeepseekAnthropic
5392 ) {
5393 resolve_deepseek_base_url_for_tui(configured_base_url, provider, wire)
5394 } else {
5395 configured_base_url
5396 .or_else(|| self.route_owned_generic_env_base_url(provider, identity))
5397 .unwrap_or_else(|| {
5398 match provider {
5399 ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
5400 ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,
5401 ApiProvider::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
5402 ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
5403 ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,
5404 ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
5405 ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
5406 ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
5407 ApiProvider::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
5408 ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,
5409 ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
5410 ApiProvider::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
5411 ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL,
5412 ApiProvider::Arcee => DEFAULT_ARCEE_BASE_URL,
5413 ApiProvider::Moonshot => {
5414 if self
5415 .provider_config_for(provider)
5416 .is_some_and(provider_config_uses_kimi_imported_token)
5417 {
5418 DEFAULT_KIMI_CODE_BASE_URL
5419 } else {
5420 DEFAULT_MOONSHOT_BASE_URL
5421 }
5422 }
5423 ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,
5424 ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,
5425 ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,
5426 ApiProvider::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
5427 ApiProvider::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
5428 ApiProvider::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
5429 ApiProvider::Together => DEFAULT_TOGETHER_BASE_URL,
5430 ApiProvider::Qianfan => DEFAULT_QIANFAN_BASE_URL,
5431 ApiProvider::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
5432 ApiProvider::Openmodel => DEFAULT_OPENMODEL_BASE_URL,
5433 ApiProvider::Zai => DEFAULT_ZAI_BASE_URL,
5434 ApiProvider::Stepfun => DEFAULT_STEPFUN_BASE_URL,
5435 ApiProvider::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
5436 ApiProvider::Minimax => DEFAULT_MINIMAX_BASE_URL,
5437 ApiProvider::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
5438 ApiProvider::Sakana => DEFAULT_SAKANA_BASE_URL,
5439 ApiProvider::LongCat => DEFAULT_LONGCAT_BASE_URL,
5440 ApiProvider::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL,
5441 ApiProvider::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL,
5442 ApiProvider::Meta => DEFAULT_META_BASE_URL,
5443 ApiProvider::Xai => DEFAULT_XAI_BASE_URL,
5444 ApiProvider::Telecomjs => DEFAULT_TELECOMJS_BASE_URL,
5445 ApiProvider::ModelstudioTokenPlan
5446 | ApiProvider::ModelstudioTokenPlanAnthropic
5447 | ApiProvider::ModelstudioCodingPlan
5448 | ApiProvider::ModelstudioCodingPlanAnthropic => {
5449 DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL
5450 }
5451 // No built-in endpoint; descriptor placeholder keeps the
5452 // fallback total. A real custom route configures
5453 // `[providers.<name>] base_url` which wins above (#1519).
5454 ApiProvider::Custom => codewhale_config::ProviderKind::Custom
5455 .provider()
5456 .default_base_url(),
5457 }
5458 .to_string()
5459 })
5460 };
5461 normalize_base_url(&base)
5462 }
5463
5464 /// The generic `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL` override, but
5465 /// only for the route that override actually selected.
5466 ///
5467 /// [`apply_env_overrides`] records the owning `(provider, identity)` in
5468 /// [`Config::base_url_env_receipt`] at load time and writes the value onto
5469 /// that provider's own entry. A config later re-pointed at another identity
5470 /// is a different route: it must fall through to that provider's own
5471 /// default rather than borrow the session host.
5472 fn route_owned_generic_env_base_url(
5473 &self,
5474 provider: ApiProvider,
5475 identity: &str,
5476 ) -> Option<String> {
5477 match &self.base_url_env_receipt {
5478 // Never went through the environment layer: keep the established
5479 // global fallback so directly constructed configs are unaffected.
5480 BaseUrlEnvReceipt::Unrecorded => env_base_url_override(),
5481 // A positive "nobody owns it" — a managed overlay took the
5482 // endpoint. No route may borrow the ambient generic host.
5483 BaseUrlEnvReceipt::NoOwner => None,
5484 BaseUrlEnvReceipt::Route(..) => self
5485 .base_url_env_receipt
5486 .owns(provider, identity)
5487 .then(env_base_url_override)
5488 .flatten(),
5489 }
5490 }
5491
5492 /// The legacy root `base_url`, unless an environment write addressed it to
5493 /// a different route.
5494 ///
5495 /// `Deepseek` and `DeepseekCN` share this one field. A user who writes
5496 /// `base_url` in their config file still means it for both identities —
5497 /// that legacy compatibility is preserved by `None` ownership. But when
5498 /// [`apply_env_overrides`] wrote the value, it wrote it for exactly the
5499 /// identity that was active, and a pinned child of the sibling identity
5500 /// must not inherit it.
5501 fn route_owned_root_base_url(&self, provider: ApiProvider, identity: &str) -> Option<String> {
5502 let root = self.base_url.clone()?;
5503 match &self.root_base_url_owner {
5504 // File-owned legacy root: shared by every route that reads it, as
5505 // it always has been.
5506 BaseUrlEnvReceipt::Unrecorded => Some(root),
5507 // An environment write that a higher-precedence layer has since
5508 // taken authority over. It belongs to no route.
5509 BaseUrlEnvReceipt::NoOwner => None,
5510 BaseUrlEnvReceipt::Route(..) => self
5511 .root_base_url_owner
5512 .owns(provider, identity)
5513 .then_some(root),
5514 }
5515 }
5516
5517 /// Resolve a named custom provider's table by explicit identity.
5518 ///
5519 /// Fails closed: an empty identity, or one that names no
5520 /// `[providers.<name>]` custom table, resolves to nothing instead of
5521 /// falling back to whichever custom route the session is currently on.
5522 fn custom_provider_entry_for_identity(&self, identity: &str) -> Option<&ProviderConfig> {
5523 let key = identity.trim();
5524 if key.is_empty() {
5525 return None;
5526 }
5527 self.providers.as_ref()?.custom_provider_config(key)
5528 }
5529
5530 fn active_provider_preserves_custom_base_url_model(&self) -> bool {
5531 self.provider_uses_custom_endpoint(self.api_provider())
5532 }
5533
5534 /// Whether `provider`'s effective endpoint is a custom host rather than its
5535 /// shipped one. Resolved through the same identity-aware resolver the
5536 /// client is built from, so this predicate cannot disagree with the URL the
5537 /// request will actually be sent to.
5538 pub(crate) fn provider_uses_custom_endpoint(&self, provider: ApiProvider) -> bool {
5539 provider_preserves_custom_base_url_model(provider, &self.base_url_for_route(provider))
5540 }
5541
5542 /// Whether file-owned credential slots are bound to `provider`'s
5543 /// effective endpoint.
5544 ///
5545 /// The environment can replace the active route's base URL after config
5546 /// parsing. In that case, a root/provider `api_key` or configured
5547 /// `api_key_env` still belongs to the file-owned endpoint and must not
5548 /// follow a newly selected custom host. An explicit source-marked CLI key
5549 /// remains a deliberate endpoint override and is handled before this
5550 /// predicate by the runtime resolver.
5551 pub(crate) fn config_credentials_are_bound_to_provider_endpoint(
5552 &self,
5553 provider: ApiProvider,
5554 ) -> bool {
5555 provider != self.api_provider()
5556 || !self.active_base_url_is_environment_owned(provider)
5557 || !self.provider_uses_custom_endpoint(provider)
5558 }
5559
5560 fn active_base_url_is_environment_owned(&self, provider: ApiProvider) -> bool {
5561 if provider != self.api_provider() {
5562 return false;
5563 }
5564 let identity = self.provider_identity_for(provider);
5565 if self.base_url_env_receipt.owns(provider, &identity) {
5566 return true;
5567 }
5568
5569 // Below the receipt, the environment can still supply the endpoint for
5570 // a route that has none of its own. A provider-scoped variable names
5571 // exactly one provider, so it always owns that route's endpoint. The
5572 // generic variable only does so while no receipt has said otherwise —
5573 // once a receipt exists and does not name this route,
5574 // `route_owned_generic_env_base_url` refuses it, so claiming env
5575 // ownership here would contradict the URL actually resolved.
5576 if self.configured_base_url_for_provider(provider).is_some() {
5577 return false;
5578 }
5579 provider_env_base_url_override(provider).is_some()
5580 || (matches!(self.base_url_env_receipt, BaseUrlEnvReceipt::Unrecorded)
5581 && env_base_url_override().is_some())
5582 }
5583
5584 /// The endpoint `provider` owns through a file or in-memory layer, before
5585 /// the environment layer is consulted.
5586 ///
5587 /// The legacy root field is read through
5588 /// [`Config::route_owned_root_base_url`] so an environment write addressed
5589 /// to one identity is not mistaken for the sibling identity's configured
5590 /// endpoint. DeepSeek and Xiaomi MiMo honour root `base_url` when their
5591 /// per-provider table has none. OpenAI Codex does not: its OAuth login is
5592 /// valid only for the official route, not an inherited legacy endpoint.
5593 fn configured_base_url_for_provider(&self, provider: ApiProvider) -> Option<String> {
5594 let identity = self.provider_identity_for(provider);
5595 let provider_base = self
5596 .provider_config_string_with_runtime_fallback(provider, |entry| entry.base_url.clone());
5597 match provider {
5598 ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::XiaomiMimo => {
5599 provider_base.or_else(|| self.route_owned_root_base_url(provider, &identity))
5600 }
5601 ApiProvider::NvidiaNim => provider_base.or_else(|| {
5602 self.route_owned_root_base_url(provider, &identity)
5603 .filter(|base| base.contains("integrate.api.nvidia.com"))
5604 }),
5605 ApiProvider::Custom if self.uses_legacy_literal_custom_route() => {
5606 provider_base.or_else(|| self.route_owned_root_base_url(provider, &identity))
5607 }
5608 _ => provider_base,
5609 }
5610 .filter(|base| !base.trim().is_empty())
5611 }
5612
5613 /// Whether model ids for `provider` belong to the configured endpoint.
5614 ///
5615 /// Every route — active or pinned — is judged on the endpoint it will
5616 /// actually be dispatched to, so a pinned child cannot canonicalize model
5617 /// ids for a host that owns its own namespace (or pass through ids on a
5618 /// route that resolves to a canonical endpoint). The resolver behind
5619 /// [`Config::provider_uses_custom_endpoint`] is identity-aware, so this no
5620 /// longer risks attributing the session's endpoint to another provider.
5621 pub(crate) fn model_ids_pass_through_for_provider(&self, provider: ApiProvider) -> bool {
5622 provider_passes_model_through(provider) || self.provider_uses_custom_endpoint(provider)
5623 }
5624
5625 pub(crate) fn model_ids_pass_through(&self) -> bool {
5626 self.model_ids_pass_through_for_provider(self.api_provider())
5627 }
5628
5629 pub(crate) fn auth_mode_for_provider(&self, provider: ApiProvider) -> Option<String> {
5630 self.provider_config_string_with_runtime_fallback(provider, |entry| entry.auth_mode.clone())
5631 .or_else(|| {
5632 (provider == self.api_provider())
5633 .then(|| self.auth_mode.clone())
5634 .flatten()
5635 })
5636 }
5637
5638 /// Mint a read capability for the exact external credential path selected
5639 /// when consent was granted.
5640 ///
5641 /// Path resolution itself is side-effect free. The returned capability is
5642 /// required by every external credential adapter before it may stat or
5643 /// read the selected file. `suggested_path` is used only in disabled-mode
5644 /// guidance; an existing grant remains pinned to its persisted path even
5645 /// if ambient CLI-home environment variables change later.
5646 pub(crate) fn external_credential_read_grant(
5647 &self,
5648 provider: ApiProvider,
5649 source: codewhale_config::ExternalCredentialSource,
5650 suggested_path: &Path,
5651 ) -> Result<codewhale_config::ExternalCredentialReadGrant> {
5652 if provider != self.api_provider() {
5653 anyhow::bail!(
5654 "external credential access for {} is dormant until that provider is explicitly selected",
5655 provider.display_name()
5656 );
5657 }
5658 let kind = provider
5659 .metadata()
5660 .map(codewhale_config::provider::Provider::kind)
5661 .context("external credentials are unsupported for this provider")?;
5662 let consent = self
5663 .provider_config_for(provider)
5664 .and_then(|entry| entry.external_credentials.as_ref())
5665 .with_context(|| {
5666 format!(
5667 "External credentials owned by {} are disabled for {}. To allow read-only access to this exact file, run:\n codewhale auth external-consent --provider {} --mode read-only --path {}",
5668 source.as_str(),
5669 provider.display_name(),
5670 kind.as_str(),
5671 codewhale_config::quote_os_path(suggested_path)
5672 )
5673 })?;
5674 consent
5675 .read_grant(kind, source, &consent.path)
5676 .map_err(|error| {
5677 anyhow::anyhow!(
5678 "external credential consent for {}: {error}",
5679 provider.display_name()
5680 )
5681 })
5682 }
5683
5684 /// Whether a structurally valid read-only consent record exists for an
5685 /// external credential source. This never stats or reads the selected
5686 /// file and never mints the capability required to do so.
5687 pub(crate) fn external_credential_read_consent_configured(
5688 &self,
5689 provider: ApiProvider,
5690 source: codewhale_config::ExternalCredentialSource,
5691 ) -> bool {
5692 let Some(kind) = provider
5693 .metadata()
5694 .map(codewhale_config::provider::Provider::kind)
5695 else {
5696 return false;
5697 };
5698 let Some(consent) = self
5699 .provider_config_for(provider)
5700 .and_then(|entry| entry.external_credentials.as_ref())
5701 else {
5702 return false;
5703 };
5704 consent
5705 .validate_read_scope(kind, source, &consent.path)
5706 .is_ok()
5707 }
5708
5709 pub(crate) fn should_skip_secret_store_for_provider(&self, provider: ApiProvider) -> bool {
5710 // The CLI's durable credential namespace has one compatibility slot
5711 // named `custom`; it cannot identify an arbitrary named custom route.
5712 // Reusing that slot for `[providers.<name>]` could send endpoint A's
5713 // bearer token to endpoint B. Named routes therefore resolve only
5714 // their own config/auth/api_key_env sources. The generic slot remains
5715 // valid solely for the literal legacy root-field custom route.
5716 if provider == ApiProvider::Custom && !self.uses_legacy_literal_custom_route() {
5717 return true;
5718 }
5719
5720 let auth_mode = self.auth_mode_for_provider(provider);
5721 if auth_mode_disables_api_key(auth_mode.as_deref()) {
5722 return true;
5723 }
5724 if self.provider_uses_custom_endpoint(provider) {
5725 // An explicitly authenticated loopback runtime may intentionally
5726 // use the durable provider slot (for example a protected local
5727 // vLLM server). Remote custom endpoints must never inherit an
5728 // official provider's saved credential.
5729 let explicitly_authenticated_loopback = provider == self.api_provider()
5730 && auth_mode_requires_api_key(auth_mode.as_deref())
5731 && base_url_uses_local_host(&self.deepseek_base_url());
5732 if !explicitly_authenticated_loopback {
5733 return true;
5734 }
5735 }
5736 if auth_mode_requires_api_key(auth_mode.as_deref()) {
5737 return false;
5738 }
5739
5740 provider.is_self_hosted()
5741 || (provider == self.api_provider()
5742 && base_url_uses_local_host(&self.deepseek_base_url()))
5743 }
5744
5745 /// Read the API key.
5746 ///
5747 /// Precedence: **route-specific explicitly consented OAuth token → source-marked explicit CLI key →
5748 /// provider/root config → configured custom-provider environment →
5749 /// secret store → ambient provider environment**.
5750 ///
5751 /// The in-memory `self.api_key` override is only honored when the user
5752 /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`
5753 /// placeholder, not empty whitespace).
5754 pub fn deepseek_api_key(&self) -> Result<String> {
5755 self.deepseek_api_key_with_secret_store_mode(false)
5756 }
5757
5758 /// Resolve an API key for a diagnostic without migrating a legacy secret
5759 /// store or opening a write-capable secret backend.
5760 ///
5761 /// This retains ordinary credential precedence, including a legacy
5762 /// file-backed secret as a fallback, but it must only be used by static
5763 /// diagnostic/reporting paths. Normal runtime and authentication paths use
5764 /// [`Self::deepseek_api_key`] and preserve their existing migration
5765 /// behavior.
5766 pub(crate) fn deepseek_api_key_read_only(&self) -> Result<String> {
5767 self.deepseek_api_key_with_secret_store_mode(true)
5768 }
5769
5770 /// Clone this route with a diagnostic-only credential in its in-memory
5771 /// provider slot.
5772 ///
5773 /// A live `doctor` probe still needs to construct the ordinary client. By
5774 /// materializing the credential on an isolated clone first, that client
5775 /// never reaches the normal migrating secret-store resolver while it is
5776 /// only checking connectivity. The clone is process-local and is never
5777 /// persisted.
5778 pub(crate) fn with_read_only_api_key_for_diagnostic(&self) -> Result<Self> {
5779 let provider = self.api_provider();
5780 let api_key = self.deepseek_api_key_read_only()?;
5781 let mut diagnostic = self.clone();
5782 diagnostic.set_provider_api_key_override(provider, Some(api_key));
5783 Ok(diagnostic)
5784 }
5785
5786 fn deepseek_api_key_with_secret_store_mode(&self, read_only: bool) -> Result<String> {
5787 let provider = self.api_provider();
5788 let auth_mode = self.auth_mode_for_provider(provider);
5789 if auth_mode_disables_api_key(auth_mode.as_deref()) {
5790 return Ok(String::new());
5791 }
5792 let custom_endpoint = self.provider_uses_custom_endpoint(provider);
5793 let explicit_cli_key = explicit_cli_api_key_override();
5794
5795 // 0. Legacy root compatibility slot. The top-level `api_key` belongs
5796 // to DeepSeek, plus the literal root-field `provider = "custom"`
5797 // compatibility route. Provider-specific keys below must win for all
5798 // named/custom-table routes so a stale root key is not sent elsewhere.
5799 //
5800 // However, when the CLI dispatcher forwards an explicit `--api-key`
5801 // through `DEEPSEEK_API_KEY` with the dispatcher source marker, that
5802 // intentional override must win over the saved root key. This is
5803 // essential for DeepSeek-compatible subscription endpoints where the
5804 // user runs something like:
5805 // codewhale --provider deepseek --api-key ark-... --base-url ... --model auto
5806 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
5807 && std::env::var("DEEPSEEK_API_KEY_SOURCE").as_deref() == Ok("cli")
5808 && let Some(env_key) = explicit_cli_key
5809 .as_ref()
5810 .cloned()
5811 .or_else(|| provider_env_api_key(provider))
5812 && !env_key.trim().is_empty()
5813 {
5814 return Ok(env_key);
5815 }
5816 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
5817 && self.config_credentials_are_bound_to_provider_endpoint(provider)
5818 && let Some(configured) = self.api_key.as_ref()
5819 && classify_config_api_key_value(configured) == ConfigApiKeyValueKind::Literal
5820 {
5821 warn_on_config_api_key_shadowing(self, provider, "the root api_key");
5822 return Ok(configured.clone());
5823 }
5824
5825 if provider == ApiProvider::Moonshot
5826 && !custom_endpoint
5827 && self
5828 .provider_config_for(provider)
5829 .is_some_and(provider_config_uses_kimi_imported_token)
5830 {
5831 let credential_help =
5832 credential_help_for_provider_route(provider, &self.deepseek_base_url());
5833 anyhow::bail!(
5834 "Kimi CLI credential import is unsupported. Codewhale does not impersonate or reuse Kimi OAuth clients; configure an API key from {} instead.",
5835 credential_help
5836 .credential_url
5837 .unwrap_or("the selected provider's API-key console")
5838 );
5839 }
5840
5841 // xAI OAuth prefers Codewhale-owned device-login storage. An existing
5842 // Grok CLI file is considered only with provider/path-scoped read-only
5843 // consent. Activated by [providers.xai] auth_mode = "oauth".
5844 if provider == ApiProvider::Xai
5845 && !custom_endpoint
5846 && self
5847 .provider_config_for(provider)
5848 .is_some_and(provider_config_uses_xai_oauth)
5849 && crate::xai_oauth::credentials_present(self)
5850 {
5851 return crate::xai_oauth::get_access_token(self);
5852 }
5853
5854 // OpenAI Codex (ChatGPT) can read an existing Codex CLI OAuth login
5855 // only after exact read-only consent. Codewhale never refreshes or
5856 // rewrites that file. Explicit env overrides remain process-scoped.
5857 if provider == ApiProvider::OpenaiCodex && !custom_endpoint {
5858 if let Some(credentials) = crate::oauth::credentials_from_env() {
5859 return Ok(credentials.access_token);
5860 }
5861 let path = crate::oauth::auth_file_path();
5862 let grant = self.external_credential_read_grant(
5863 provider,
5864 codewhale_config::ExternalCredentialSource::CodexCli,
5865 &path,
5866 )?;
5867 return Ok(crate::oauth::get_credentials(&grant)?.access_token);
5868 }
5869
5870 // The dispatcher cannot know the effective provider until the TUI
5871 // applies `--profile`. A provider-neutral, source-marked CLI override
5872 // therefore wins over saved API-key slots here, after OAuth routes
5873 // have made their own credential decision.
5874 if let Some(value) = explicit_cli_key {
5875 return Ok(value);
5876 }
5877
5878 // 1. Config file (provider-scoped slot). This intentionally wins
5879 // over ambient env so `codewhale auth set` fixes stale shell exports.
5880 if self.config_credentials_are_bound_to_provider_endpoint(provider)
5881 && let Some(configured) = self
5882 .provider_config_string_with_runtime_fallback(provider, |entry| {
5883 entry.api_key.clone()
5884 })
5885 && classify_config_api_key_value(&configured) == ConfigApiKeyValueKind::Literal
5886 {
5887 let config_source = match provider_config_table_name(provider) {
5888 Ok(table) => format!("`{table}` api_key"),
5889 Err(_) => "the provider config-table api_key".to_string(),
5890 };
5891 warn_on_config_api_key_shadowing(self, provider, &config_source);
5892 return Ok(configured);
5893 }
5894 if provider == ApiProvider::Custom
5895 && self.uses_legacy_literal_custom_route()
5896 && self.config_credentials_are_bound_to_provider_endpoint(provider)
5897 && let Some(configured) = self.api_key.as_ref()
5898 && classify_config_api_key_value(configured) == ConfigApiKeyValueKind::Literal
5899 {
5900 warn_on_config_api_key_shadowing(self, provider, "the root api_key");
5901 return Ok(configured.clone());
5902 }
5903
5904 // 1b. A route can explicitly bind an environment variable by name via
5905 // `[providers.<name>] api_key_env = "..."`. This remains safe for a
5906 // custom endpoint because the binding belongs to that route; ambient
5907 // provider variables below do not.
5908 //
5909 // For a custom provider, a binding that names an unset (or empty)
5910 // variable is a broken credential contract, not a keyless route: fail
5911 // loudly with the route-scoped fix instead of silently degrading to
5912 // the self-hosted loopback keyless fallback below (#5104). Without
5913 // this, an `api_key_env` route on a loopback host dispatched
5914 // unauthenticated while the operator believed credentials were wired,
5915 // and the composer-side preflight recovery never saw an error.
5916 if provider == ApiProvider::Custom
5917 && let Some(env_name) = bound_provider_api_key_env_name(self, provider)
5918 {
5919 return match std::env::var(&env_name) {
5920 Ok(value) if !value.trim().is_empty() => Ok(value),
5921 _ => {
5922 let route_name = self.provider.as_deref().unwrap_or("<name>");
5923 Err(anyhow::anyhow!(
5924 "Custom provider '{route_name}' API key not found: the route binds \
5925 api_key_env = \"{env_name}\" but that environment variable is not set. \
5926 Set {env_name} to your key, or remove api_key_env from \
5927 [providers.{route_name}] to run the endpoint without credentials."
5928 ))
5929 }
5930 };
5931 }
5932 if let Some(value) = provider_config_env_api_key(self, provider) {
5933 return Ok(value);
5934 }
5935
5936 // 2. The dispatcher resolves this same provider slot before launching
5937 // the TUI. Standalone `codewhale-tui` launches must see the identical
5938 // durable credential. Auto-detection is file-backed and prompt-free by
5939 // default; the OS keyring is queried only when the user explicitly
5940 // selects the system backend.
5941 if !self.should_skip_secret_store_for_provider(provider)
5942 && let Some(value) = provider_secret_store_api_key_with_mode(self, provider, read_only)
5943 {
5944 return Ok(value);
5945 }
5946
5947 // 3. Ambient provider environment variables are scoped to official
5948 // endpoints. Never send an official-provider export to a custom host.
5949 if !self.should_skip_secret_store_for_provider(provider)
5950 && provider == ApiProvider::XiaomiMimo
5951 {
5952 let mode = self
5953 .provider_config_for(provider)
5954 .and_then(|provider| provider.mode.as_deref());
5955 if let Some(value) =
5956 xiaomi_mimo_env_api_key_for_runtime(mode, Some(&self.deepseek_base_url()))
5957 && !value.trim().is_empty()
5958 {
5959 return Ok(value);
5960 }
5961 }
5962 if !self.should_skip_secret_store_for_provider(provider)
5963 && let Some(value) = provider_env_api_key(provider)
5964 {
5965 return Ok(value);
5966 }
5967
5968 if !auth_mode_requires_api_key(auth_mode.as_deref())
5969 && (provider.is_self_hosted() || base_url_uses_local_host(&self.deepseek_base_url()))
5970 {
5971 return Ok(String::new());
5972 }
5973
5974 if custom_endpoint {
5975 let route_name = self
5976 .provider
5977 .as_deref()
5978 .unwrap_or_else(|| provider.as_str());
5979 anyhow::bail!(
5980 "Custom endpoint credentials for {route_name} must be bound explicitly. Ambient provider credentials are not sent to {}. Add api_key or api_key_env to this provider route, or pass --api-key with --base-url.",
5981 self.deepseek_base_url()
5982 );
5983 }
5984
5985 match provider {
5986 ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(
5987 "DeepSeek API key not found.\n\
5988 \n\
5989 1. Get a key: https://platform.deepseek.com/api_keys\n\
5990 2. Save it (works in every folder, no OS prompts):\n\
5991 codewhale auth set --provider deepseek\n\
5992 \n\
5993 Alternatives:\n\
5994 • export DEEPSEEK_API_KEY=<your-key> (current shell only;\n\
5995 also note: zsh users — exports in ~/.zshrc only reach interactive\n\
5996 shells, prefer ~/.zshenv for everything)\n\
5997 • api_key = \"<your-key>\" in ~/.codewhale/config.toml"
5998 ),
5999 ApiProvider::SiliconflowCn => anyhow::bail!(
6000 "SiliconFlow China API key not found. Get a key: {}. Run 'codewhale auth set --provider siliconflow-CN', \
6001 set {}, or add [{}] api_key in ~/.codewhale/config.toml. \
6002 [providers.siliconflow] remains a fallback when the CN table omits api_key.",
6003 provider
6004 .credential_url()
6005 .unwrap_or("https://cloud.siliconflow.com/account/ak"),
6006 provider.env_vars_label(),
6007 provider_config_table_name(provider)?
6008 ),
6009 ApiProvider::Moonshot => {
6010 let credential_help =
6011 credential_help_for_provider_route(provider, &self.deepseek_base_url());
6012 if moonshot_base_url_is_exact_kimi_code(&self.deepseek_base_url()) {
6013 anyhow::bail!(
6014 "Kimi Code membership-plan API key not found. Get a plan key: {}. This route uses api.kimi.com/coding/v1 and does not import Kimi CLI credentials. Run 'codewhale auth set --provider moonshot', set {}, or add [{}] api_key.",
6015 credential_help
6016 .credential_url
6017 .unwrap_or(KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL),
6018 provider.env_vars_label(),
6019 provider_config_table_name(provider)?
6020 );
6021 }
6022 anyhow::bail!(
6023 "Moonshot/Kimi API key not found. Get a key: {}. Run 'codewhale auth set --provider moonshot', \
6024 set {}, or add [{}] api_key. \
6025 For a Kimi Code plan key, set [providers.moonshot] base_url = \
6026 \"https://api.kimi.com/coding/v1\" and model = \"kimi-for-coding\".",
6027 credential_help
6028 .credential_url
6029 .unwrap_or("https://platform.kimi.ai/console/api-keys"),
6030 provider.env_vars_label(),
6031 provider_config_table_name(provider)?
6032 );
6033 }
6034 ApiProvider::Anthropic | ApiProvider::Openmodel => {
6035 anyhow::bail!("{}", missing_provider_api_key_message(provider)?)
6036 }
6037 ApiProvider::OpencodeZen => {
6038 anyhow::bail!("{}", missing_provider_api_key_message(provider)?)
6039 }
6040 ApiProvider::OpenaiCodex => anyhow::bail!("{}", crate::oauth::missing_auth_message()),
6041 ApiProvider::Xai => {
6042 // Prefer OAuth guidance when auth_mode requests it or Grok CLI
6043 // tokens already exist; otherwise show both API-key and OAuth.
6044 if self
6045 .provider_config_for(provider)
6046 .is_some_and(provider_config_uses_xai_oauth)
6047 || crate::xai_oauth::credentials_present(self)
6048 {
6049 anyhow::bail!("{}", crate::xai_oauth::missing_auth_message());
6050 }
6051 anyhow::bail!(
6052 "xAI API key not found. Get a key: https://console.x.ai/\n\
6053 Run 'codewhale auth set --provider xai', set XAI_API_KEY, or add \
6054 [providers.xai] api_key.\n\
6055 OAuth alternative: run `codewhale auth xai-device` for \
6056 Codewhale-owned storage and set [providers.xai] auth_mode = \"oauth\"."
6057 );
6058 }
6059 // Self-hosted deployments commonly run without auth on localhost.
6060 // Return an empty key and let the client omit the Authorization header.
6061 ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),
6062 // Custom OpenAI-compatible endpoints (#1519): the key comes from the
6063 // env var named by `[providers.<name>] api_key_env`. If we reached
6064 // here it is unset/empty (and the endpoint is not loopback).
6065 ApiProvider::Custom => {
6066 let provider_name = self.provider.as_deref().unwrap_or("<name>");
6067 match self
6068 .provider_config_for(provider)
6069 .and_then(|entry| entry.api_key_env.as_deref())
6070 .map(str::trim)
6071 .filter(|name| !name.is_empty())
6072 {
6073 Some(env_name) => anyhow::bail!(
6074 "Custom provider '{provider_name}' API key not found.\n\
6075 Set the environment variable {env_name} to your key, \
6076 or add api_key to [providers.{provider_name}]."
6077 ),
6078 None => anyhow::bail!(
6079 "Custom provider '{provider_name}' has no auth configured.\n\
6080 Add api_key_env = \"YOUR_ENV_VAR\" (or api_key) to \
6081 [providers.{provider_name}] in ~/.codewhale/config.toml."
6082 ),
6083 }
6084 }
6085 _ => anyhow::bail!("{}", missing_provider_api_key_message(provider)?),
6086 }
6087 }
6088
6089 /// Resolve the skills directory path.
6090 #[must_use]
6091 pub fn skills_dir(&self) -> PathBuf {
6092 self.skills_dir
6093 .as_deref()
6094 .map(expand_path)
6095 .or_else(default_skills_dir)
6096 .unwrap_or_else(|| PathBuf::from("./skills"))
6097 }
6098
6099 /// Resolve the MCP config path.
6100 #[must_use]
6101 pub fn mcp_config_path(&self) -> PathBuf {
6102 self.mcp_config_path
6103 .as_deref()
6104 .map(expand_path)
6105 .or_else(default_mcp_config_path)
6106 .unwrap_or_else(|| PathBuf::from("./mcp.json"))
6107 }
6108
6109 /// Resolve the notes file path.
6110 #[must_use]
6111 pub fn notes_path(&self) -> PathBuf {
6112 self.notes_path
6113 .as_deref()
6114 .map(expand_path)
6115 .or_else(default_notes_path)
6116 .unwrap_or_else(|| PathBuf::from("./notes.txt"))
6117 }
6118
6119 /// Resolve the memory file path.
6120 #[must_use]
6121 pub fn memory_path(&self) -> PathBuf {
6122 let legacy_path = self
6123 .memory_path
6124 .as_deref()
6125 .map(expand_path)
6126 .or_else(default_memory_path)
6127 .unwrap_or_else(|| PathBuf::from("./memory.md"));
6128 if self.memory_backend() == MemoryBackend::Native {
6129 return legacy_path
6130 .parent()
6131 .unwrap_or_else(|| Path::new("."))
6132 .join("memory")
6133 .join("global")
6134 .join("MEMORY.md");
6135 }
6136 legacy_path
6137 }
6138
6139 /// Resolve the default speech/TTS output directory, if configured.
6140 #[must_use]
6141 pub fn speech_output_dir(&self) -> Option<PathBuf> {
6142 std::env::var("XIAOMI_MIMO_SPEECH_OUTPUT_DIR")
6143 .or_else(|_| std::env::var("MIMO_SPEECH_OUTPUT_DIR"))
6144 .or_else(|_| std::env::var("XIAOMIMIMO_SPEECH_OUTPUT_DIR"))
6145 .ok()
6146 .map(|value| value.trim().to_string())
6147 .filter(|value| !value.is_empty())
6148 .map(|value| expand_path(&value))
6149 .or_else(|| {
6150 self.speech
6151 .as_ref()
6152 .and_then(|speech| speech.output_dir.as_deref())
6153 .map(str::trim)
6154 .filter(|value| !value.is_empty())
6155 .map(expand_path)
6156 })
6157 }
6158
6159 /// Resolve the configured `instructions = [...]` array (#454)
6160 /// to absolute paths, in declared order. Empty when unset or
6161 /// when every entry is empty after trimming. Each entry runs
6162 /// through `expand_path` so `~` and env vars are honoured.
6163 #[must_use]
6164 pub fn instructions_paths(&self) -> Vec<PathBuf> {
6165 self.instructions
6166 .as_deref()
6167 .unwrap_or(&[])
6168 .iter()
6169 .map(String::as_str)
6170 .map(str::trim)
6171 .filter(|s| !s.is_empty())
6172 .map(expand_path)
6173 .collect()
6174 }
6175
6176 /// Whether the user-memory feature is enabled. The default is **off**
6177 /// to preserve zero-overhead behavior for users who haven't opted in.
6178 /// Flips to `true` when `[memory] enabled = true` in `config.toml` or
6179 /// `DEEPSEEK_MEMORY=on` is set in the environment.
6180 #[must_use]
6181 pub fn memory_enabled(&self) -> bool {
6182 if let Some(backend) = self.memory.as_ref().and_then(|memory| memory.backend) {
6183 return backend != MemoryBackend::Off;
6184 }
6185 self.memory
6186 .as_ref()
6187 .and_then(|m| m.enabled)
6188 .unwrap_or(false)
6189 }
6190
6191 /// Effective safety backstop on automatic goal continuation passes
6192 /// (#5052). `[goal] max_continuations` overrides the built-in default;
6193 /// `0` disables the backstop so only completion/blocked or token/time
6194 /// budget exhaustion stop an operate-mode goal run.
6195 #[must_use]
6196 pub fn goal_max_continuations(&self) -> u32 {
6197 self.goal
6198 .as_ref()
6199 .and_then(|goal| goal.max_continuations)
6200 .unwrap_or(crate::goal_loop::DEFAULT_MAX_GOAL_CONTINUATIONS)
6201 }
6202
6203 /// Resolve the explicit local-memory backend.
6204 #[must_use]
6205 pub fn memory_backend(&self) -> MemoryBackend {
6206 self.memory
6207 .as_ref()
6208 .and_then(|memory| memory.backend)
6209 .unwrap_or_else(|| {
6210 let Some(memory) = self.memory.as_ref() else {
6211 return MemoryBackend::Off;
6212 };
6213 if memory.enabled.unwrap_or(false) {
6214 MemoryBackend::Native
6215 } else {
6216 MemoryBackend::Off
6217 }
6218 })
6219 }
6220
6221 /// Return the configured vision model config, inheriting api_key from main config.
6222 #[must_use]
6223 pub fn vision_model_config(&self) -> Option<VisionModelConfig> {
6224 let mut config = self.vision_model.clone()?;
6225 if config.api_key.is_none() {
6226 config.api_key = self.api_key.clone();
6227 }
6228 Some(config)
6229 }
6230
6231 #[must_use]
6232 pub fn project_context_pack_enabled(&self) -> bool {
6233 self.context.project_pack.unwrap_or(false)
6234 }
6235
6236 /// Return whether shell execution is allowed for noninteractive and
6237 /// durable-task profiles. Defaults to `false`: in headless, app-server, and
6238 /// background-task contexts there is no human to approve commands, so shell
6239 /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).
6240 #[must_use]
6241 pub fn allow_shell(&self) -> bool {
6242 self.allow_shell.unwrap_or(false)
6243 }
6244
6245 /// Return whether shell execution is allowed for an *interactive* TUI Agent
6246 /// session. Defaults to `true`: the interactive composer always gates each
6247 /// shell command behind an approval prompt, so the catalog can expose shell
6248 /// by default while still preserving consent (GHSA-72w5-pf8h-xfp4). An
6249 /// explicit `allow_shell = false` still hides shell tools. This is the
6250 /// single source of truth for the interactive default; both startup
6251 /// (`run_interactive`) and the durable Agent permission baseline read it so
6252 /// the default cannot drift between them.
6253 #[must_use]
6254 pub fn interactive_allow_shell(&self) -> bool {
6255 self.allow_shell.unwrap_or(true)
6256 }
6257
6258 /// Whether ghost-text prompt suggestion is enabled (opt-in, default off).
6259 pub fn prompt_suggestion_enabled(&self) -> bool {
6260 self.prompt_suggestion.unwrap_or(false)
6261 }
6262
6263 /// Return the maximum number of concurrent sub-agents.
6264 /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,
6265 /// then falls back to `DEFAULT_MAX_SUBAGENTS`.
6266 #[must_use]
6267 pub fn max_subagents(&self) -> usize {
6268 // Check [subagents] max_concurrent first
6269 if let Some(subagents_cfg) = self.subagents.as_ref()
6270 && let Some(max) = subagents_cfg.max_concurrent
6271 {
6272 return max.clamp(1, MAX_SUBAGENTS);
6273 }
6274 // Fall back to top-level max_subagents
6275 self.max_subagents
6276 .unwrap_or(DEFAULT_MAX_SUBAGENTS)
6277 .clamp(1, MAX_SUBAGENTS)
6278 }
6279
6280 /// Return the provider-specific maximum number of concurrent sub-agents.
6281 /// `[subagents.providers.<provider>] max_concurrent` inherits from the
6282 /// global `[subagents]` value when unset.
6283 #[must_use]
6284 pub fn max_subagents_for_provider(&self, provider: ApiProvider) -> usize {
6285 self.subagent_provider_config(provider)
6286 .and_then(|cfg| cfg.max_concurrent)
6287 .map(|max| max.clamp(1, MAX_SUBAGENTS))
6288 .unwrap_or_else(|| self.max_subagents())
6289 }
6290
6291 /// Whether the model-facing `agent` tool is available after applying the
6292 /// feature flag, explicit `[subagents] enabled` switch, and legacy
6293 /// zero-valued opt-outs.
6294 #[must_use]
6295 pub fn subagents_enabled(&self) -> bool {
6296 self.subagents_disabled_reason().is_none()
6297 }
6298
6299 /// Whether the model-facing `agent` tool is available for this provider
6300 /// after applying global and provider-specific sub-agent controls.
6301 #[must_use]
6302 pub fn subagents_enabled_for_provider(&self, provider: ApiProvider) -> bool {
6303 if !self.subagents_enabled() {
6304 return false;
6305 }
6306 let Some(provider_cfg) = self.subagent_provider_config(provider) else {
6307 return true;
6308 };
6309 provider_cfg.enabled != Some(false)
6310 && provider_cfg.max_concurrent != Some(0)
6311 && provider_cfg.max_depth != Some(0)
6312 }
6313
6314 /// Machine-readable reason sub-agents are disabled, in precedence order.
6315 #[must_use]
6316 pub fn subagents_disabled_reason(&self) -> Option<&'static str> {
6317 if !self.features().enabled(Feature::Subagents) {
6318 return Some("features.subagents=false");
6319 }
6320 let subagents_cfg = self.subagents.as_ref()?;
6321 if subagents_cfg.enabled == Some(false) {
6322 return Some("subagents.enabled=false");
6323 }
6324 if subagents_cfg.max_concurrent == Some(0) {
6325 return Some("subagents.max_concurrent=0");
6326 }
6327 if subagents_cfg.max_depth == Some(0) {
6328 return Some("subagents.max_depth=0");
6329 }
6330 None
6331 }
6332
6333 /// How many levels of nested sub-agents the interactive `agent` tool may
6334 /// spawn. Reads `[subagents] max_depth`; when unset it defaults to
6335 /// [`codewhale_config::DEFAULT_SPAWN_DEPTH`]. `0` is a valid value that
6336 /// blocks the `agent` tool at this runtime depth. Any value is clamped to
6337 /// [`codewhale_config::MAX_SPAWN_DEPTH_CEILING`] so the operator's choice
6338 /// can never exceed the hard recursion ceiling.
6339 #[must_use]
6340 pub fn subagent_max_spawn_depth(&self) -> u32 {
6341 self.subagents
6342 .as_ref()
6343 .and_then(|cfg| cfg.max_depth)
6344 .unwrap_or(codewhale_config::DEFAULT_SPAWN_DEPTH)
6345 .min(codewhale_config::MAX_SPAWN_DEPTH_CEILING)
6346 }
6347
6348 /// Return the provider-specific maximum sub-agent recursion depth.
6349 #[must_use]
6350 pub fn subagent_max_spawn_depth_for_provider(&self, provider: ApiProvider) -> u32 {
6351 self.subagent_provider_config(provider)
6352 .and_then(|cfg| cfg.max_depth)
6353 .unwrap_or_else(|| self.subagent_max_spawn_depth())
6354 .min(codewhale_config::MAX_SPAWN_DEPTH_CEILING)
6355 }
6356
6357 /// Number of direct (depth-1) sub-agents that may execute concurrently
6358 /// before further launches queue for a launch slot (#3095). Reads
6359 /// `[subagents] launch_concurrency` (or the deprecated
6360 /// `interactive_max_launch` alias); when unset it defaults to the full
6361 /// resolved `max_subagents()` (no artificial throttle), and any explicit
6362 /// value is clamped to `[1, max_subagents]`.
6363 #[must_use]
6364 pub fn launch_concurrency(&self) -> usize {
6365 let max = self.max_subagents();
6366 self.subagents
6367 .as_ref()
6368 .and_then(|cfg| cfg.launch_concurrency.or(cfg.interactive_max_launch_legacy))
6369 .unwrap_or(max)
6370 .clamp(1, max)
6371 }
6372
6373 /// Return the provider-specific direct launch throttle. Children above
6374 /// this limit queue for a launch slot instead of starting immediately.
6375 #[must_use]
6376 pub fn launch_concurrency_for_provider(&self, provider: ApiProvider) -> usize {
6377 let max = self.max_subagents_for_provider(provider);
6378 self.subagent_provider_config(provider)
6379 .and_then(|cfg| cfg.launch_concurrency)
6380 .or_else(|| {
6381 self.subagents
6382 .as_ref()
6383 .and_then(|cfg| cfg.launch_concurrency.or(cfg.interactive_max_launch_legacy))
6384 })
6385 .unwrap_or(max)
6386 .clamp(1, max)
6387 }
6388
6389 /// Maximum queued + running sub-agents admitted for the session.
6390 ///
6391 /// Defaults to [`MAX_SUBAGENT_ADMISSION`] so distinct `agent` calls can
6392 /// queue and drain through `launch_concurrency` instead of being rejected
6393 /// at the instantaneous concurrency cap. Explicit values are clamped to
6394 /// `[max_subagents, MAX_SUBAGENT_ADMISSION]`.
6395 #[must_use]
6396 pub fn max_admitted_subagents(&self) -> usize {
6397 let max_concurrent = self.max_subagents();
6398 self.subagents
6399 .as_ref()
6400 .and_then(|cfg| cfg.max_admitted)
6401 .unwrap_or(MAX_SUBAGENT_ADMISSION)
6402 .clamp(max_concurrent, MAX_SUBAGENT_ADMISSION)
6403 }
6404
6405 /// Return the provider-specific queued + running admission cap.
6406 #[must_use]
6407 pub fn max_admitted_subagents_for_provider(&self, provider: ApiProvider) -> usize {
6408 let max_concurrent = self.max_subagents_for_provider(provider);
6409 self.subagent_provider_config(provider)
6410 .and_then(|cfg| cfg.max_admitted)
6411 .or_else(|| self.subagents.as_ref().and_then(|cfg| cfg.max_admitted))
6412 .unwrap_or(MAX_SUBAGENT_ADMISSION)
6413 .clamp(max_concurrent, MAX_SUBAGENT_ADMISSION)
6414 }
6415
6416 /// Optional aggregate token budget for each root `agent` run.
6417 ///
6418 /// Reads `[subagents] token_budget`. `None` and `0` both mean unlimited,
6419 /// preserving legacy behavior until a budget is explicitly configured.
6420 #[must_use]
6421 pub fn subagent_token_budget(&self) -> Option<u64> {
6422 self.subagents
6423 .as_ref()
6424 .and_then(|cfg| cfg.token_budget)
6425 .filter(|budget| *budget > 0)
6426 }
6427
6428 /// Return the provider-specific aggregate token budget for each root
6429 /// `agent` run.
6430 #[must_use]
6431 pub fn subagent_token_budget_for_provider(&self, provider: ApiProvider) -> Option<u64> {
6432 self.subagent_provider_config(provider)
6433 .and_then(|cfg| cfg.token_budget)
6434 .or_else(|| self.subagents.as_ref().and_then(|cfg| cfg.token_budget))
6435 .filter(|budget| *budget > 0)
6436 }
6437
6438 /// Resolved per-step DeepSeek API timeout for sub-agents, in seconds.
6439 ///
6440 /// Reads `[subagents] api_timeout_secs` and clamps to
6441 /// `[MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS]`
6442 /// (1..=3600). `None` or `0` resolve to
6443 /// `DEFAULT_SUBAGENT_API_TIMEOUT_SECS` (600); explicit `1` is honored,
6444 /// useful only in fast fail-fast tests, not production (#1806, #1808).
6445 #[must_use]
6446 pub fn subagent_api_timeout_secs(&self) -> u64 {
6447 resolve_subagent_api_timeout_secs(
6448 self.subagents.as_ref().and_then(|cfg| cfg.api_timeout_secs),
6449 )
6450 }
6451
6452 /// Return the provider-specific per-step API timeout for sub-agents.
6453 #[must_use]
6454 pub fn subagent_api_timeout_secs_for_provider(&self, provider: ApiProvider) -> u64 {
6455 resolve_subagent_api_timeout_secs(
6456 self.subagent_provider_config(provider)
6457 .and_then(|cfg| cfg.api_timeout_secs)
6458 .or_else(|| self.subagents.as_ref().and_then(|cfg| cfg.api_timeout_secs)),
6459 )
6460 }
6461
6462 /// Resolved no-progress heartbeat timeout for running sub-agents.
6463 ///
6464 /// Reads `[subagents] heartbeat_timeout_secs` and clamps to
6465 /// `[MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS]`.
6466 /// `None` or `0` resolve to the default 300 seconds. The final value is
6467 /// also kept at least 30 seconds above `subagent_api_timeout_secs()` so a
6468 /// configured long model request is not pre-empted by heartbeat cleanup,
6469 /// and at least 30 seconds above the sub-agent tool timeout so a single
6470 /// long tool execution is not cancelled as "no progress" (2026-08-04
6471 /// sub-agent hunt, finding 4).
6472 #[must_use]
6473 pub fn subagent_heartbeat_timeout_secs(&self) -> u64 {
6474 resolve_subagent_heartbeat_timeout_secs(
6475 self.subagents
6476 .as_ref()
6477 .and_then(|cfg| cfg.heartbeat_timeout_secs),
6478 self.subagent_api_timeout_secs(),
6479 DEFAULT_SUBAGENT_TOOL_TIMEOUT_SECS,
6480 )
6481 }
6482
6483 /// Return the provider-specific no-progress heartbeat timeout.
6484 #[must_use]
6485 pub fn subagent_heartbeat_timeout_secs_for_provider(&self, provider: ApiProvider) -> u64 {
6486 let api_timeout = self.subagent_api_timeout_secs_for_provider(provider);
6487 resolve_subagent_heartbeat_timeout_secs(
6488 self.subagent_provider_config(provider)
6489 .and_then(|cfg| cfg.heartbeat_timeout_secs)
6490 .or_else(|| {
6491 self.subagents
6492 .as_ref()
6493 .and_then(|cfg| cfg.heartbeat_timeout_secs)
6494 }),
6495 api_timeout,
6496 DEFAULT_SUBAGENT_TOOL_TIMEOUT_SECS,
6497 )
6498 }
6499
6500 /// Resolved per-SSE-chunk idle timeout in seconds.
6501 ///
6502 /// Reads `[tui].stream_chunk_timeout_secs`, falling back to the
6503 /// `CODEWHALE_STREAM_IDLE_TIMEOUT_SECS` env var (legacy alias:
6504 /// `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS`) when the config key is
6505 /// omitted. `None` or `0` resolve to the default 900 seconds; explicit
6506 /// values are clamped to `1..=3600`.
6507 #[must_use]
6508 pub fn stream_chunk_timeout_secs(&self) -> u64 {
6509 let raw = self
6510 .tui
6511 .as_ref()
6512 .and_then(|cfg| cfg.stream_chunk_timeout_secs)
6513 .or_else(|| {
6514 std::env::var(STREAM_CHUNK_TIMEOUT_ENV)
6515 .or_else(|_| std::env::var(LEGACY_STREAM_CHUNK_TIMEOUT_ENV))
6516 .ok()
6517 .and_then(|value| value.parse::<u64>().ok())
6518 })
6519 .unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS);
6520 if raw == 0 {
6521 return DEFAULT_STREAM_CHUNK_TIMEOUT_SECS;
6522 }
6523 raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS)
6524 }
6525
6526 /// Raw sub-agent model override map. Values are validated at spawn time
6527 /// so an invalid role/type model fails before any partial agent spawn.
6528 #[must_use]
6529 pub fn subagent_model_overrides(&self) -> HashMap<String, String> {
6530 let mut overrides = HashMap::new();
6531 let Some(cfg) = self.subagents.as_ref() else {
6532 return overrides;
6533 };
6534
6535 let mut insert = |key: &str, value: &Option<String>| {
6536 if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
6537 overrides.insert(key.to_string(), model.to_string());
6538 }
6539 };
6540 insert("default", &cfg.default_model);
6541 insert("worker", &cfg.worker_model);
6542 insert("general", &cfg.worker_model);
6543 insert("scout", &cfg.explorer_model);
6544 insert("explorer", &cfg.explorer_model);
6545 insert("explore", &cfg.explorer_model);
6546 insert("planner", &cfg.awaiter_model);
6547 insert("awaiter", &cfg.awaiter_model);
6548 insert("plan", &cfg.awaiter_model);
6549 insert("reviewer", &cfg.review_model);
6550 insert("review", &cfg.review_model);
6551 insert("custom", &cfg.custom_model);
6552
6553 if let Some(models) = cfg.models.as_ref() {
6554 for (key, model) in models {
6555 let key = key.trim();
6556 let model = model.trim();
6557 if !key.is_empty() && !model.is_empty() {
6558 overrides.insert(key.to_ascii_lowercase(), model.to_string());
6559 }
6560 }
6561 }
6562
6563 overrides
6564 }
6565
6566 /// Parsed `[fleet]` table, or defaults when the table is absent
6567 /// (#fleet-roster cutover (v0.8.67)).
6568 #[must_use]
6569 pub fn fleet_config(&self) -> codewhale_config::FleetConfigToml {
6570 self.fleet.clone().unwrap_or_default()
6571 }
6572
6573 /// Parsed `[workflow]` table, or product defaults when the table is absent
6574 /// (#4128 / Section 2.11). Automatic launch, approval, isolation, and
6575 /// activity-persistence consumers should read through this accessor so
6576 /// omitted keys share one model.
6577 #[must_use]
6578 pub fn workflow_config(&self) -> codewhale_config::WorkflowConfigToml {
6579 self.workflow.clone().unwrap_or_default()
6580 }
6581
6582 /// Return the configured DeepSeek reasoning-effort tier, if any.
6583 #[must_use]
6584 pub fn reasoning_effort(&self) -> Option<&str> {
6585 self.reasoning_effort.as_deref()
6586 }
6587
6588 pub(crate) fn reasoning_effort_is_explicit(&self) -> bool {
6589 self.reasoning_effort.is_some() && !self.reasoning_effort_inferred_from_legacy_alias
6590 }
6591
6592 /// Get hooks configuration, returning default if not configured.
6593 pub fn hooks_config(&self) -> HooksConfig {
6594 self.hooks.clone().unwrap_or_default()
6595 }
6596
6597 /// Resolve the notifications configuration with defaults applied.
6598 #[must_use]
6599 pub fn notifications_config(&self) -> NotificationsConfig {
6600 self.notifications.clone().unwrap_or_default()
6601 }
6602
6603 /// Resolve workspace side-git snapshot settings with defaults applied.
6604 #[must_use]
6605 pub fn snapshots_config(&self) -> SnapshotsConfig {
6606 self.snapshots.clone().unwrap_or_default()
6607 }
6608
6609 /// Resolve community skill settings with defaults applied.
6610 #[must_use]
6611 pub fn skills_config(&self) -> SkillsConfig {
6612 self.skills.clone().unwrap_or_default()
6613 }
6614
6615 /// Resolve startup update-check settings with defaults applied.
6616 #[must_use]
6617 pub fn update_config(&self) -> UpdateConfig {
6618 self.update.clone().unwrap_or_default()
6619 }
6620
6621 /// Resolve durable hotbar bindings for render/dispatch layers.
6622 #[must_use]
6623 pub fn resolve_hotbar_bindings(
6624 &self,
6625 known_action_ids: &[&str],
6626 ) -> codewhale_config::HotbarConfigResolution {
6627 codewhale_config::resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids)
6628 }
6629
6630 /// Resolve enabled features from defaults and config entries.
6631 #[must_use]
6632 pub fn features(&self) -> Features {
6633 let mut features = Features::with_defaults();
6634 if let Some(table) = &self.features {
6635 features.apply_map(&table.entries);
6636 }
6637 features
6638 }
6639
6640 /// Override a feature flag in memory (used by CLI overrides).
6641 pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {
6642 if !is_known_feature_key(key) {
6643 anyhow::bail!("Unknown feature flag: {key}");
6644 }
6645 let table = self.features.get_or_insert_with(FeaturesToml::default);
6646 table.entries.insert(key.to_string(), enabled);
6647 Ok(())
6648 }
6649
6650 /// Resolve the effective retry policy with defaults applied.
6651 #[must_use]
6652 pub fn retry_policy(&self) -> RetryPolicy {
6653 let defaults = RetryPolicy {
6654 enabled: true,
6655 max_retries: 3,
6656 initial_delay: 1.0,
6657 max_delay: 60.0,
6658 exponential_base: 2.0,
6659 };
6660
6661 let Some(cfg) = &self.retry else {
6662 return defaults;
6663 };
6664
6665 RetryPolicy {
6666 enabled: cfg.enabled.unwrap_or(defaults.enabled),
6667 max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),
6668 initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),
6669 max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),
6670 exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),
6671 }
6672 }
6673 }
6674
6675 /// Controls whether configuration loading may copy secret-bearing environment
6676 /// values into the in-memory configuration.
6677 ///
6678 /// Structural diagnostics intentionally retain safe environment routing and
6679 /// policy fields while refusing values that could be secrets when later
6680 /// rendered or included in an error path.
6681 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
6682 enum ConfigEnvironmentPolicy {
6683 Runtime,
6684 StructuralDiagnostic,
6685 }
6686
6687 impl ConfigEnvironmentPolicy {
6688 const fn permits_secret_bearing_values(self) -> bool {
6689 matches!(self, Self::Runtime)
6690 }
6691 }
6692
6693 fn root_deepseek_model_is_foreign_to_direct_provider(provider: ApiProvider, model: &str) -> bool {
6694 if matches!(
6695 provider,
6696 ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic
6697 ) || provider_passes_model_through(provider)
6698 {
6699 return false;
6700 }
6701 if matches!(
6702 provider,
6703 ApiProvider::NvidiaNim
6704 | ApiProvider::Openrouter
6705 | ApiProvider::Novita
6706 | ApiProvider::Fireworks
6707 | ApiProvider::Siliconflow
6708 | ApiProvider::SiliconflowCn
6709 | ApiProvider::Deepinfra
6710 | ApiProvider::Together
6711 | ApiProvider::Sglang
6712 | ApiProvider::Vllm
6713 | ApiProvider::Volcengine
6714 | ApiProvider::Atlascloud
6715 | ApiProvider::OpencodeGo
6716 | ApiProvider::WanjieArk
6717 ) {
6718 return false;
6719 }
6720 normalize_model_name(model).is_some()
6721 }
6722
6723 // === Defaults ===
6724
6725 // Pure filesystem path helpers live in the `paths` leaf module. The two
6726 // `pub(crate)` entry points are re-exported so external `crate::config::`
6727 // callers resolve unchanged; the remaining helpers are imported privately for
6728 // the workspace-trust/config-load logic that stays in this file (#3311).
6729 mod home;
6730 mod paths;
6731 use paths::{
6732 canonicalize_or_keep, codewhale_home_dir, default_config_path, default_managed_config_path,
6733 default_mcp_config_path, default_memory_path, default_notes_path, default_requirements_path,
6734 default_skills_dir, env_config_path, expand_pathbuf, home_config_path, try_default_config_path,
6735 workspace_config_key,
6736 };
6737 pub(crate) use paths::{effective_home_dir, expand_path};
6738
6739 pub(crate) fn workspace_trust_config_candidate_paths() -> Vec<PathBuf> {
6740 match env_config_path() {
6741 Ok(Some(path)) => return vec![path],
6742 Ok(None) => {}
6743 Err(error) => {
6744 tracing::error!(
6745 error = %error,
6746 "invalid config path override; refusing workspace-trust fallback"
6747 );
6748 return Vec::new();
6749 }
6750 }
6751
6752 match codewhale_home_dir() {
6753 Ok(Some(codewhale_home)) => return vec![codewhale_home.join("config.toml")],
6754 Ok(None) => {}
6755 Err(error) => {
6756 tracing::error!(
6757 error = %error,
6758 "invalid Codewhale home override; refusing workspace-trust fallback"
6759 );
6760 return Vec::new();
6761 }
6762 }
6763
6764 let Some(home) = effective_home_dir() else {
6765 return Vec::new();
6766 };
6767 vec![
6768 home.join(".codewhale").join("config.toml"),
6769 home.join(".deepseek").join("config.toml"),
6770 ]
6771 }
6772
6773 #[must_use]
6774 pub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {
6775 let config_path = match default_config_path() {
6776 Ok(path) => path,
6777 Err(error) => {
6778 tracing::error!(
6779 error = %error,
6780 "failed to resolve workspace-trust config; treating workspace as untrusted"
6781 );
6782 return false;
6783 }
6784 };
6785 let Ok(raw) = fs::read_to_string(config_path) else {
6786 return false;
6787 };
6788 let Ok(doc) = toml::from_str::<toml::Value>(&raw) else {
6789 return false;
6790 };
6791 workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)
6792 }
6793
6794 pub(crate) fn save_workspace_trust(workspace: &Path) -> Result<PathBuf> {
6795 let config_path =
6796 try_default_config_path().context("Failed to resolve config path for workspace trust.")?;
6797 ensure_parent_dir(&config_path)?;
6798
6799 let project_key = workspace_config_key(workspace);
6800 crate::config_persistence::mutate_config_document(&config_path, |doc| {
6801 crate::config_persistence::set_document_value(
6802 doc,
6803 &["projects", project_key.as_str(), "trust_level"],
6804 "trusted",
6805 )
6806 })
6807 .with_context(|| format!("Failed to write config to {}", config_path.display()))?;
6808 Ok(config_path)
6809 }
6810
6811 fn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {
6812 let workspace = canonicalize_or_keep(workspace);
6813 // Trust records may sit at the top level or — from the historic
6814 // extras-nesting write bug (healed on mutation since 2026-07-23) — under
6815 // one or more literal `extras` tables. Read tolerantly so a not-yet-
6816 // healed config file still recognizes its trusted workspaces.
6817 let mut scope = Some(doc);
6818 while let Some(current) = scope {
6819 if let Some(projects) = current.get("projects").and_then(toml::Value::as_table) {
6820 for (raw_path, project) in projects {
6821 let project_path = canonicalize_or_keep(&expand_path(raw_path));
6822 if project_path == workspace {
6823 return project.get("trust_level").and_then(toml::Value::as_str);
6824 }
6825 }
6826 }
6827 scope = current.get("extras");
6828 }
6829 None
6830 }
6831
6832 fn is_trusted_level(level: &str) -> bool {
6833 level.trim().eq_ignore_ascii_case("trusted")
6834 }
6835
6836 pub(crate) fn resolve_load_config_path(path: Option<PathBuf>) -> Result<Option<PathBuf>> {
6837 if let Some(path) = path {
6838 return Ok(Some(expand_pathbuf(path)));
6839 }
6840
6841 #[cfg(test)]
6842 {
6843 let honor_guarded_environment = crate::test_support::current_thread_holds_test_env_lock();
6844 crate::test_support::with_test_env_lock(|| {
6845 if honor_guarded_environment {
6846 try_default_config_path().map(Some)
6847 } else {
6848 Ok(Some(
6849 crate::test_support::isolated_test_state_root()
6850 .join(codewhale_config::CONFIG_FILE_NAME),
6851 ))
6852 }
6853 })
6854 }
6855
6856 #[cfg(not(test))]
6857 try_default_config_path().map(Some)
6858 }
6859
6860 /// Create an inspectable config file on first interactive launch.
6861 ///
6862 /// The file intentionally omits `api_key`; onboarding or `codewhale auth set`
6863 /// writes that field after the user supplies a key.
6864 pub fn ensure_config_file_exists(path: Option<PathBuf>) -> Result<Option<PathBuf>> {
6865 let config_path = match path {
6866 Some(path) => expand_pathbuf(path),
6867 None => default_config_path().context("Failed to resolve config path.")?,
6868 };
6869 if config_path.exists() {
6870 return Ok(None);
6871 }
6872
6873 ensure_parent_dir(&config_path)?;
6874 let content = format!(
6875 r#"# codewhale Configuration
6876 # Get your API key from https://platform.deepseek.com
6877 # Save it with: codewhale auth set --provider deepseek
6878
6879 # Base URL (default: https://api.deepseek.com/beta)
6880 # Set https://api.deepseek.com to opt out of beta features.
6881 # base_url = "https://api.deepseek.com/beta"
6882
6883 # Default model
6884 default_text_model = "{DEFAULT_TEXT_MODEL}"
6885
6886 # Thinking mode (DeepSeek V4 reasoning effort):
6887 # "auto" | "off" | "low" | "medium" | "high" | "max"
6888 # Shift+Tab in the TUI cycles between off / high / max.
6889 reasoning_effort = "auto"
6890
6891 # Startup update check
6892 [update]
6893 check_for_updates = true
6894 # update_uri = "https://internal.mirror.example/codewhale/releases/latest"
6895 "#
6896 );
6897 write_config_file_secure(&config_path, &content)
6898 .with_context(|| format!("Failed to write config to {}", config_path.display()))?;
6899 Ok(Some(config_path))
6900 }
6901
6902 // === Environment Overrides ===
6903
6904 /// Read the `DEEPSEEK_BASE_URL` / `CODEWHALE_BASE_URL` env var that the CLI
6905 /// dispatcher forwards from `--base-url`. Returns `None` when the var is
6906 /// absent or empty so that provider-specific defaults still apply.
6907 fn env_base_url_override() -> Option<String> {
6908 codewhale_env_var("CODEWHALE_BASE_URL", "DEEPSEEK_BASE_URL")
6909 .ok()
6910 .filter(|v| !v.trim().is_empty())
6911 }
6912
6913 fn first_nonempty_env(names: &[&str]) -> Option<String> {
6914 let read = || {
6915 names.iter().find_map(|name| {
6916 std::env::var(name)
6917 .ok()
6918 .filter(|value| !value.trim().is_empty())
6919 })
6920 };
6921 #[cfg(test)]
6922 {
6923 crate::test_support::with_test_env_lock(read)
6924 }
6925 #[cfg(not(test))]
6926 {
6927 read()
6928 }
6929 }
6930
6931 /// Return the provider-scoped endpoint override that `apply_env_overrides`
6932 /// will apply to the active route. This is intentionally kept beside the
6933 /// mutation code: after the write, a provider-table `base_url` no longer
6934 /// carries enough information to distinguish a file-owned route from an
6935 /// environment-selected host.
6936 fn provider_env_base_url_override(provider: ApiProvider) -> Option<String> {
6937 let names: &[&str] = match provider {
6938 ApiProvider::NvidiaNim => &["NVIDIA_NIM_BASE_URL", "NIM_BASE_URL", "NVIDIA_BASE_URL"],
6939 ApiProvider::Openai => &["OPENAI_BASE_URL"],
6940 ApiProvider::Atlascloud => &["ATLASCLOUD_BASE_URL"],
6941 ApiProvider::Openrouter => &["OPENROUTER_BASE_URL"],
6942 ApiProvider::XiaomiMimo => &["XIAOMI_MIMO_BASE_URL", "MIMO_BASE_URL"],
6943 ApiProvider::WanjieArk => &[
6944 "WANJIE_ARK_BASE_URL",
6945 "WANJIE_BASE_URL",
6946 "WANJIE_MAAS_BASE_URL",
6947 ],
6948 ApiProvider::Volcengine => &[
6949 "VOLCENGINE_BASE_URL",
6950 "VOLCENGINE_ARK_BASE_URL",
6951 "ARK_BASE_URL",
6952 ],
6953 ApiProvider::Novita => &["NOVITA_BASE_URL"],
6954 ApiProvider::Fireworks => &["FIREWORKS_BASE_URL"],
6955 ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => &["SILICONFLOW_BASE_URL"],
6956 ApiProvider::Arcee => &["ARCEE_BASE_URL"],
6957 ApiProvider::Moonshot => &["MOONSHOT_BASE_URL", "KIMI_BASE_URL"],
6958 ApiProvider::Sglang => &["SGLANG_BASE_URL"],
6959 ApiProvider::Vllm => &["VLLM_BASE_URL"],
6960 ApiProvider::Ollama => &["OLLAMA_BASE_URL"],
6961 ApiProvider::Huggingface => &["HUGGINGFACE_BASE_URL", "HF_BASE_URL"],
6962 ApiProvider::Meta => &["META_MODEL_API_BASE_URL", "MODEL_API_BASE_URL"],
6963 ApiProvider::Xai => &["XAI_BASE_URL"],
6964 ApiProvider::Telecomjs => &["TELECOMJS_BASE_URL"],
6965 ApiProvider::ModelstudioTokenPlan | ApiProvider::ModelstudioTokenPlanAnthropic => {
6966 &["MODELSTUDIO_TOKEN_PLAN_BASE_URL"]
6967 }
6968 ApiProvider::ModelstudioCodingPlan | ApiProvider::ModelstudioCodingPlanAnthropic => {
6969 &["MODELSTUDIO_CODING_PLAN_BASE_URL"]
6970 }
6971 ApiProvider::OpencodeGo => &["OPENCODE_GO_BASE_URL"],
6972 ApiProvider::OpencodeZen => &["OPENCODE_ZEN_BASE_URL"],
6973 ApiProvider::Deepseek
6974 | ApiProvider::DeepseekCN
6975 | ApiProvider::DeepseekAnthropic
6976 | ApiProvider::Anthropic
6977 | ApiProvider::Openmodel
6978 | ApiProvider::Deepinfra
6979 | ApiProvider::Together
6980 | ApiProvider::Qianfan
6981 | ApiProvider::OpenaiCodex
6982 | ApiProvider::Zai
6983 | ApiProvider::Stepfun
6984 | ApiProvider::Minimax
6985 | ApiProvider::MinimaxAnthropic
6986 | ApiProvider::Sakana
6987 | ApiProvider::LongCat
6988 | ApiProvider::Custom => &[],
6989 };
6990 first_nonempty_env(names)
6991 }
6992
6993 /// Resolve an env var, preferring the `CODEWHALE_*` form over the
6994 /// legacy `DEEPSEEK_*` form. Empty values are ignored so a blank shell export
6995 /// does not erase configured provider settings.
6996 fn codewhale_env_var(
6997 codewhale_name: &str,
6998 legacy_name: &str,
6999 ) -> Result<String, std::env::VarError> {
7000 let read = || {
7001 std::env::var(codewhale_name)
7002 .ok()
7003 .filter(|value| !value.trim().is_empty())
7004 .or_else(|| {
7005 std::env::var(legacy_name)
7006 .ok()
7007 .filter(|value| !value.trim().is_empty())
7008 })
7009 .ok_or(std::env::VarError::NotPresent)
7010 };
7011 #[cfg(test)]
7012 {
7013 crate::test_support::with_test_env_lock(read)
7014 }
7015 #[cfg(not(test))]
7016 {
7017 read()
7018 }
7019 }
7020
7021 fn apply_env_overrides(config: &mut Config, policy: ConfigEnvironmentPolicy) {
7022 #[cfg(test)]
7023 {
7024 crate::test_support::with_test_env_lock(|| {
7025 apply_env_overrides_unlocked(config, policy);
7026 })
7027 }
7028 #[cfg(not(test))]
7029 {
7030 apply_env_overrides_unlocked(config, policy);
7031 }
7032 }
7033
7034 fn apply_env_overrides_unlocked(config: &mut Config, policy: ConfigEnvironmentPolicy) {
7035 if let Ok(value) = codewhale_env_var("CODEWHALE_PROVIDER", "DEEPSEEK_PROVIDER") {
7036 config.provider = Some(value);
7037 }
7038 let active_base_url_from_env = env_base_url_override().is_some()
7039 || provider_env_base_url_override(config.api_provider()).is_some();
7040 if let Ok(value) = codewhale_env_var("CODEWHALE_BASE_URL", "DEEPSEEK_BASE_URL") {
7041 match config.api_provider() {
7042 ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
7043 // DeepSeek and DeepSeek-CN share this one legacy root field.
7044 // Record which of them the environment addressed so the
7045 // sibling identity cannot inherit the value, while a
7046 // file-owned root (no owner recorded) stays shared.
7047 config.base_url = Some(value);
7048 // Resolve the owner *after* the write: the root value is one
7049 // of the inputs `api_provider()` sniffs, so the effective
7050 // identity is the post-write one, matching the receipt
7051 // recorded at the end of this function.
7052 let owner = config.api_provider();
7053 config.root_base_url_owner =
7054 BaseUrlEnvReceipt::Route(owner, config.provider_identity_for(owner));
7055 }
7056 ApiProvider::DeepseekAnthropic => {
7057 config
7058 .providers
7059 .get_or_insert_with(ProvidersConfig::default)
7060 .deepseek_anthropic
7061 .base_url = Some(value);
7062 }
7063 ApiProvider::NvidiaNim => {
7064 config
7065 .providers
7066 .get_or_insert_with(ProvidersConfig::default)
7067 .nvidia_nim
7068 .base_url = Some(value);
7069 }
7070 ApiProvider::Openai => {
7071 config
7072 .providers
7073 .get_or_insert_with(ProvidersConfig::default)
7074 .openai
7075 .base_url = Some(value);
7076 }
7077 ApiProvider::Anthropic => {
7078 config
7079 .providers
7080 .get_or_insert_with(ProvidersConfig::default)
7081 .anthropic
7082 .base_url = Some(value);
7083 }
7084 ApiProvider::Openmodel => {
7085 config
7086 .providers
7087 .get_or_insert_with(ProvidersConfig::default)
7088 .openmodel
7089 .base_url = Some(value);
7090 }
7091 ApiProvider::Openrouter => {
7092 config
7093 .providers
7094 .get_or_insert_with(ProvidersConfig::default)
7095 .openrouter
7096 .base_url = Some(value);
7097 }
7098 ApiProvider::XiaomiMimo => {
7099 config
7100 .providers
7101 .get_or_insert_with(ProvidersConfig::default)
7102 .xiaomi_mimo
7103 .base_url = Some(value);
7104 }
7105 ApiProvider::WanjieArk => {
7106 config
7107 .providers
7108 .get_or_insert_with(ProvidersConfig::default)
7109 .wanjie_ark
7110 .base_url = Some(value);
7111 }
7112 ApiProvider::Novita => {
7113 config
7114 .providers
7115 .get_or_insert_with(ProvidersConfig::default)
7116 .novita
7117 .base_url = Some(value);
7118 }
7119 ApiProvider::Fireworks => {
7120 config
7121 .providers
7122 .get_or_insert_with(ProvidersConfig::default)
7123 .fireworks
7124 .base_url = Some(value);
7125 }
7126 ApiProvider::Siliconflow => {
7127 config
7128 .providers
7129 .get_or_insert_with(ProvidersConfig::default)
7130 .siliconflow
7131 .base_url = Some(value);
7132 }
7133 ApiProvider::SiliconflowCn => {
7134 config
7135 .providers
7136 .get_or_insert_with(ProvidersConfig::default)
7137 .siliconflow_cn
7138 .base_url = Some(value);
7139 }
7140 ApiProvider::Arcee => {
7141 config
7142 .providers
7143 .get_or_insert_with(ProvidersConfig::default)
7144 .arcee
7145 .base_url = Some(value);
7146 }
7147 ApiProvider::Moonshot => {
7148 config
7149 .providers
7150 .get_or_insert_with(ProvidersConfig::default)
7151 .moonshot
7152 .base_url = Some(value);
7153 }
7154 ApiProvider::Sglang => {
7155 config
7156 .providers
7157 .get_or_insert_with(ProvidersConfig::default)
7158 .sglang
7159 .base_url = Some(value);
7160 }
7161 ApiProvider::Vllm => {
7162 config
7163 .providers
7164 .get_or_insert_with(ProvidersConfig::default)
7165 .vllm
7166 .base_url = Some(value);
7167 }
7168 ApiProvider::Ollama => {
7169 config
7170 .providers
7171 .get_or_insert_with(ProvidersConfig::default)
7172 .ollama
7173 .base_url = Some(value);
7174 }
7175 ApiProvider::Volcengine => {
7176 config
7177 .providers
7178 .get_or_insert_with(ProvidersConfig::default)
7179 .volcengine
7180 .base_url = Some(value);
7181 }
7182 ApiProvider::Atlascloud => {
7183 config
7184 .providers
7185 .get_or_insert_with(ProvidersConfig::default)
7186 .atlascloud
7187 .base_url = Some(value);
7188 }
7189 ApiProvider::Huggingface => {
7190 config
7191 .providers
7192 .get_or_insert_with(ProvidersConfig::default)
7193 .huggingface
7194 .base_url = Some(value);
7195 }
7196 ApiProvider::Deepinfra => {
7197 config
7198 .providers
7199 .get_or_insert_with(ProvidersConfig::default)
7200 .deepinfra
7201 .base_url = Some(value);
7202 }
7203 ApiProvider::Together => {
7204 config
7205 .providers
7206 .get_or_insert_with(ProvidersConfig::default)
7207 .together
7208 .base_url = Some(value);
7209 }
7210 ApiProvider::Qianfan => {
7211 config
7212 .providers
7213 .get_or_insert_with(ProvidersConfig::default)
7214 .qianfan
7215 .base_url = Some(value);
7216 }
7217 ApiProvider::OpenaiCodex => {
7218 config
7219 .providers
7220 .get_or_insert_with(ProvidersConfig::default)
7221 .openai_codex
7222 .base_url = Some(value);
7223 }
7224 ApiProvider::Zai => {
7225 config
7226 .providers
7227 .get_or_insert_with(ProvidersConfig::default)
7228 .zai
7229 .base_url = Some(value);
7230 }
7231 ApiProvider::Stepfun => {
7232 config
7233 .providers
7234 .get_or_insert_with(ProvidersConfig::default)
7235 .stepfun
7236 .base_url = Some(value);
7237 }
7238 ApiProvider::Minimax => {
7239 config
7240 .providers
7241 .get_or_insert_with(ProvidersConfig::default)
7242 .minimax
7243 .base_url = Some(value);
7244 }
7245 ApiProvider::MinimaxAnthropic => {
7246 config
7247 .providers
7248 .get_or_insert_with(ProvidersConfig::default)
7249 .minimax_anthropic
7250 .base_url = Some(value);
7251 }
7252 ApiProvider::Sakana => {
7253 config
7254 .providers
7255 .get_or_insert_with(ProvidersConfig::default)
7256 .sakana
7257 .base_url = Some(value);
7258 }
7259 ApiProvider::LongCat => {
7260 config
7261 .providers
7262 .get_or_insert_with(ProvidersConfig::default)
7263 .longcat
7264 .base_url = Some(value);
7265 }
7266 ApiProvider::OpencodeGo => {
7267 config
7268 .providers
7269 .get_or_insert_with(ProvidersConfig::default)
7270 .opencode_go
7271 .base_url = Some(value);
7272 }
7273 ApiProvider::OpencodeZen => {
7274 config
7275 .providers
7276 .get_or_insert_with(ProvidersConfig::default)
7277 .opencode_zen
7278 .base_url = Some(value);
7279 }
7280 ApiProvider::Meta => {
7281 config
7282 .providers
7283 .get_or_insert_with(ProvidersConfig::default)
7284 .meta
7285 .base_url = Some(value);
7286 }
7287 ApiProvider::Xai => {
7288 config
7289 .providers
7290 .get_or_insert_with(ProvidersConfig::default)
7291 .xai
7292 .base_url = Some(value);
7293 }
7294 ApiProvider::Telecomjs => {
7295 config
7296 .providers
7297 .get_or_insert_with(ProvidersConfig::default)
7298 .telecomjs
7299 .base_url = Some(value);
7300 }
7301 ApiProvider::ModelstudioTokenPlan => {
7302 config
7303 .providers
7304 .get_or_insert_with(ProvidersConfig::default)
7305 .modelstudio_token_plan
7306 .base_url = Some(value);
7307 }
7308 ApiProvider::ModelstudioTokenPlanAnthropic => {
7309 config
7310 .providers
7311 .get_or_insert_with(ProvidersConfig::default)
7312 .modelstudio_token_plan_anthropic
7313 .base_url = Some(value);
7314 }
7315 ApiProvider::ModelstudioCodingPlan => {
7316 config
7317 .providers
7318 .get_or_insert_with(ProvidersConfig::default)
7319 .modelstudio_coding_plan
7320 .base_url = Some(value);
7321 }
7322 ApiProvider::ModelstudioCodingPlanAnthropic => {
7323 config
7324 .providers
7325 .get_or_insert_with(ProvidersConfig::default)
7326 .modelstudio_coding_plan_anthropic
7327 .base_url = Some(value);
7328 }
7329 // Custom resolves to the named `[providers.<name>]` table; route the
7330 // override through the exact route while retaining the released
7331 // root-literal custom storage shape (#1519, #4334).
7332 ApiProvider::Custom => {
7333 config.set_provider_base_url_override(ApiProvider::Custom, Some(value));
7334 }
7335 }
7336 }
7337 if matches!(config.api_provider(), ApiProvider::NvidiaNim)
7338 && let Ok(value) = std::env::var("NVIDIA_NIM_BASE_URL")
7339 .or_else(|_| std::env::var("NIM_BASE_URL"))
7340 .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
7341 {
7342 config
7343 .providers
7344 .get_or_insert_with(ProvidersConfig::default)
7345 .nvidia_nim
7346 .base_url = Some(value);
7347 }
7348 // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on
7349 // their own provider entry — the legacy root `base_url` keeps DeepSeek-only
7350 // semantics.
7351 if matches!(config.api_provider(), ApiProvider::Openai)
7352 && let Ok(value) = std::env::var("OPENAI_BASE_URL")
7353 && !value.trim().is_empty()
7354 {
7355 config
7356 .providers
7357 .get_or_insert_with(ProvidersConfig::default)
7358 .openai
7359 .base_url = Some(value);
7360 }
7361 if matches!(config.api_provider(), ApiProvider::Atlascloud)
7362 && let Ok(value) = std::env::var("ATLASCLOUD_BASE_URL")
7363 && !value.trim().is_empty()
7364 {
7365 config
7366 .providers
7367 .get_or_insert_with(ProvidersConfig::default)
7368 .atlascloud
7369 .base_url = Some(value);
7370 }
7371 if matches!(config.api_provider(), ApiProvider::Openrouter)
7372 && let Ok(value) = std::env::var("OPENROUTER_BASE_URL")
7373 && !value.trim().is_empty()
7374 {
7375 config
7376 .providers
7377 .get_or_insert_with(ProvidersConfig::default)
7378 .openrouter
7379 .base_url = Some(value);
7380 }
7381 if matches!(config.api_provider(), ApiProvider::XiaomiMimo)
7382 && let Ok(value) =
7383 std::env::var("XIAOMI_MIMO_BASE_URL").or_else(|_| std::env::var("MIMO_BASE_URL"))
7384 && !value.trim().is_empty()
7385 {
7386 config
7387 .providers
7388 .get_or_insert_with(ProvidersConfig::default)
7389 .xiaomi_mimo
7390 .base_url = Some(value);
7391 }
7392 if matches!(config.api_provider(), ApiProvider::XiaomiMimo)
7393 && let Ok(value) = std::env::var("XIAOMI_MIMO_MODE").or_else(|_| std::env::var("MIMO_MODE"))
7394 && !value.trim().is_empty()
7395 {
7396 config
7397 .providers
7398 .get_or_insert_with(ProvidersConfig::default)
7399 .xiaomi_mimo
7400 .mode = Some(value);
7401 }
7402 if matches!(config.api_provider(), ApiProvider::WanjieArk)
7403 && let Ok(value) = std::env::var("WANJIE_ARK_BASE_URL")
7404 .or_else(|_| std::env::var("WANJIE_BASE_URL"))
7405 .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
7406 && !value.trim().is_empty()
7407 {
7408 config
7409 .providers
7410 .get_or_insert_with(ProvidersConfig::default)
7411 .wanjie_ark
7412 .base_url = Some(value);
7413 }
7414 if matches!(config.api_provider(), ApiProvider::Volcengine)
7415 && let Ok(value) = std::env::var("VOLCENGINE_BASE_URL")
7416 .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
7417 .or_else(|_| std::env::var("ARK_BASE_URL"))
7418 && !value.trim().is_empty()
7419 {
7420 config
7421 .providers
7422 .get_or_insert_with(ProvidersConfig::default)
7423 .volcengine
7424 .base_url = Some(value);
7425 }
7426 if matches!(config.api_provider(), ApiProvider::Novita)
7427 && let Ok(value) = std::env::var("NOVITA_BASE_URL")
7428 && !value.trim().is_empty()
7429 {
7430 config
7431 .providers
7432 .get_or_insert_with(ProvidersConfig::default)
7433 .novita
7434 .base_url = Some(value);
7435 }
7436 if matches!(config.api_provider(), ApiProvider::Fireworks)
7437 && let Ok(value) = std::env::var("FIREWORKS_BASE_URL")
7438 && !value.trim().is_empty()
7439 {
7440 config
7441 .providers
7442 .get_or_insert_with(ProvidersConfig::default)
7443 .fireworks
7444 .base_url = Some(value);
7445 }
7446 let active_provider = config.api_provider();
7447 if matches!(
7448 active_provider,
7449 ApiProvider::Siliconflow | ApiProvider::SiliconflowCn
7450 ) && let Ok(value) = std::env::var("SILICONFLOW_BASE_URL")
7451 && !value.trim().is_empty()
7452 {
7453 config.provider_config_for_mut(active_provider).base_url = Some(value);
7454 }
7455 if matches!(config.api_provider(), ApiProvider::Arcee)
7456 && let Ok(value) = std::env::var("ARCEE_BASE_URL")
7457 && !value.trim().is_empty()
7458 {
7459 config
7460 .providers
7461 .get_or_insert_with(ProvidersConfig::default)
7462 .arcee
7463 .base_url = Some(value);
7464 }
7465 if matches!(config.api_provider(), ApiProvider::Huggingface)
7466 && let Ok(value) =
7467 std::env::var("HUGGINGFACE_BASE_URL").or_else(|_| std::env::var("HF_BASE_URL"))
7468 && !value.trim().is_empty()
7469 {
7470 config
7471 .providers
7472 .get_or_insert_with(ProvidersConfig::default)
7473 .huggingface
7474 .base_url = Some(value);
7475 }
7476 if matches!(config.api_provider(), ApiProvider::Moonshot)
7477 && let Ok(value) =
7478 std::env::var("MOONSHOT_BASE_URL").or_else(|_| std::env::var("KIMI_BASE_URL"))
7479 && !value.trim().is_empty()
7480 {
7481 config
7482 .providers
7483 .get_or_insert_with(ProvidersConfig::default)
7484 .moonshot
7485 .base_url = Some(value);
7486 }
7487 if matches!(config.api_provider(), ApiProvider::Sglang)
7488 && let Ok(value) = std::env::var("SGLANG_BASE_URL")
7489 && !value.trim().is_empty()
7490 {
7491 config
7492 .providers
7493 .get_or_insert_with(ProvidersConfig::default)
7494 .sglang
7495 .base_url = Some(value);
7496 }
7497 if matches!(config.api_provider(), ApiProvider::Vllm)
7498 && let Ok(value) = std::env::var("VLLM_BASE_URL")
7499 && !value.trim().is_empty()
7500 {
7501 config
7502 .providers
7503 .get_or_insert_with(ProvidersConfig::default)
7504 .vllm
7505 .base_url = Some(value);
7506 }
7507 if matches!(config.api_provider(), ApiProvider::Meta)
7508 && let Ok(value) = std::env::var("META_MODEL_API_BASE_URL")
7509 .or_else(|_| std::env::var("MODEL_API_BASE_URL"))
7510 && !value.trim().is_empty()
7511 {
7512 config
7513 .providers
7514 .get_or_insert_with(ProvidersConfig::default)
7515 .meta
7516 .base_url = Some(value);
7517 }
7518 if matches!(config.api_provider(), ApiProvider::Xai)
7519 && let Ok(value) = std::env::var("XAI_BASE_URL")
7520 && !value.trim().is_empty()
7521 {
7522 config
7523 .providers
7524 .get_or_insert_with(ProvidersConfig::default)
7525 .xai
7526 .base_url = Some(value);
7527 }
7528 if matches!(config.api_provider(), ApiProvider::Telecomjs)
7529 && let Ok(value) = std::env::var("TELECOMJS_BASE_URL")
7530 && !value.trim().is_empty()
7531 {
7532 config
7533 .providers
7534 .get_or_insert_with(ProvidersConfig::default)
7535 .telecomjs
7536 .base_url = Some(value);
7537 }
7538 if matches!(
7539 config.api_provider(),
7540 ApiProvider::ModelstudioTokenPlan | ApiProvider::ModelstudioTokenPlanAnthropic
7541 ) && let Ok(value) = std::env::var("MODELSTUDIO_TOKEN_PLAN_BASE_URL")
7542 && !value.trim().is_empty()
7543 {
7544 let field = if config.api_provider() == ApiProvider::ModelstudioTokenPlanAnthropic {
7545 &mut config
7546 .providers
7547 .get_or_insert_with(ProvidersConfig::default)
7548 .modelstudio_token_plan_anthropic
7549 .base_url
7550 } else {
7551 &mut config
7552 .providers
7553 .get_or_insert_with(ProvidersConfig::default)
7554 .modelstudio_token_plan
7555 .base_url
7556 };
7557 *field = Some(value);
7558 }
7559 if matches!(
7560 config.api_provider(),
7561 ApiProvider::ModelstudioCodingPlan | ApiProvider::ModelstudioCodingPlanAnthropic
7562 ) && let Ok(value) = std::env::var("MODELSTUDIO_CODING_PLAN_BASE_URL")
7563 && !value.trim().is_empty()
7564 {
7565 let field = if config.api_provider() == ApiProvider::ModelstudioCodingPlanAnthropic {
7566 &mut config
7567 .providers
7568 .get_or_insert_with(ProvidersConfig::default)
7569 .modelstudio_coding_plan_anthropic
7570 .base_url
7571 } else {
7572 &mut config
7573 .providers
7574 .get_or_insert_with(ProvidersConfig::default)
7575 .modelstudio_coding_plan
7576 .base_url
7577 };
7578 *field = Some(value);
7579 }
7580 if policy.permits_secret_bearing_values()
7581 && let Ok(value) = std::env::var("CODEWHALE_HTTP_HEADERS")
7582 .or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
7583 && let Ok(headers) = parse_http_headers(&value)
7584 && !headers.is_empty()
7585 {
7586 let mut root_headers = config.http_headers.clone().unwrap_or_default();
7587 root_headers.extend(headers.clone());
7588 config.http_headers = Some(root_headers);
7589
7590 let provider = config.api_provider();
7591 // Root headers are the canonical header slot for a released literal
7592 // custom route. Creating `[providers.custom]` here would make the route
7593 // ambiguous and disconnect its root endpoint, model, and credential.
7594 if !(provider == ApiProvider::Custom && config.uses_legacy_literal_custom_route()) {
7595 // Capture the custom entry key (the selected provider name) before
7596 // the mutable borrow of `providers` below (#1519).
7597 let custom_key = (provider == ApiProvider::Custom).then(|| {
7598 config
7599 .provider
7600 .clone()
7601 .unwrap_or_else(|| "__custom__".to_string())
7602 });
7603 let providers = config
7604 .providers
7605 .get_or_insert_with(ProvidersConfig::default);
7606 let entry = match provider {
7607 ApiProvider::Deepseek => &mut providers.deepseek,
7608 ApiProvider::DeepseekCN => &mut providers.deepseek_cn,
7609 ApiProvider::DeepseekAnthropic => &mut providers.deepseek_anthropic,
7610 ApiProvider::NvidiaNim => &mut providers.nvidia_nim,
7611 ApiProvider::Openai => &mut providers.openai,
7612 ApiProvider::Atlascloud => &mut providers.atlascloud,
7613 ApiProvider::WanjieArk => &mut providers.wanjie_ark,
7614 ApiProvider::Openrouter => &mut providers.openrouter,
7615 ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo,
7616 ApiProvider::Novita => &mut providers.novita,
7617 ApiProvider::Fireworks => &mut providers.fireworks,
7618 ApiProvider::Siliconflow => &mut providers.siliconflow,
7619 ApiProvider::SiliconflowCn => &mut providers.siliconflow_cn,
7620 ApiProvider::Arcee => &mut providers.arcee,
7621 ApiProvider::Moonshot => &mut providers.moonshot,
7622 ApiProvider::Sglang => &mut providers.sglang,
7623 ApiProvider::Vllm => &mut providers.vllm,
7624 ApiProvider::Ollama => &mut providers.ollama,
7625 ApiProvider::Volcengine => &mut providers.volcengine,
7626 ApiProvider::Huggingface => &mut providers.huggingface,
7627 ApiProvider::Deepinfra => &mut providers.deepinfra,
7628 ApiProvider::Together => &mut providers.together,
7629 ApiProvider::Qianfan => &mut providers.qianfan,
7630 ApiProvider::OpenaiCodex => &mut providers.openai_codex,
7631 ApiProvider::Anthropic => &mut providers.anthropic,
7632 ApiProvider::Openmodel => &mut providers.openmodel,
7633 ApiProvider::Zai => &mut providers.zai,
7634 ApiProvider::Stepfun => &mut providers.stepfun,
7635 ApiProvider::Minimax => &mut providers.minimax,
7636 ApiProvider::MinimaxAnthropic => &mut providers.minimax_anthropic,
7637 ApiProvider::Sakana => &mut providers.sakana,
7638 ApiProvider::LongCat => &mut providers.longcat,
7639 ApiProvider::OpencodeGo => &mut providers.opencode_go,
7640 ApiProvider::OpencodeZen => &mut providers.opencode_zen,
7641 ApiProvider::Meta => &mut providers.meta,
7642 ApiProvider::Xai => &mut providers.xai,
7643 ApiProvider::Telecomjs => &mut providers.telecomjs,
7644 ApiProvider::ModelstudioTokenPlan => &mut providers.modelstudio_token_plan,
7645 ApiProvider::ModelstudioTokenPlanAnthropic => {
7646 &mut providers.modelstudio_token_plan_anthropic
7647 }
7648 ApiProvider::ModelstudioCodingPlan => &mut providers.modelstudio_coding_plan,
7649 ApiProvider::ModelstudioCodingPlanAnthropic => {
7650 &mut providers.modelstudio_coding_plan_anthropic
7651 }
7652 ApiProvider::Custom => providers
7653 .custom
7654 .entry(custom_key.unwrap_or_else(|| "__custom__".to_string()))
7655 .or_default(),
7656 };
7657 let mut provider_headers = entry.http_headers.clone().unwrap_or_default();
7658 provider_headers.extend(headers);
7659 entry.http_headers = Some(provider_headers);
7660 }
7661 }
7662 if matches!(config.api_provider(), ApiProvider::Ollama)
7663 && let Ok(value) = std::env::var("OLLAMA_BASE_URL")
7664 && !value.trim().is_empty()
7665 {
7666 config
7667 .providers
7668 .get_or_insert_with(ProvidersConfig::default)
7669 .ollama
7670 .base_url = Some(value);
7671 }
7672 if matches!(config.api_provider(), ApiProvider::Sglang)
7673 && let Ok(value) = std::env::var("SGLANG_MODEL")
7674 {
7675 config.default_text_model = Some(value);
7676 }
7677 if matches!(config.api_provider(), ApiProvider::Vllm)
7678 && let Ok(value) = std::env::var("VLLM_MODEL")
7679 {
7680 config.default_text_model = Some(value);
7681 }
7682 if matches!(config.api_provider(), ApiProvider::Ollama)
7683 && let Ok(value) = std::env::var("OLLAMA_MODEL")
7684 {
7685 config.default_text_model = Some(value);
7686 }
7687 if matches!(config.api_provider(), ApiProvider::Openai)
7688 && let Ok(value) = std::env::var("OPENAI_MODEL")
7689 {
7690 config
7691 .providers
7692 .get_or_insert_with(ProvidersConfig::default)
7693 .openai
7694 .model = Some(value);
7695 }
7696 if matches!(config.api_provider(), ApiProvider::XiaomiMimo)
7697 && let Ok(value) =
7698 std::env::var("XIAOMI_MIMO_MODEL").or_else(|_| std::env::var("MIMO_MODEL"))
7699 {
7700 config
7701 .providers
7702 .get_or_insert_with(ProvidersConfig::default)
7703 .xiaomi_mimo
7704 .model = Some(value);
7705 }
7706 if matches!(config.api_provider(), ApiProvider::Atlascloud)
7707 && let Ok(value) = std::env::var("ATLASCLOUD_MODEL")
7708 {
7709 config.default_text_model = Some(value);
7710 }
7711 if matches!(config.api_provider(), ApiProvider::WanjieArk)
7712 && let Ok(value) = std::env::var("WANJIE_ARK_MODEL")
7713 .or_else(|_| std::env::var("WANJIE_MODEL"))
7714 .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
7715 && !value.trim().is_empty()
7716 {
7717 config
7718 .providers
7719 .get_or_insert_with(ProvidersConfig::default)
7720 .wanjie_ark
7721 .model = Some(value);
7722 }
7723 if matches!(config.api_provider(), ApiProvider::Openrouter)
7724 && let Ok(value) = std::env::var("OPENROUTER_MODEL")
7725 && !value.trim().is_empty()
7726 {
7727 config
7728 .providers
7729 .get_or_insert_with(ProvidersConfig::default)
7730 .openrouter
7731 .model = Some(value);
7732 }
7733 if matches!(config.api_provider(), ApiProvider::Volcengine)
7734 && let Ok(value) =
7735 std::env::var("VOLCENGINE_MODEL").or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
7736 && !value.trim().is_empty()
7737 {
7738 config
7739 .providers
7740 .get_or_insert_with(ProvidersConfig::default)
7741 .volcengine
7742 .model = Some(value);
7743 }
7744 if matches!(config.api_provider(), ApiProvider::Novita)
7745 && let Ok(value) = std::env::var("NOVITA_MODEL")
7746 && !value.trim().is_empty()
7747 {
7748 config
7749 .providers
7750 .get_or_insert_with(ProvidersConfig::default)
7751 .novita
7752 .model = Some(value);
7753 }
7754 if matches!(config.api_provider(), ApiProvider::Fireworks)
7755 && let Ok(value) = std::env::var("FIREWORKS_MODEL")
7756 && !value.trim().is_empty()
7757 {
7758 config
7759 .providers
7760 .get_or_insert_with(ProvidersConfig::default)
7761 .fireworks
7762 .model = Some(value);
7763 }
7764 if matches!(config.api_provider(), ApiProvider::Moonshot)
7765 && let Ok(value) = std::env::var("MOONSHOT_MODEL")
7766 .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
7767 .or_else(|_| std::env::var("KIMI_MODEL"))
7768 && !value.trim().is_empty()
7769 {
7770 config
7771 .providers
7772 .get_or_insert_with(ProvidersConfig::default)
7773 .moonshot
7774 .model = Some(value);
7775 }
7776 let active_provider = config.api_provider();
7777 if matches!(
7778 active_provider,
7779 ApiProvider::Siliconflow | ApiProvider::SiliconflowCn
7780 ) && let Ok(value) = std::env::var("SILICONFLOW_MODEL")
7781 && !value.trim().is_empty()
7782 {
7783 config.provider_config_for_mut(active_provider).model = Some(value);
7784 }
7785 if matches!(config.api_provider(), ApiProvider::Arcee)
7786 && let Ok(value) = std::env::var("ARCEE_MODEL")
7787 && !value.trim().is_empty()
7788 {
7789 config
7790 .providers
7791 .get_or_insert_with(ProvidersConfig::default)
7792 .arcee
7793 .model = Some(value);
7794 }
7795 if matches!(config.api_provider(), ApiProvider::Huggingface)
7796 && let Ok(value) = std::env::var("HUGGINGFACE_MODEL").or_else(|_| std::env::var("HF_MODEL"))
7797 && !value.trim().is_empty()
7798 {
7799 config
7800 .providers
7801 .get_or_insert_with(ProvidersConfig::default)
7802 .huggingface
7803 .model = Some(value);
7804 }
7805 if matches!(config.api_provider(), ApiProvider::Meta)
7806 && let Ok(value) =
7807 std::env::var("META_MODEL_API_MODEL").or_else(|_| std::env::var("MODEL_API_MODEL"))
7808 && !value.trim().is_empty()
7809 {
7810 config
7811 .providers
7812 .get_or_insert_with(ProvidersConfig::default)
7813 .meta
7814 .model = Some(value);
7815 }
7816 if matches!(config.api_provider(), ApiProvider::Xai)
7817 && let Ok(value) = std::env::var("XAI_MODEL")
7818 && !value.trim().is_empty()
7819 {
7820 config
7821 .providers
7822 .get_or_insert_with(ProvidersConfig::default)
7823 .xai
7824 .model = Some(value);
7825 }
7826 if matches!(config.api_provider(), ApiProvider::OpencodeGo)
7827 && let Ok(value) = std::env::var("OPENCODE_GO_MODEL")
7828 && !value.trim().is_empty()
7829 {
7830 config
7831 .providers
7832 .get_or_insert_with(ProvidersConfig::default)
7833 .opencode_go
7834 .model = Some(value);
7835 }
7836 if matches!(config.api_provider(), ApiProvider::Telecomjs)
7837 && let Ok(value) = std::env::var("TELECOMJS_MODEL")
7838 && !value.trim().is_empty()
7839 {
7840 config
7841 .providers
7842 .get_or_insert_with(ProvidersConfig::default)
7843 .telecomjs
7844 .model = Some(value);
7845 }
7846 if matches!(
7847 config.api_provider(),
7848 ApiProvider::ModelstudioTokenPlan | ApiProvider::ModelstudioTokenPlanAnthropic
7849 ) && let Ok(value) = std::env::var("MODELSTUDIO_TOKEN_PLAN_MODEL")
7850 && !value.trim().is_empty()
7851 {
7852 let field = if config.api_provider() == ApiProvider::ModelstudioTokenPlanAnthropic {
7853 &mut config
7854 .providers
7855 .get_or_insert_with(ProvidersConfig::default)
7856 .modelstudio_token_plan_anthropic
7857 .model
7858 } else {
7859 &mut config
7860 .providers
7861 .get_or_insert_with(ProvidersConfig::default)
7862 .modelstudio_token_plan
7863 .model
7864 };
7865 *field = Some(value);
7866 }
7867 if matches!(
7868 config.api_provider(),
7869 ApiProvider::ModelstudioCodingPlan | ApiProvider::ModelstudioCodingPlanAnthropic
7870 ) && let Ok(value) = std::env::var("MODELSTUDIO_CODING_PLAN_MODEL")
7871 && !value.trim().is_empty()
7872 {
7873 let field = if config.api_provider() == ApiProvider::ModelstudioCodingPlanAnthropic {
7874 &mut config
7875 .providers
7876 .get_or_insert_with(ProvidersConfig::default)
7877 .modelstudio_coding_plan_anthropic
7878 .model
7879 } else {
7880 &mut config
7881 .providers
7882 .get_or_insert_with(ProvidersConfig::default)
7883 .modelstudio_coding_plan
7884 .model
7885 };
7886 *field = Some(value);
7887 }
7888 if matches!(config.api_provider(), ApiProvider::OpencodeZen)
7889 && let Ok(value) = std::env::var("OPENCODE_ZEN_MODEL")
7890 && !value.trim().is_empty()
7891 {
7892 config
7893 .providers
7894 .get_or_insert_with(ProvidersConfig::default)
7895 .opencode_zen
7896 .model = Some(value);
7897 }
7898 if let Some(value) = codewhale_env_var("CODEWHALE_MODEL", "DEEPSEEK_MODEL")
7899 .ok()
7900 .or_else(|| {
7901 std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL")
7902 .ok()
7903 .filter(|value| !value.trim().is_empty())
7904 })
7905 {
7906 // The CLI `--model` handoff always sets DEEPSEEK_MODEL, never the
7907 // provider-specific *_MODEL var. The legacy root `default_text_model`
7908 // is a DeepSeek-only slot (the validator rejects non-DeepSeek IDs
7909 // there). For a non-DeepSeek provider the explicit model must land in
7910 // the provider-scoped slot instead so the verbatim-passthrough path
7911 // honors it rather than falling back to a DeepSeek/provider default
7912 // (issue #1714). Mirror the OPENAI_MODEL branch above for every
7913 // non-DeepSeek provider.
7914 let provider = config.api_provider();
7915 if (provider == ApiProvider::Custom && config.uses_legacy_literal_custom_route())
7916 || matches!(
7917 provider,
7918 ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic
7919 )
7920 {
7921 config.default_text_model = Some(value);
7922 } else {
7923 // Capture the custom entry key before the mutable borrow below (#1519).
7924 let custom_key = (provider == ApiProvider::Custom).then(|| {
7925 config
7926 .provider
7927 .clone()
7928 .unwrap_or_else(|| "__custom__".to_string())
7929 });
7930 let providers = config
7931 .providers
7932 .get_or_insert_with(ProvidersConfig::default);
7933 let entry = match provider {
7934 ApiProvider::Deepseek
7935 | ApiProvider::DeepseekCN
7936 | ApiProvider::DeepseekAnthropic => unreachable!(
7937 "DeepSeek providers are handled in the if branch above (issue #1714)"
7938 ),
7939 ApiProvider::Custom => providers
7940 .custom
7941 .entry(custom_key.unwrap_or_else(|| "__custom__".to_string()))
7942 .or_default(),
7943 ApiProvider::NvidiaNim => &mut providers.nvidia_nim,
7944 ApiProvider::Openai => &mut providers.openai,
7945 ApiProvider::Atlascloud => &mut providers.atlascloud,
7946 ApiProvider::WanjieArk => &mut providers.wanjie_ark,
7947 ApiProvider::Openrouter => &mut providers.openrouter,
7948 ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo,
7949 ApiProvider::Novita => &mut providers.novita,
7950 ApiProvider::Fireworks => &mut providers.fireworks,
7951 ApiProvider::Siliconflow => &mut providers.siliconflow,
7952 ApiProvider::SiliconflowCn => &mut providers.siliconflow_cn,
7953 ApiProvider::Arcee => &mut providers.arcee,
7954 ApiProvider::Moonshot => &mut providers.moonshot,
7955 ApiProvider::Sglang => &mut providers.sglang,
7956 ApiProvider::Vllm => &mut providers.vllm,
7957 ApiProvider::Ollama => &mut providers.ollama,
7958 ApiProvider::Volcengine => &mut providers.volcengine,
7959 ApiProvider::Huggingface => &mut providers.huggingface,
7960 ApiProvider::Deepinfra => &mut providers.deepinfra,
7961 ApiProvider::Together => &mut providers.together,
7962 ApiProvider::Qianfan => &mut providers.qianfan,
7963 ApiProvider::OpenaiCodex => &mut providers.openai_codex,
7964 ApiProvider::Anthropic => &mut providers.anthropic,
7965 ApiProvider::Openmodel => &mut providers.openmodel,
7966 ApiProvider::Zai => &mut providers.zai,
7967 ApiProvider::Stepfun => &mut providers.stepfun,
7968 ApiProvider::Minimax => &mut providers.minimax,
7969 ApiProvider::MinimaxAnthropic => &mut providers.minimax_anthropic,
7970 ApiProvider::Sakana => &mut providers.sakana,
7971 ApiProvider::LongCat => &mut providers.longcat,
7972 ApiProvider::OpencodeGo => &mut providers.opencode_go,
7973 ApiProvider::OpencodeZen => &mut providers.opencode_zen,
7974 ApiProvider::Meta => &mut providers.meta,
7975 ApiProvider::Xai => &mut providers.xai,
7976 ApiProvider::Telecomjs => &mut providers.telecomjs,
7977 ApiProvider::ModelstudioTokenPlan => &mut providers.modelstudio_token_plan,
7978 ApiProvider::ModelstudioTokenPlanAnthropic => {
7979 &mut providers.modelstudio_token_plan_anthropic
7980 }
7981 ApiProvider::ModelstudioCodingPlan => &mut providers.modelstudio_coding_plan,
7982 ApiProvider::ModelstudioCodingPlanAnthropic => {
7983 &mut providers.modelstudio_coding_plan_anthropic
7984 }
7985 };
7986 entry.model = Some(value);
7987 }
7988 }
7989 if matches!(config.api_provider(), ApiProvider::NvidiaNim)
7990 && let Ok(value) = std::env::var("NVIDIA_NIM_MODEL")
7991 {
7992 config.default_text_model = Some(value);
7993 }
7994 if let Ok(value) =
7995 std::env::var("CODEWHALE_SKILLS_DIR").or_else(|_| std::env::var("DEEPSEEK_SKILLS_DIR"))
7996 {
7997 config.skills_dir = Some(value);
7998 }
7999 if let Ok(value) =
8000 std::env::var("CODEWHALE_MCP_CONFIG").or_else(|_| std::env::var("DEEPSEEK_MCP_CONFIG"))
8001 {
8002 config.mcp_config_path = Some(value);
8003 }
8004 if let Ok(value) =
8005 std::env::var("CODEWHALE_NOTES_PATH").or_else(|_| std::env::var("DEEPSEEK_NOTES_PATH"))
8006 {
8007 config.notes_path = Some(value);
8008 }
8009 if let Ok(value) =
8010 std::env::var("CODEWHALE_MEMORY_PATH").or_else(|_| std::env::var("DEEPSEEK_MEMORY_PATH"))
8011 {
8012 config.memory_path = Some(value);
8013 }
8014 if let Ok(value) =
8015 std::env::var("CODEWHALE_MEMORY").or_else(|_| std::env::var("DEEPSEEK_MEMORY"))
8016 {
8017 let on = matches!(
8018 value.trim().to_ascii_lowercase().as_str(),
8019 "1" | "on" | "true" | "yes" | "y" | "enabled"
8020 );
8021 config
8022 .memory
8023 .get_or_insert_with(MemoryConfig::default)
8024 .enabled = Some(on);
8025 }
8026 if let Ok(value) =
8027 std::env::var("CODEWHALE_ALLOW_SHELL").or_else(|_| std::env::var("DEEPSEEK_ALLOW_SHELL"))
8028 {
8029 config.allow_shell = Some(value == "1" || value.eq_ignore_ascii_case("true"));
8030 }
8031 if let Ok(value) = std::env::var("CODEWHALE_APPROVAL_POLICY")
8032 .or_else(|_| std::env::var("DEEPSEEK_APPROVAL_POLICY"))
8033 {
8034 config.approval_policy = Some(value);
8035 }
8036 if let Ok(value) =
8037 std::env::var("CODEWHALE_SANDBOX_MODE").or_else(|_| std::env::var("DEEPSEEK_SANDBOX_MODE"))
8038 {
8039 config.sandbox_mode = Some(value);
8040 }
8041 if let Ok(value) = std::env::var("CODEWHALE_YOLO").or_else(|_| std::env::var("DEEPSEEK_YOLO")) {
8042 config.yolo = Some(value == "1" || value.eq_ignore_ascii_case("true"));
8043 }
8044 if let Ok(value) =
8045 std::env::var("CODEWHALE_VERBOSITY").or_else(|_| std::env::var("DEEPSEEK_VERBOSITY"))
8046 {
8047 config.verbosity = Some(value);
8048 }
8049 if let Ok(value) = std::env::var("CODEWHALE_SANDBOX_BACKEND")
8050 .or_else(|_| std::env::var("DEEPSEEK_SANDBOX_BACKEND"))
8051 {
8052 config.sandbox_backend = Some(value);
8053 }
8054 if let Ok(value) =
8055 std::env::var("CODEWHALE_SANDBOX_URL").or_else(|_| std::env::var("DEEPSEEK_SANDBOX_URL"))
8056 {
8057 config.sandbox_url = Some(value);
8058 }
8059 if policy.permits_secret_bearing_values()
8060 && let Ok(value) = std::env::var("CODEWHALE_SANDBOX_API_KEY")
8061 .or_else(|_| std::env::var("DEEPSEEK_SANDBOX_API_KEY"))
8062 {
8063 config.sandbox_api_key = Some(value);
8064 }
8065 if let Ok(value) = std::env::var("CODEWHALE_MANAGED_CONFIG_PATH")
8066 .or_else(|_| std::env::var("DEEPSEEK_MANAGED_CONFIG_PATH"))
8067 {
8068 config.managed_config_path = Some(value);
8069 }
8070 if policy.permits_secret_bearing_values()
8071 && let Ok(value) = std::env::var("CODEWHALE_SEARCH_API_KEY")
8072 .or_else(|_| std::env::var("DEEPSEEK_SEARCH_API_KEY"))
8073 && !value.trim().is_empty()
8074 {
8075 config
8076 .search
8077 .get_or_insert_with(SearchConfig::default)
8078 .api_key = Some(value);
8079 }
8080 if let Ok(value) = codewhale_env_var("CODEWHALE_SEARCH_BASE_URL", "DEEPSEEK_SEARCH_BASE_URL") {
8081 config
8082 .search
8083 .get_or_insert_with(SearchConfig::default)
8084 .base_url = Some(value);
8085 }
8086 if let Ok(value) = std::env::var("CODEWHALE_REQUIREMENTS_PATH")
8087 .or_else(|_| std::env::var("DEEPSEEK_REQUIREMENTS_PATH"))
8088 {
8089 config.requirements_path = Some(value);
8090 }
8091 if let Ok(value) = std::env::var("CODEWHALE_MAX_SUBAGENTS")
8092 .or_else(|_| std::env::var("DEEPSEEK_MAX_SUBAGENTS"))
8093 && let Ok(parsed) = value.parse::<usize>()
8094 {
8095 config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));
8096 }
8097 // Always leave a receipt: "the environment layer ran and nobody owns the
8098 // base URL" is a different, stronger statement than "no receipt", and only
8099 // the explicit form stops a pinned cross-provider child from treating the
8100 // ambient generic host as a global fallback.
8101 config.base_url_env_receipt = if active_base_url_from_env {
8102 let provider = config.api_provider();
8103 BaseUrlEnvReceipt::Route(provider, config.provider_identity_for(provider))
8104 } else {
8105 BaseUrlEnvReceipt::NoOwner
8106 };
8107 }
8108
8109 fn normalize_model_config(config: &mut Config) {
8110 let provider = config.api_provider();
8111 let base_url = config.deepseek_base_url();
8112 config.migrated_deepseek_model_alias = if matches!(
8113 provider,
8114 ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic
8115 ) {
8116 config
8117 .active_configured_model_id()
8118 .map(str::to_ascii_lowercase)
8119 .filter(|model| deepseek_alias_deprecation(model).is_some())
8120 .filter(|model| {
8121 wire_model_for_provider_route(provider, &base_url, model) != model.as_str()
8122 })
8123 } else {
8124 None
8125 };
8126
8127 // Preserve the behavioral half of DeepSeek's retired aliases while
8128 // migrating their model id to V4 Flash. An explicit reasoning setting is
8129 // authoritative; this compatibility default only fills an omitted value.
8130 // Custom endpoints retain both their model id and their own semantics.
8131 if config.reasoning_effort.is_none() {
8132 let alias_effort = config
8133 .migrated_deepseek_model_alias
8134 .as_deref()
8135 .and_then(legacy_deepseek_alias_reasoning_effort);
8136 if let Some(effort) = alias_effort {
8137 config.reasoning_effort = Some(effort.to_string());
8138 config.reasoning_effort_inferred_from_legacy_alias = true;
8139 }
8140 }
8141
8142 if let Some(model) = config.default_text_model.as_deref()
8143 && !provider_passes_model_through(config.api_provider())
8144 && !config.active_provider_preserves_custom_base_url_model()
8145 && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)
8146 {
8147 config.default_text_model = Some(normalized);
8148 }
8149
8150 if let Some(providers) = config.providers.as_mut() {
8151 if let Some(model) = providers.deepseek.model.as_deref()
8152 && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)
8153 && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)
8154 {
8155 providers.deepseek.model = Some(normalized);
8156 }
8157 if let Some(model) = providers.deepseek_cn.model.as_deref()
8158 && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)
8159 && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)
8160 {
8161 providers.deepseek_cn.model = Some(normalized);
8162 }
8163 if let Some(model) = providers.deepseek_anthropic.model.as_deref()
8164 && !provider_entry_uses_custom_base_url(
8165 ApiProvider::DeepseekAnthropic,
8166 &providers.deepseek_anthropic,
8167 )
8168 && let Some(normalized) =
8169 normalize_model_for_provider(ApiProvider::DeepseekAnthropic, model)
8170 {
8171 providers.deepseek_anthropic.model = Some(normalized);
8172 }
8173 if let Some(model) = providers.nvidia_nim.model.as_deref()
8174 && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)
8175 && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)
8176 {
8177 providers.nvidia_nim.model = Some(normalized);
8178 }
8179 if let Some(model) = providers.openrouter.model.as_deref()
8180 && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)
8181 && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)
8182 {
8183 providers.openrouter.model = Some(normalized);
8184 }
8185 if let Some(model) = providers.novita.model.as_deref()
8186 && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)
8187 && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)
8188 {
8189 providers.novita.model = Some(normalized);
8190 }
8191 if let Some(model) = providers.fireworks.model.as_deref()
8192 && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)
8193 && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)
8194 {
8195 providers.fireworks.model = Some(normalized);
8196 }
8197 if let Some(model) = providers.siliconflow.model.as_deref()
8198 && !provider_entry_uses_custom_base_url(
8199 ApiProvider::Siliconflow,
8200 &providers.siliconflow,
8201 )
8202 && let Some(normalized) = normalize_model_for_provider(ApiProvider::Siliconflow, model)
8203 {
8204 providers.siliconflow.model = Some(normalized);
8205 }
8206 if let Some(model) = providers.siliconflow_cn.model.as_deref()
8207 && !provider_entry_uses_custom_base_url(
8208 ApiProvider::SiliconflowCn,
8209 &providers.siliconflow_cn,
8210 )
8211 && let Some(normalized) =
8212 normalize_model_for_provider(ApiProvider::SiliconflowCn, model)
8213 {
8214 providers.siliconflow_cn.model = Some(normalized);
8215 }
8216 if let Some(model) = providers.moonshot.model.as_deref()
8217 && !provider_entry_uses_custom_base_url(ApiProvider::Moonshot, &providers.moonshot)
8218 && let Some(normalized) = normalize_model_for_provider(ApiProvider::Moonshot, model)
8219 {
8220 providers.moonshot.model = Some(normalized);
8221 }
8222 if let Some(model) = providers.sglang.model.as_deref()
8223 && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)
8224 && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)
8225 {
8226 providers.sglang.model = Some(normalized);
8227 }
8228 if let Some(model) = providers.vllm.model.as_deref()
8229 && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)
8230 && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)
8231 {
8232 providers.vllm.model = Some(normalized);
8233 }
8234 if let Some(model) = providers.deepinfra.model.as_deref()
8235 && !provider_entry_uses_custom_base_url(ApiProvider::Deepinfra, &providers.deepinfra)
8236 && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepinfra, model)
8237 {
8238 providers.deepinfra.model = Some(normalized);
8239 }
8240 }
8241 }
8242
8243 #[cfg(test)]
8244 pub(crate) fn normalize_model_config_for_test(config: &mut Config) {
8245 normalize_model_config(config);
8246 }
8247
8248 fn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option<String> {
8249 if matches!(provider, ApiProvider::XiaomiMimo)
8250 && let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
8251 {
8252 return Some(canonical.to_string());
8253 }
8254 if provider_passes_model_through(provider) {
8255 return None;
8256 }
8257 normalize_model_name_for_provider(provider, model)
8258 }
8259
8260 pub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {
8261 matches!(
8262 provider,
8263 ApiProvider::Openai
8264 | ApiProvider::Atlascloud
8265 | ApiProvider::WanjieArk
8266 | ApiProvider::Volcengine
8267 | ApiProvider::XiaomiMimo
8268 | ApiProvider::Moonshot
8269 | ApiProvider::Qianfan
8270 | ApiProvider::Openmodel
8271 | ApiProvider::Ollama
8272 | ApiProvider::Huggingface
8273 | ApiProvider::Meta
8274 | ApiProvider::Xai
8275 | ApiProvider::Telecomjs
8276 | ApiProvider::ModelstudioTokenPlan
8277 | ApiProvider::ModelstudioTokenPlanAnthropic
8278 | ApiProvider::ModelstudioCodingPlan
8279 | ApiProvider::ModelstudioCodingPlanAnthropic
8280 // Custom OpenAI-compatible endpoints preserve user-supplied model
8281 // ids verbatim (#1519); never normalize/rewrite them.
8282 | ApiProvider::Custom
8283 )
8284 }
8285
8286 /// Whether a provider identity key is the historical literal `custom`.
8287 fn identity_is_literal_custom(identity: &str) -> bool {
8288 identity
8289 .trim()
8290 .eq_ignore_ascii_case(ApiProvider::Custom.as_str())
8291 }
8292
8293 fn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {
8294 entry
8295 .base_url
8296 .as_deref()
8297 .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))
8298 }
8299
8300 fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
8301 let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
8302 if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
8303 return None;
8304 }
8305 Some(match normalized.as_str() {
8306 "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => {
8307 DEFAULT_XIAOMI_MIMO_BASE_URL
8308 }
8309 "token-plan-cn"
8310 | "token-plan-china"
8311 | "token-plan-mainland"
8312 | "token-plan-mainland-china"
8313 | "cn"
8314 | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
8315 "token-plan-sgp"
8316 | "token-plan-sg"
8317 | "token-plan-singapore"
8318 | "sgp"
8319 | "sg"
8320 | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
8321 "token-plan-ams"
8322 | "token-plan-eu"
8323 | "token-plan-europe"
8324 | "token-plan-amsterdam"
8325 | "ams"
8326 | "eu"
8327 | "europe"
8328 | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
8329 _ => DEFAULT_XIAOMI_MIMO_BASE_URL,
8330 })
8331 }
8332
8333 fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
8334 matches!(
8335 normalized_mode,
8336 "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
8337 )
8338 }
8339
8340 fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
8341 let normalized = normalize_base_url(base_url).to_ascii_lowercase();
8342 normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
8343 || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL
8344 || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
8345 }
8346
8347 fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option<String> {
8348 candidates.iter().find_map(|name| {
8349 std::env::var(name)
8350 .ok()
8351 .filter(|value| !value.trim().is_empty())
8352 })
8353 }
8354
8355 fn xiaomi_mimo_env_api_key_for_runtime(
8356 mode: Option<&str>,
8357 base_url: Option<&str>,
8358 ) -> Option<String> {
8359 const TOKEN_PLAN_ENV_VARS: &[&str] =
8360 &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"];
8361 const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"];
8362
8363 let normalized_mode =
8364 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
8365 let standard_selected = normalized_mode
8366 .as_deref()
8367 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint)
8368 || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go);
8369 if standard_selected {
8370 return xiaomi_mimo_env_var(STANDARD_ENV_VARS);
8371 }
8372
8373 let token_plan_selected = normalized_mode
8374 .as_deref()
8375 .and_then(xiaomi_mimo_base_url_for_mode)
8376 .is_some()
8377 || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan);
8378 if token_plan_selected {
8379 return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS);
8380 }
8381
8382 xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS))
8383 }
8384
8385 fn wire_config_prefers_anthropic(wire: Option<&str>) -> bool {
8386 let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else {
8387 return false;
8388 };
8389 let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-");
8390 matches!(
8391 normalized.as_str(),
8392 "anthropic"
8393 | "anthropic-messages"
8394 | "messages"
8395 | "claude"
8396 | "anthropic-compatible"
8397 | "anthropic-compat"
8398 )
8399 }
8400
8401 fn modelstudio_mode_is_coding_plan(provider: ApiProvider, mode: Option<&str>) -> bool {
8402 if matches!(
8403 provider,
8404 ApiProvider::ModelstudioCodingPlan | ApiProvider::ModelstudioCodingPlanAnthropic
8405 ) {
8406 return true;
8407 }
8408 let Some(raw) = mode.map(str::trim).filter(|value| !value.is_empty()) else {
8409 return false;
8410 };
8411 let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-");
8412 matches!(
8413 normalized.as_str(),
8414 "coding-plan" | "coding" | "codingplan" | "dashscope-coding" | "code"
8415 )
8416 }
8417
8418 fn resolve_modelstudio_base_url_for_tui(
8419 configured: Option<String>,
8420 provider: ApiProvider,
8421 mode: Option<&str>,
8422 wire: Option<&str>,
8423 ) -> String {
8424 if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
8425 return url;
8426 }
8427 let coding = modelstudio_mode_is_coding_plan(provider, mode);
8428 let anthropic = matches!(
8429 provider,
8430 ApiProvider::ModelstudioTokenPlanAnthropic | ApiProvider::ModelstudioCodingPlanAnthropic
8431 ) || wire_config_prefers_anthropic(wire);
8432 match (coding, anthropic) {
8433 (true, true) => MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL.to_string(),
8434 (true, false) => DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL.to_string(),
8435 (false, true) => MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL.to_string(),
8436 (false, false) => DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL.to_string(),
8437 }
8438 }
8439
8440 fn resolve_minimax_base_url_for_tui(
8441 configured: Option<String>,
8442 provider: ApiProvider,
8443 wire: Option<&str>,
8444 ) -> String {
8445 if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
8446 return url;
8447 }
8448 if matches!(provider, ApiProvider::MinimaxAnthropic) || wire_config_prefers_anthropic(wire) {
8449 DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string()
8450 } else {
8451 DEFAULT_MINIMAX_BASE_URL.to_string()
8452 }
8453 }
8454
8455 fn resolve_deepseek_base_url_for_tui(
8456 configured: Option<String>,
8457 provider: ApiProvider,
8458 wire: Option<&str>,
8459 ) -> String {
8460 if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
8461 return url;
8462 }
8463 if matches!(provider, ApiProvider::DeepseekAnthropic) || wire_config_prefers_anthropic(wire) {
8464 DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string()
8465 } else {
8466 DEFAULT_DEEPSEEK_BASE_URL.to_string()
8467 }
8468 }
8469
8470 fn resolve_xiaomi_mimo_base_url(
8471 configured: Option<String>,
8472 api_key: Option<&str>,
8473 mode: Option<&str>,
8474 ) -> String {
8475 let normalized_mode =
8476 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
8477 let uses_standard_mode = normalized_mode
8478 .as_deref()
8479 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint);
8480 let mode_base_url = normalized_mode
8481 .as_deref()
8482 .and_then(xiaomi_mimo_base_url_for_mode);
8483 let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key);
8484 match configured {
8485 Some(base_url) if uses_standard_mode => base_url,
8486 Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => {
8487 mode_base_url
8488 .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL)
8489 .to_string()
8490 }
8491 Some(base_url) => base_url,
8492 None => {
8493 if let Some(base_url) = mode_base_url {
8494 base_url.to_string()
8495 } else if uses_standard_mode {
8496 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
8497 } else if uses_token_plan || api_key.is_none() {
8498 DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()
8499 } else {
8500 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
8501 }
8502 }
8503 }
8504 }
8505
8506 fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
8507 api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
8508 }
8509
8510 fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool {
8511 matches!(
8512 normalize_base_url(base_url).to_ascii_lowercase().as_str(),
8513 "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
8514 )
8515 }
8516
8517 fn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {
8518 let kind = provider
8519 .kind()
8520 .unwrap_or(codewhale_config::ProviderKind::Deepseek);
8521 codewhale_config::provider_preserves_custom_base_url_model(kind, base_url)
8522 }
8523
8524 fn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {
8525 base_url_is_custom_for_provider(provider, base_url)
8526 }
8527
8528 fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
8529 let normalized = normalize_base_url(base_url).to_ascii_lowercase();
8530 normalized == DEFAULT_KIMI_CODE_BASE_URL
8531 || normalized == "https://api.kimi.com/coding"
8532 || normalized.starts_with("https://api.kimi.com/coding/")
8533 }
8534
8535 /// The Kimi Code API endpoint, normalized only for insignificant trailing
8536 /// slashes. This must stay stricter than `moonshot_base_url_uses_kimi_code`:
8537 /// route-specific K3 capability and request shaping are not safe for arbitrary
8538 /// Kimi-hosted paths.
8539 pub(crate) fn moonshot_base_url_is_exact_kimi_code(base_url: &str) -> bool {
8540 codewhale_config::provider::is_exact_kimi_code_route(
8541 codewhale_config::ProviderKind::Moonshot,
8542 base_url,
8543 )
8544 }
8545
8546 /// The exact Moonshot direct-API endpoint, normalized only for an
8547 /// insignificant trailing slash. Custom gateways must retain their own wire
8548 /// contract even when they expose a `kimi-k3` model id.
8549 pub(crate) fn moonshot_base_url_is_exact_direct_platform(base_url: &str) -> bool {
8550 codewhale_config::provider::is_exact_moonshot_platform_route(
8551 codewhale_config::ProviderKind::Moonshot,
8552 base_url,
8553 )
8554 }
8555
8556 /// Whether a route is exactly Moonshot's direct pay-as-you-go K3 route.
8557 pub(crate) fn is_exact_direct_moonshot_k3_route(
8558 provider: ApiProvider,
8559 base_url: &str,
8560 model: &str,
8561 ) -> bool {
8562 provider == ApiProvider::Moonshot
8563 && moonshot_base_url_is_exact_direct_platform(base_url)
8564 && model.trim().eq_ignore_ascii_case(MOONSHOT_KIMI_K3_MODEL)
8565 }
8566
8567 /// Whether a route is exactly the Kimi Code K3 membership-plan route.
8568 ///
8569 /// Keep the bare `k3` identifier route-owned. In particular, do not infer a
8570 /// Kimi Code plan entitlement for direct Moonshot `kimi-k3`, generic `k3`, or
8571 /// `kimi-for-coding` routes.
8572 pub(crate) fn is_exact_kimi_code_k3_route(
8573 provider: ApiProvider,
8574 base_url: &str,
8575 model: &str,
8576 ) -> bool {
8577 provider == ApiProvider::Moonshot
8578 && moonshot_base_url_is_exact_kimi_code(base_url)
8579 && model.trim().eq_ignore_ascii_case(KIMI_CODE_K3_MODEL)
8580 }
8581
8582 /// Whether a route is one of Z.ai's exact first-party Chat endpoints.
8583 #[must_use]
8584 pub(crate) fn is_exact_zai_chat_route(provider: ApiProvider, base_url: &str) -> bool {
8585 provider == ApiProvider::Zai
8586 && codewhale_config::provider::is_exact_zai_chat_route(
8587 codewhale_config::ProviderKind::Zai,
8588 base_url,
8589 )
8590 }
8591
8592 /// Whether a route is an exact first-party Z.ai model that exposes **tiered**
8593 /// reasoning effort (`reasoning_effort: high | max`) rather than only the
8594 /// generic thinking toggle.
8595 ///
8596 /// GLM-5.2 is the verified member. GLM-5.3 inherits it because its catalog row
8597 /// inherits GLM-5.2's `reasoning_options` wholesale — see the
8598 /// `INHERITED FROM glm-5.2` marker in `config/models.rs`. If Z.ai publishes
8599 /// different reasoning controls for 5.3, this predicate is where they split.
8600 #[must_use]
8601 pub(crate) fn is_exact_zai_tiered_effort_route(
8602 provider: ApiProvider,
8603 base_url: &str,
8604 model: &str,
8605 ) -> bool {
8606 is_exact_zai_chat_route(provider, base_url)
8607 && (model.trim().eq_ignore_ascii_case(ZAI_GLM_5_2_MODEL)
8608 || model.trim().eq_ignore_ascii_case(ZAI_GLM_5_3_MODEL))
8609 }
8610
8611 /// Whether a route is exactly first-party Z.ai GLM-5-Turbo.
8612 #[must_use]
8613 pub(crate) fn is_exact_zai_glm_5_turbo_route(
8614 provider: ApiProvider,
8615 base_url: &str,
8616 model: &str,
8617 ) -> bool {
8618 is_exact_zai_chat_route(provider, base_url)
8619 && model.trim().eq_ignore_ascii_case(ZAI_GLM_5_TURBO_MODEL)
8620 }
8621
8622 /// Whether a route is an exact first-party Z.ai model with a verified
8623 /// reasoning control. GLM-5.2 and GLM-5.3 have tiered effort; GLM-5.1 and
8624 /// GLM-5-Turbo only expose the generic thinking toggle.
8625 #[must_use]
8626 pub(crate) fn is_exact_known_zai_reasoning_route(
8627 provider: ApiProvider,
8628 base_url: &str,
8629 model: &str,
8630 ) -> bool {
8631 is_exact_zai_tiered_effort_route(provider, base_url, model)
8632 || is_exact_zai_glm_5_turbo_route(provider, base_url, model)
8633 || (is_exact_zai_chat_route(provider, base_url)
8634 && model.trim().eq_ignore_ascii_case(ZAI_GLM_5_1_MODEL))
8635 }
8636
8637 /// MiniMax's own hosted routes, for both wire dialects.
8638 ///
8639 /// Kept as a pure string predicate so a dispatch receipt can be judged without
8640 /// a `Config`, and shared with billing classification so a MiniMax-compatible
8641 /// gateway cannot inherit the first-party PAYG/Token Plan duality. Both the
8642 /// `.io` and `.com` hosts are first-party; anything else is a gateway.
8643 #[must_use]
8644 pub(crate) fn minimax_base_url_is_supported_direct(base_url: &str) -> bool {
8645 codewhale_config::provider::is_exact_minimax_chat_route(
8646 codewhale_config::ProviderKind::Minimax,
8647 base_url,
8648 ) || codewhale_config::provider::is_exact_minimax_anthropic_route(
8649 codewhale_config::ProviderKind::MinimaxAnthropic,
8650 base_url,
8651 )
8652 }
8653
8654 /// Whether a route is exactly MiniMax-M3 on the first-party OpenAI-compatible
8655 /// Chat API. Compatible gateways and the Anthropic Messages route retain
8656 /// their own token-limit dialects.
8657 #[must_use]
8658 pub(crate) fn is_exact_minimax_m3_route(
8659 provider: ApiProvider,
8660 base_url: &str,
8661 model: &str,
8662 ) -> bool {
8663 provider == ApiProvider::Minimax
8664 && codewhale_config::provider::is_exact_minimax_chat_route(
8665 codewhale_config::ProviderKind::Minimax,
8666 base_url,
8667 )
8668 && model.trim().eq_ignore_ascii_case(DEFAULT_MINIMAX_MODEL)
8669 }
8670
8671 /// Whether a route is exactly MiniMax-M3 on a first-party Anthropic-compatible
8672 /// Messages endpoint. The wire supports adaptive/disabled thinking, but no
8673 /// distinct effort tier.
8674 #[must_use]
8675 pub(crate) fn is_exact_minimax_anthropic_m3_route(
8676 provider: ApiProvider,
8677 base_url: &str,
8678 model: &str,
8679 ) -> bool {
8680 provider == ApiProvider::MinimaxAnthropic
8681 && codewhale_config::provider::is_exact_minimax_anthropic_route(
8682 codewhale_config::ProviderKind::MinimaxAnthropic,
8683 base_url,
8684 )
8685 && model.trim().eq_ignore_ascii_case(DEFAULT_MINIMAX_MODEL)
8686 }
8687
8688 #[must_use]
8689 pub(crate) fn minimax_m3_route_uses_max_completion_tokens(
8690 provider: ApiProvider,
8691 base_url: &str,
8692 model: &str,
8693 ) -> bool {
8694 is_exact_minimax_m3_route(provider, base_url, model)
8695 }
8696
8697 /// The Kimi Code membership roster, as one fact.
8698 ///
8699 /// The picker offers these ids, `validate_kimi_code_api_model_id` accepts them
8700 /// on the membership endpoint and rejects them on the direct platform, and the
8701 /// model picker labels them as plan routes. Those sites previously kept
8702 /// independent literal lists and had already drifted (`kimi-for-coding` was
8703 /// missing from the picker label), so the roster lives here and nowhere else.
8704 pub(crate) const KIMI_CODE_MEMBERSHIP_MODELS: [&str; 3] = [
8705 KIMI_CODE_K3_MODEL,
8706 DEFAULT_KIMI_CODE_MODEL,
8707 KIMI_CODE_HIGHSPEED_MODEL,
8708 ];
8709
8710 /// Whether `model` is a Kimi Code membership model id.
8711 ///
8712 /// The single membership-roster predicate. Callers that need to name the
8713 /// product — output-ceiling provenance, picker rosters, setup validation, and
8714 /// the model picker's route label — must use this rather than re-listing ids.
8715 #[must_use]
8716 pub(crate) fn is_kimi_code_membership_model(model: &str) -> bool {
8717 let model = model.trim();
8718 KIMI_CODE_MEMBERSHIP_MODELS
8719 .iter()
8720 .any(|id| model.eq_ignore_ascii_case(id))
8721 }
8722
8723 /// The Moonshot direct-platform roster, as one fact. Mirror of
8724 /// [`KIMI_CODE_MEMBERSHIP_MODELS`] for the pay-as-you-go product.
8725 pub(crate) const MOONSHOT_DIRECT_PLATFORM_MODELS: [&str; 3] = [
8726 MOONSHOT_KIMI_K3_MODEL,
8727 DEFAULT_MOONSHOT_MODEL,
8728 MOONSHOT_KIMI_K2_6_MODEL,
8729 ];
8730
8731 pub(crate) const KIMI_CODE_CLAUDE_ALIAS_GUIDANCE: &str = "Kimi Code model `k3[1m]` is a Claude Code environment convention, not an API model id. Use model = \"k3\". If your Kimi Code plan includes 1M context, also set context_window = 1048576; otherwise keep the 262144 safe default.";
8732
8733 #[derive(Debug, thiserror::Error)]
8734 pub(crate) enum SafeConfigDiagnostic {
8735 #[error("{}", KIMI_CODE_CLAUDE_ALIAS_GUIDANCE)]
8736 KimiCodeClaudeAlias,
8737 }
8738
8739 /// Fail closed on known-bad model/endpoint pairings (#4687).
8740 ///
8741 /// Canonical endpoints reject `k3[1m]` and known membership/direct cross-pairings.
8742 /// Unknown IDs and custom Moonshot-compatible gateways remain pass-through.
8743 pub(crate) fn validate_kimi_code_api_model_id(
8744 provider: ApiProvider,
8745 base_url: &str,
8746 model: &str,
8747 ) -> std::result::Result<(), String> {
8748 if provider != ApiProvider::Moonshot {
8749 return Ok(());
8750 }
8751 let model = model.trim();
8752 if model.is_empty() {
8753 return Ok(());
8754 }
8755
8756 if moonshot_base_url_is_exact_kimi_code(base_url) {
8757 if model.eq_ignore_ascii_case("k3[1m]") {
8758 return Err(KIMI_CODE_CLAUDE_ALIAS_GUIDANCE.to_string());
8759 }
8760 for direct_id in MOONSHOT_DIRECT_PLATFORM_MODELS {
8761 if model.eq_ignore_ascii_case(direct_id) {
8762 return Err(format!(
8763 "Kimi Code membership route (api.kimi.com/coding/v1) does not accept model = \"{model}\": it is a direct Moonshot platform id. Use a Kimi Code membership model (\"k3\", \"kimi-for-coding\", or \"kimi-for-coding-highspeed\") for this base_url. Direct Moonshot pay-as-you-go uses base_url = \"https://api.moonshot.ai/v1\" with model = \"{direct_id}\"."
8764 ));
8765 }
8766 }
8767 return Ok(());
8768 }
8769
8770 if moonshot_base_url_is_exact_direct_platform(base_url) {
8771 for membership_id in KIMI_CODE_MEMBERSHIP_MODELS {
8772 if model.eq_ignore_ascii_case(membership_id) {
8773 return Err(format!(
8774 "Moonshot direct route (api.moonshot.ai/v1) does not accept model = \"{model}\": it is a Kimi Code membership model id, not a direct-platform catalog model. Kimi Code membership uses base_url = \"https://api.kimi.com/coding/v1\" with model = \"{membership_id}\"; direct Moonshot pay-as-you-go K3 uses model = \"kimi-k3\"."
8775 ));
8776 }
8777 }
8778 }
8779
8780 Ok(())
8781 }
8782
8783 #[cfg(test)]
8784 mod kimi_code_pairing_tests {
8785 use super::*;
8786
8787 #[test]
8788 fn membership_roster_passes_on_kimi_code_endpoint() {
8789 for model in [
8790 KIMI_CODE_K3_MODEL,
8791 DEFAULT_KIMI_CODE_MODEL,
8792 KIMI_CODE_HIGHSPEED_MODEL,
8793 ] {
8794 assert!(
8795 validate_kimi_code_api_model_id(
8796 ApiProvider::Moonshot,
8797 DEFAULT_KIMI_CODE_BASE_URL,
8798 model,
8799 )
8800 .is_ok(),
8801 "{model} must be accepted on the exact Kimi Code membership endpoint"
8802 );
8803 }
8804 }
8805
8806 #[test]
8807 fn direct_platform_ids_fail_on_kimi_code_endpoint() {
8808 for model in [
8809 MOONSHOT_KIMI_K3_MODEL,
8810 DEFAULT_MOONSHOT_MODEL,
8811 MOONSHOT_KIMI_K2_6_MODEL,
8812 ] {
8813 let err = validate_kimi_code_api_model_id(
8814 ApiProvider::Moonshot,
8815 DEFAULT_KIMI_CODE_BASE_URL,
8816 model,
8817 )
8818 .expect_err("direct-platform ids are not Kimi Code membership roster models");
8819 assert!(err.contains(model), "{err}");
8820 assert!(err.contains("api.moonshot.ai/v1"), "{err}");
8821 }
8822 }
8823
8824 #[test]
8825 fn membership_ids_fail_on_direct_moonshot_endpoint() {
8826 for model in [
8827 KIMI_CODE_K3_MODEL,
8828 DEFAULT_KIMI_CODE_MODEL,
8829 KIMI_CODE_HIGHSPEED_MODEL,
8830 ] {
8831 let err = validate_kimi_code_api_model_id(
8832 ApiProvider::Moonshot,
8833 DEFAULT_MOONSHOT_BASE_URL,
8834 model,
8835 )
8836 .expect_err("membership ids are not direct-platform catalog models");
8837 assert!(err.contains(model), "{err}");
8838 assert!(err.contains("api.kimi.com/coding/v1"), "{err}");
8839 }
8840 }
8841
8842 #[test]
8843 fn canonical_pairs_pass_and_custom_gateways_are_untouched() {
8844 // Canonical pairs pass on both endpoints.
8845 for (base_url, model) in [
8846 (DEFAULT_KIMI_CODE_BASE_URL, KIMI_CODE_K3_MODEL),
8847 (DEFAULT_KIMI_CODE_BASE_URL, DEFAULT_KIMI_CODE_MODEL),
8848 (DEFAULT_KIMI_CODE_BASE_URL, KIMI_CODE_HIGHSPEED_MODEL),
8849 (DEFAULT_MOONSHOT_BASE_URL, MOONSHOT_KIMI_K3_MODEL),
8850 (DEFAULT_MOONSHOT_BASE_URL, DEFAULT_MOONSHOT_MODEL),
8851 (DEFAULT_MOONSHOT_BASE_URL, MOONSHOT_KIMI_K2_6_MODEL),
8852 ] {
8853 assert!(
8854 validate_kimi_code_api_model_id(ApiProvider::Moonshot, base_url, model).is_ok(),
8855 "{base_url} / {model}"
8856 );
8857 }
8858 // The pre-existing cross-pairings still fail closed.
8859 assert!(
8860 validate_kimi_code_api_model_id(
8861 ApiProvider::Moonshot,
8862 DEFAULT_KIMI_CODE_BASE_URL,
8863 MOONSHOT_KIMI_K3_MODEL,
8864 )
8865 .is_err()
8866 );
8867 assert!(
8868 validate_kimi_code_api_model_id(
8869 ApiProvider::Moonshot,
8870 DEFAULT_MOONSHOT_BASE_URL,
8871 KIMI_CODE_K3_MODEL,
8872 )
8873 .is_err()
8874 );
8875 // Custom gateways keep their own wire contract, membership ids
8876 // included: only the two canonical endpoints enforce pairings.
8877 for model in [
8878 KIMI_CODE_K3_MODEL,
8879 DEFAULT_KIMI_CODE_MODEL,
8880 KIMI_CODE_HIGHSPEED_MODEL,
8881 MOONSHOT_KIMI_K3_MODEL,
8882 ] {
8883 assert!(
8884 validate_kimi_code_api_model_id(
8885 ApiProvider::Moonshot,
8886 "https://proxy.example/v1",
8887 model,
8888 )
8889 .is_ok(),
8890 "{model} on a custom gateway"
8891 );
8892 }
8893 }
8894 }
8895
8896 /// Short route label for header/diagnostics without credentials (#4687).
8897 pub(crate) fn moonshot_k3_route_display_name(base_url: &str, model: &str) -> Option<&'static str> {
8898 if is_exact_kimi_code_k3_route(ApiProvider::Moonshot, base_url, model) {
8899 return Some("Kimi Code membership / k3");
8900 }
8901 if is_exact_direct_moonshot_k3_route(ApiProvider::Moonshot, base_url, model) {
8902 return Some("Moonshot direct / kimi-k3");
8903 }
8904 None
8905 }
8906
8907 /// Credential help for a concrete provider route.
8908 ///
8909 /// `ProviderKind::Moonshot` intentionally retains its generic direct-API
8910 /// metadata in `codewhale-config`: that remains correct for Moonshot's own
8911 /// platform route. The Kimi Code membership endpoint is a distinct route and
8912 /// must not send its users to the generic API console or imply CLI credential
8913 /// import support.
8914 pub(crate) fn credential_help_for_provider_route(
8915 provider: ApiProvider,
8916 base_url: &str,
8917 ) -> codewhale_config::provider::CredentialHelp {
8918 provider.kind().map_or_else(
8919 || provider.credential_help(),
8920 |kind| codewhale_config::provider::credential_help_for_route(kind, base_url),
8921 )
8922 }
8923
8924 pub(crate) fn provider_config_uses_kimi_imported_token(config: &ProviderConfig) -> bool {
8925 config
8926 .auth_mode
8927 .as_deref()
8928 .is_some_and(auth_mode_uses_kimi_imported_token)
8929 }
8930
8931 pub(crate) use codewhale_config::{
8932 auth_mode_disables_api_key, auth_mode_requires_api_key, auth_mode_uses_kimi_imported_token,
8933 };
8934
8935 fn provider_config_uses_xai_oauth(config: &ProviderConfig) -> bool {
8936 config
8937 .auth_mode
8938 .as_deref()
8939 .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
8940 }
8941
8942 /// Whether a base URL points at a loopback/unspecified host, i.e. a local
8943 /// runtime rather than a hosted endpoint. Shared by the active-provider
8944 /// local-base-url check above and the `/provider` picker's custom-provider
8945 /// auth-optionality heuristic (#3830).
8946 pub(crate) fn base_url_uses_local_host(base_url: &str) -> bool {
8947 let Some(host) = base_url_host(base_url) else {
8948 return false;
8949 };
8950 let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
8951 if matches!(host.as_str(), "localhost" | "0.0.0.0") {
8952 return true;
8953 }
8954 host.parse::<std::net::IpAddr>()
8955 .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
8956 }
8957
8958 fn base_url_host(base_url: &str) -> Option<&str> {
8959 let without_scheme = base_url
8960 .split_once("://")
8961 .map_or(base_url, |(_, rest)| rest);
8962 let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
8963 if let Some(rest) = authority.strip_prefix('[') {
8964 return rest.split_once(']').map(|(host, _)| host);
8965 }
8966 authority.split(':').next().filter(|host| !host.is_empty())
8967 }
8968
8969 fn model_for_provider(provider: ApiProvider, normalized: String) -> String {
8970 let lowered = normalized.to_ascii_lowercase();
8971 match (provider, lowered.as_str()) {
8972 (ApiProvider::NvidiaNim, "deepseek-v4-pro") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),
8973 (ApiProvider::NvidiaNim, "deepseek-v4-flash") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
8974 (ApiProvider::Openrouter, "deepseek-v4-pro") => DEFAULT_OPENROUTER_MODEL.to_string(),
8975 (ApiProvider::Openrouter, "deepseek-v4-flash") => {
8976 DEFAULT_OPENROUTER_FLASH_MODEL.to_string()
8977 }
8978 (ApiProvider::Novita, "deepseek-v4-pro") => DEFAULT_NOVITA_MODEL.to_string(),
8979 (ApiProvider::Novita, "deepseek-v4-flash") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
8980 (ApiProvider::Fireworks, "deepseek-v4-pro") => DEFAULT_FIREWORKS_MODEL.to_string(),
8981 (
8982 ApiProvider::Siliconflow | ApiProvider::SiliconflowCn,
8983 "deepseek-v4-pro" | "deepseek-reasoner" | "deepseek-r1",
8984 ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
8985 (
8986 ApiProvider::Siliconflow | ApiProvider::SiliconflowCn,
8987 "deepseek-v4-flash" | "deepseek-chat" | "deepseek-v3",
8988 ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
8989 (ApiProvider::Sglang, "deepseek-v4-pro") => DEFAULT_SGLANG_MODEL.to_string(),
8990 (ApiProvider::Sglang, "deepseek-v4-flash") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
8991 (ApiProvider::Vllm, "deepseek-v4-pro") => DEFAULT_VLLM_MODEL.to_string(),
8992 (ApiProvider::Vllm, "deepseek-v4-flash") => DEFAULT_VLLM_FLASH_MODEL.to_string(),
8993 (ApiProvider::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
8994 DEFAULT_DEEPINFRA_MODEL.to_string()
8995 }
8996 (ApiProvider::Deepinfra, "deepseek-v4-flash" | "deepseek-chat" | "deepseek-reasoner") => {
8997 DEFAULT_DEEPINFRA_FLASH_MODEL.to_string()
8998 }
8999 (ApiProvider::Together, "deepseek-v4-pro" | "deepseek-v4pro") => {
9000 DEFAULT_TOGETHER_MODEL.to_string()
9001 }
9002 (
9003 ApiProvider::Together,
9004 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner",
9005 ) => DEFAULT_TOGETHER_FLASH_MODEL.to_string(),
9006 (ApiProvider::Together, "inkling" | "together-inkling" | "thinkingmachines/inkling") => {
9007 TOGETHER_INKLING_MODEL.to_string()
9008 }
9009 (
9010 ApiProvider::Moonshot,
9011 "kimi"
9012 | "kimi-k2"
9013 | "kimi-k2.7"
9014 | "kimi-k2-7"
9015 | "kimi-k2.7-code"
9016 | "kimi-k2-7-code"
9017 | "kimi-code"
9018 | "moonshot-kimi-k2.7-code",
9019 ) => DEFAULT_MOONSHOT_MODEL.to_string(),
9020 (ApiProvider::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
9021 MOONSHOT_KIMI_K2_6_MODEL.to_string()
9022 }
9023 _ => normalized,
9024 }
9025 }
9026
9027 fn normalize_base_url(base: &str) -> String {
9028 let trimmed = base.trim_end_matches('/');
9029 let deepseek_domains = ["api.deepseek.com", "api.deepseeki.com"];
9030 if deepseek_domains
9031 .iter()
9032 .any(|domain| trimmed.contains(domain))
9033 {
9034 return trimmed.trim_end_matches("/v1").to_string();
9035 }
9036 trimmed.to_string()
9037 }
9038
9039 fn parse_http_headers(raw: &str) -> Result<HashMap<String, String>> {
9040 let mut headers = HashMap::new();
9041 for pair in raw.trim().split(',') {
9042 let pair = pair.trim();
9043 if pair.is_empty() {
9044 continue;
9045 }
9046 let Some((name, value)) = pair.split_once('=') else {
9047 anyhow::bail!("invalid header pair '{pair}', expected name=value");
9048 };
9049 let name = name.trim();
9050 let value = value.trim();
9051 if name.is_empty() {
9052 anyhow::bail!("header name cannot be empty");
9053 }
9054 if value.is_empty() {
9055 continue;
9056 }
9057 headers.insert(name.to_string(), value.to_string());
9058 }
9059 Ok(headers)
9060 }
9061
9062 fn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result<Config> {
9063 if let Some(profile_name) = profile {
9064 let profiles = config.profiles.as_ref();
9065 match profiles.and_then(|profiles| profiles.get(profile_name)) {
9066 Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),
9067 None => {
9068 let available = profiles
9069 .map(|profiles| {
9070 let mut keys = profiles.keys().cloned().collect::<Vec<_>>();
9071 keys.sort();
9072 if keys.is_empty() {
9073 "none".to_string()
9074 } else {
9075 keys.join(", ")
9076 }
9077 })
9078 .unwrap_or_else(|| "none".to_string());
9079 anyhow::bail!("Profile '{profile_name}' not found. Available profiles: {available}")
9080 }
9081 }
9082 } else {
9083 Ok(config.base)
9084 }
9085 }
9086
9087 fn merge_config(base: Config, override_cfg: Config) -> Config {
9088 // Captured before the struct literal moves the field out of `override_cfg`.
9089 let override_defines_root_base_url = override_cfg.base_url.is_some();
9090 Config {
9091 provider: override_cfg.provider.or(base.provider),
9092 api_key: override_cfg.api_key.or(base.api_key),
9093 base_url: override_cfg.base_url.or(base.base_url),
9094 http_headers: override_cfg.http_headers.or(base.http_headers),
9095 default_text_model: override_cfg.default_text_model.or(base.default_text_model),
9096 auth_mode: override_cfg.auth_mode.or(base.auth_mode),
9097 reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),
9098 reasoning_effort_inferred_from_legacy_alias: override_cfg
9099 .reasoning_effort_inferred_from_legacy_alias
9100 || base.reasoning_effort_inferred_from_legacy_alias,
9101 migrated_deepseek_model_alias: override_cfg
9102 .migrated_deepseek_model_alias
9103 .or(base.migrated_deepseek_model_alias),
9104 tools: override_cfg.tools.or(base.tools),
9105 skills_dir: override_cfg.skills_dir.or(base.skills_dir),
9106 mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),
9107 mcp_oauth_callback_port: override_cfg
9108 .mcp_oauth_callback_port
9109 .or(base.mcp_oauth_callback_port),
9110 mcp_oauth_callback_url: override_cfg
9111 .mcp_oauth_callback_url
9112 .or(base.mcp_oauth_callback_url),
9113 notes_path: override_cfg.notes_path.or(base.notes_path),
9114 memory_path: override_cfg.memory_path.or(base.memory_path),
9115 vision_model: override_cfg.vision_model.or(base.vision_model),
9116 // #454: user-owned overlays such as profiles and managed config may
9117 // replace the instruction array. Project-scope config is filtered in
9118 // main.rs and cannot set instruction paths.
9119 instructions: override_cfg.instructions.or(base.instructions),
9120 stop_words: override_cfg.stop_words.or(base.stop_words),
9121 allow_shell: override_cfg.allow_shell.or(base.allow_shell),
9122 prompt_suggestion: override_cfg.prompt_suggestion.or(base.prompt_suggestion),
9123 yolo: override_cfg.yolo.or(base.yolo),
9124 verbosity: override_cfg.verbosity.or(base.verbosity),
9125 approval_policy: override_cfg.approval_policy.or(base.approval_policy),
9126 sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),
9127 fallback_providers: if override_cfg.fallback_providers.is_empty() {
9128 base.fallback_providers
9129 } else {
9130 override_cfg.fallback_providers
9131 },
9132 sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),
9133 sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),
9134 sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),
9135 prefer_bwrap: override_cfg.prefer_bwrap.or(base.prefer_bwrap),
9136 managed_config_path: override_cfg
9137 .managed_config_path
9138 .or(base.managed_config_path),
9139 requirements_path: override_cfg.requirements_path.or(base.requirements_path),
9140 max_subagents: override_cfg.max_subagents.or(base.max_subagents),
9141 retry: override_cfg.retry.or(base.retry),
9142 auto_review: override_cfg.auto_review.or(base.auto_review),
9143 tui: override_cfg.tui.or(base.tui),
9144 hooks: override_cfg.hooks.or(base.hooks),
9145 providers: merge_providers(base.providers, override_cfg.providers),
9146 features: merge_features(base.features, override_cfg.features),
9147 notifications: override_cfg.notifications.or(base.notifications),
9148 network: override_cfg.network.or(base.network),
9149 verifier: override_cfg.verifier.or(base.verifier),
9150 advisor: override_cfg.advisor.or(base.advisor),
9151 skills: merge_skills_config(base.skills, override_cfg.skills),
9152 snapshots: override_cfg.snapshots.or(base.snapshots),
9153 search: override_cfg.search.or(base.search),
9154 goal: override_cfg.goal.or(base.goal),
9155 memory: override_cfg.memory.or(base.memory),
9156 speech: override_cfg.speech.or(base.speech),
9157 auto: override_cfg.auto.or(base.auto),
9158 hotbar: override_cfg.hotbar.or(base.hotbar),
9159 update: override_cfg.update.or(base.update),
9160 lsp: override_cfg.lsp.or(base.lsp),
9161 context: ContextConfig {
9162 enabled: override_cfg.context.enabled.or(base.context.enabled),
9163 project_pack: override_cfg
9164 .context
9165 .project_pack
9166 .or(base.context.project_pack),
9167 verbatim_window_turns: override_cfg
9168 .context
9169 .verbatim_window_turns
9170 .or(base.context.verbatim_window_turns),
9171 l1_threshold: override_cfg
9172 .context
9173 .l1_threshold
9174 .or(base.context.l1_threshold),
9175 l2_threshold: override_cfg
9176 .context
9177 .l2_threshold
9178 .or(base.context.l2_threshold),
9179 l3_threshold: override_cfg
9180 .context
9181 .l3_threshold
9182 .or(base.context.l3_threshold),
9183 seam_model: override_cfg.context.seam_model.or(base.context.seam_model),
9184 },
9185 fleet: override_cfg.fleet.or(base.fleet),
9186 workflow: override_cfg.workflow.or(base.workflow),
9187 subagents: override_cfg.subagents.or(base.subagents),
9188 strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),
9189 runtime_api: override_cfg.runtime_api.or(base.runtime_api),
9190 workshop: override_cfg.workshop.or(base.workshop),
9191 exec_policy_engine: override_cfg.exec_policy_engine,
9192 base_url_env_receipt: match override_cfg.base_url_env_receipt {
9193 BaseUrlEnvReceipt::Unrecorded => base.base_url_env_receipt,
9194 recorded => recorded,
9195 },
9196 // A layer that supplies its own root `base_url` replaces the
9197 // environment's write, so that layer's ownership wins outright.
9198 root_base_url_owner: if override_defines_root_base_url {
9199 override_cfg.root_base_url_owner
9200 } else {
9201 match override_cfg.root_base_url_owner {
9202 BaseUrlEnvReceipt::Unrecorded => base.root_base_url_owner,
9203 recorded => recorded,
9204 }
9205 },
9206 }
9207 }
9208
9209 fn load_sibling_exec_policy_engine(config_path: Option<&Path>) -> Result<ExecPolicyEngine> {
9210 let Some(config_path) = config_path else {
9211 return Ok(ExecPolicyEngine::new(Vec::new(), Vec::new()));
9212 };
9213 let permissions_path = codewhale_config::permissions_path_for_config_path(config_path);
9214 if !permissions_path.exists() {
9215 return Ok(ExecPolicyEngine::new(Vec::new(), Vec::new()));
9216 }
9217
9218 let raw = fs::read_to_string(&permissions_path).with_context(|| {
9219 format!(
9220 "Failed to read permissions file: {}",
9221 permissions_path.display()
9222 )
9223 })?;
9224 let permissions: codewhale_config::PermissionsToml = toml::from_str(&raw).map_err(|_| {
9225 anyhow::anyhow!(
9226 "Failed to parse permissions file {}; file contents were omitted",
9227 codewhale_config::quote_os_path(&permissions_path)
9228 )
9229 })?;
9230 if permissions.is_empty() {
9231 Ok(ExecPolicyEngine::new(Vec::new(), Vec::new()))
9232 } else {
9233 Ok(ExecPolicyEngine::with_rulesets(vec![permissions.ruleset()]))
9234 }
9235 }
9236
9237 fn merge_skills_config(
9238 base: Option<SkillsConfig>,
9239 override_cfg: Option<SkillsConfig>,
9240 ) -> Option<SkillsConfig> {
9241 match (base, override_cfg) {
9242 (None, None) => None,
9243 (Some(base), None) => Some(base),
9244 (None, Some(override_cfg)) => Some(override_cfg),
9245 (Some(base), Some(override_cfg)) => Some(SkillsConfig {
9246 registry_url: override_cfg.registry_url.or(base.registry_url),
9247 max_install_size_bytes: override_cfg
9248 .max_install_size_bytes
9249 .or(base.max_install_size_bytes),
9250 scan_codewhale_only: override_cfg
9251 .scan_codewhale_only
9252 .or(base.scan_codewhale_only),
9253 }),
9254 }
9255 }
9256
9257 fn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {
9258 ProviderConfig {
9259 api_key: override_cfg.api_key.or(base.api_key),
9260 base_url: override_cfg.base_url.or(base.base_url),
9261 model: override_cfg.model.or(base.model),
9262 context_window: override_cfg.context_window.or(base.context_window),
9263 mode: override_cfg.mode.or(base.mode),
9264 wire: override_cfg.wire.or(base.wire),
9265 auth_mode: override_cfg.auth_mode.or(base.auth_mode),
9266 oauth_credential_generation: override_cfg
9267 .oauth_credential_generation
9268 .or(base.oauth_credential_generation),
9269 insecure_skip_tls_verify: override_cfg
9270 .insecure_skip_tls_verify
9271 .or(base.insecure_skip_tls_verify),
9272 http_headers: override_cfg.http_headers.or(base.http_headers),
9273 path_suffix: override_cfg.path_suffix.or(base.path_suffix),
9274 reasoning_stream_style: override_cfg
9275 .reasoning_stream_style
9276 .or(base.reasoning_stream_style),
9277 max_concurrency: override_cfg.max_concurrency.or(base.max_concurrency),
9278 auth: override_cfg.auth.or(base.auth),
9279 external_credentials: override_cfg
9280 .external_credentials
9281 .or(base.external_credentials),
9282 kind: override_cfg.kind.or(base.kind),
9283 api_key_env: override_cfg.api_key_env.or(base.api_key_env),
9284 }
9285 }
9286
9287 /// Merge the per-name custom provider maps (#1519): the union of both key sets,
9288 /// with each shared key deep-merged via [`merge_provider_config`] (override
9289 /// wins field-by-field). Keys present in only one map are carried through as-is.
9290 fn merge_custom_providers(
9291 mut base: HashMap<String, ProviderConfig>,
9292 override_cfg: HashMap<String, ProviderConfig>,
9293 ) -> HashMap<String, ProviderConfig> {
9294 for (name, entry) in override_cfg {
9295 let merged = match base.remove(&name) {
9296 Some(base_entry) => merge_provider_config(base_entry, entry),
9297 None => entry,
9298 };
9299 base.insert(name, merged);
9300 }
9301 base
9302 }
9303
9304 fn merge_providers(
9305 base: Option<ProvidersConfig>,
9306 override_cfg: Option<ProvidersConfig>,
9307 ) -> Option<ProvidersConfig> {
9308 match (base, override_cfg) {
9309 (None, None) => None,
9310 (Some(base), None) => Some(base),
9311 (None, Some(override_cfg)) => Some(override_cfg),
9312 (Some(base), Some(override_cfg)) => Some(ProvidersConfig {
9313 deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),
9314 deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),
9315 deepseek_anthropic: merge_provider_config(
9316 base.deepseek_anthropic,
9317 override_cfg.deepseek_anthropic,
9318 ),
9319 nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),
9320 openai: merge_provider_config(base.openai, override_cfg.openai),
9321 anthropic: merge_provider_config(base.anthropic, override_cfg.anthropic),
9322 openmodel: merge_provider_config(base.openmodel, override_cfg.openmodel),
9323 atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),
9324 wanjie_ark: merge_provider_config(base.wanjie_ark, override_cfg.wanjie_ark),
9325 openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),
9326 xiaomi_mimo: merge_provider_config(base.xiaomi_mimo, override_cfg.xiaomi_mimo),
9327 novita: merge_provider_config(base.novita, override_cfg.novita),
9328 fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),
9329 siliconflow: merge_provider_config(base.siliconflow, override_cfg.siliconflow),
9330 siliconflow_cn: merge_provider_config(base.siliconflow_cn, override_cfg.siliconflow_cn),
9331 arcee: merge_provider_config(base.arcee, override_cfg.arcee),
9332 moonshot: merge_provider_config(base.moonshot, override_cfg.moonshot),
9333 sglang: merge_provider_config(base.sglang, override_cfg.sglang),
9334 vllm: merge_provider_config(base.vllm, override_cfg.vllm),
9335 ollama: merge_provider_config(base.ollama, override_cfg.ollama),
9336 volcengine: merge_provider_config(base.volcengine, override_cfg.volcengine),
9337 huggingface: merge_provider_config(base.huggingface, override_cfg.huggingface),
9338 deepinfra: merge_provider_config(base.deepinfra, override_cfg.deepinfra),
9339 together: merge_provider_config(base.together, override_cfg.together),
9340 qianfan: merge_provider_config(base.qianfan, override_cfg.qianfan),
9341 openai_codex: merge_provider_config(base.openai_codex, override_cfg.openai_codex),
9342 zai: merge_provider_config(base.zai, override_cfg.zai),
9343 stepfun: merge_provider_config(base.stepfun, override_cfg.stepfun),
9344 minimax: merge_provider_config(base.minimax, override_cfg.minimax),
9345 minimax_anthropic: merge_provider_config(
9346 base.minimax_anthropic,
9347 override_cfg.minimax_anthropic,
9348 ),
9349 sakana: merge_provider_config(base.sakana, override_cfg.sakana),
9350 longcat: merge_provider_config(base.longcat, override_cfg.longcat),
9351 opencode_go: merge_provider_config(base.opencode_go, override_cfg.opencode_go),
9352 opencode_zen: merge_provider_config(base.opencode_zen, override_cfg.opencode_zen),
9353 meta: merge_provider_config(base.meta, override_cfg.meta),
9354 xai: merge_provider_config(base.xai, override_cfg.xai),
9355 telecomjs: merge_provider_config(base.telecomjs, override_cfg.telecomjs),
9356 modelstudio_token_plan: merge_provider_config(
9357 base.modelstudio_token_plan,
9358 override_cfg.modelstudio_token_plan,
9359 ),
9360 modelstudio_token_plan_anthropic: merge_provider_config(
9361 base.modelstudio_token_plan_anthropic,
9362 override_cfg.modelstudio_token_plan_anthropic,
9363 ),
9364 modelstudio_coding_plan: merge_provider_config(
9365 base.modelstudio_coding_plan,
9366 override_cfg.modelstudio_coding_plan,
9367 ),
9368 modelstudio_coding_plan_anthropic: merge_provider_config(
9369 base.modelstudio_coding_plan_anthropic,
9370 override_cfg.modelstudio_coding_plan_anthropic,
9371 ),
9372 custom: merge_custom_providers(base.custom, override_cfg.custom),
9373 }),
9374 }
9375 }
9376
9377 fn load_single_config_file(path: &Path) -> Result<Config> {
9378 let contents = fs::read_to_string(path)
9379 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
9380 let parsed: ConfigFile = toml::from_str(&contents).map_err(|_| {
9381 anyhow::anyhow!(
9382 "Failed to parse config file {}; file contents were omitted",
9383 codewhale_config::quote_os_path(path)
9384 )
9385 })?;
9386 Ok(parsed.base)
9387 }
9388
9389 /// Build a one-line warning when top-level-only keys are nested under a section
9390 /// Codewhale does not define (`[general]` / `[sandbox]`). TOML silently drops
9391 /// those keys, so e.g. `[general]\nallow_shell = true` never takes effect and
9392 /// the shell tools (`exec_shell`, `task_shell_start`, …) are absent from the
9393 /// catalog with no explanation. Returns `None` when nothing is misplaced.
9394 ///
9395 /// This is the exact confusion behind #2589: `allow_shell` and `sandbox_mode`
9396 /// belong at the top of the file, above any `[section]` header.
9397 fn warn_on_misplaced_top_level_keys(raw: &str) -> Option<String> {
9398 let doc = toml::from_str::<toml::Value>(raw).ok()?;
9399 // Sections Codewhale does not recognize but users nest settings under.
9400 const UNKNOWN_SECTIONS: &[&str] = &["general", "sandbox"];
9401 // Keys that are only ever read from the top level of the config.
9402 const TOP_LEVEL_KEYS: &[&str] = &[
9403 "allow_shell",
9404 "sandbox_mode",
9405 "approval_policy",
9406 "verbosity",
9407 ];
9408
9409 let mut hits: Vec<String> = Vec::new();
9410 for section in UNKNOWN_SECTIONS {
9411 let Some(table) = doc.get(*section).and_then(toml::Value::as_table) else {
9412 continue;
9413 };
9414 for key in TOP_LEVEL_KEYS {
9415 if table.contains_key(*key) {
9416 hits.push(format!("`{section}.{key}`"));
9417 }
9418 }
9419 }
9420 if hits.is_empty() {
9421 return None;
9422 }
9423 Some(format!(
9424 "Ignoring {} — Codewhale has no `[general]` or `[sandbox]` section, so these \
9425 keys are silently dropped. Move them to the TOP of the config file (above any \
9426 `[section]` header), e.g. `allow_shell = true`. Until then, shell tools stay \
9427 disabled. (#2589)",
9428 hits.join(", ")
9429 ))
9430 }
9431
9432 fn apply_managed_overrides(config: &mut Config) -> Result<()> {
9433 let path = config
9434 .managed_config_path
9435 .as_deref()
9436 .map(expand_path)
9437 .or_else(default_managed_config_path);
9438 let Some(path) = path else {
9439 return Ok(());
9440 };
9441 if !path.exists() {
9442 return Ok(());
9443 }
9444 let mut managed = load_single_config_file(&path)?;
9445 strip_external_credential_consent(&mut managed);
9446 let prior_route = (
9447 config.api_provider(),
9448 config.provider_identity_for(config.api_provider()),
9449 );
9450 let mut merged = merge_config(config.clone(), managed.clone());
9451 let merged_route = (
9452 merged.api_provider(),
9453 merged.provider_identity_for(merged.api_provider()),
9454 );
9455 if prior_route != merged_route || config_defines_base_url_for_effective_route(&managed, &merged)
9456 {
9457 // Managed configuration is a higher-precedence file layer. If it
9458 // selects a different route or supplies that route's endpoint, the
9459 // lower environment layer no longer owns the effective base URL.
9460 //
9461 // Record that as an explicit "nobody owns it" rather than clearing the
9462 // receipt. Clearing it would read as "this config never met the
9463 // environment layer", which re-enables the generic
9464 // `CODEWHALE_BASE_URL` fallback for every route — including pinned
9465 // cross-provider children, which would then borrow an ambient host
9466 // that managed routing had just taken authority over.
9467 merged.base_url_env_receipt = BaseUrlEnvReceipt::NoOwner;
9468 // The shared legacy root field is the same ambient host by another
9469 // name. If the environment wrote it, managed authority takes it from
9470 // every route rather than leaving it addressed to the identity that
9471 // was active before the overlay. A *file*-owned root is left alone:
9472 // managed did not override it, so it stays the user's value.
9473 if matches!(merged.root_base_url_owner, BaseUrlEnvReceipt::Route(..)) {
9474 merged.root_base_url_owner = BaseUrlEnvReceipt::NoOwner;
9475 }
9476 }
9477 *config = merged;
9478 Ok(())
9479 }
9480
9481 /// Organization-managed overlays may constrain routing and policy, but they
9482 /// cannot consent on a user's behalf to credential files owned by another
9483 /// CLI. Only the user config/profile loaded before this layer may carry these
9484 /// grants. A managed `disabled` record is a tightening tombstone and is kept
9485 /// so a lower-precedence user grant cannot survive an administrator deny.
9486 fn strip_external_credential_consent(config: &mut Config) {
9487 if config.providers.is_none() {
9488 return;
9489 }
9490 for provider in ApiProvider::all()
9491 .iter()
9492 .copied()
9493 .filter(|provider| *provider != ApiProvider::Custom)
9494 {
9495 let external = &mut config
9496 .provider_config_for_mut(provider)
9497 .external_credentials;
9498 if external.as_ref().is_some_and(|consent| {
9499 consent.access != codewhale_config::ExternalCredentialAccess::Disabled
9500 }) {
9501 *external = None;
9502 }
9503 }
9504 if let Some(providers) = config.providers.as_mut() {
9505 for provider in providers.custom.values_mut() {
9506 if provider
9507 .external_credentials
9508 .as_ref()
9509 .is_some_and(|consent| {
9510 consent.access != codewhale_config::ExternalCredentialAccess::Disabled
9511 })
9512 {
9513 provider.external_credentials = None;
9514 }
9515 }
9516 }
9517 }
9518
9519 fn config_defines_base_url_for_effective_route(source: &Config, effective: &Config) -> bool {
9520 let provider = effective.api_provider();
9521 let mut source = source.clone();
9522 source.provider.clone_from(&effective.provider);
9523 let provider_base = source
9524 .provider_config_string_with_runtime_fallback(provider, |entry| entry.base_url.clone());
9525 let configured = match provider {
9526 ApiProvider::Deepseek | ApiProvider::DeepseekCN => provider_base.or(source.base_url),
9527 ApiProvider::NvidiaNim => provider_base.or_else(|| {
9528 source
9529 .base_url
9530 .filter(|base| base.contains("integrate.api.nvidia.com"))
9531 }),
9532 ApiProvider::Custom if effective.uses_legacy_literal_custom_route() => source.base_url,
9533 _ => provider_base,
9534 };
9535 configured.is_some_and(|base| !base.trim().is_empty())
9536 }
9537
9538 fn apply_requirements(config: &mut Config) -> Result<()> {
9539 let path = config
9540 .requirements_path
9541 .as_deref()
9542 .map(expand_path)
9543 .or_else(default_requirements_path);
9544 let Some(path) = path else {
9545 return Ok(());
9546 };
9547 if !path.exists() {
9548 return Ok(());
9549 }
9550 let contents = fs::read_to_string(&path)
9551 .with_context(|| format!("Failed to read requirements file: {}", path.display()))?;
9552 let requirements: RequirementsFile = toml::from_str(&contents).map_err(|_| {
9553 anyhow::anyhow!(
9554 "Failed to parse requirements file {}; file contents were omitted",
9555 codewhale_config::quote_os_path(&path)
9556 )
9557 })?;
9558
9559 if !requirements.allowed_approval_policies.is_empty()
9560 && let Some(policy) = config.approval_policy.as_ref()
9561 {
9562 let policy = policy.to_ascii_lowercase();
9563 if !requirements
9564 .allowed_approval_policies
9565 .iter()
9566 .any(|p| p.eq_ignore_ascii_case(&policy))
9567 {
9568 anyhow::bail!(
9569 "approval_policy '{policy}' is not allowed by requirements ({})",
9570 requirements.allowed_approval_policies.join(", ")
9571 );
9572 }
9573 }
9574 if !requirements.allowed_sandbox_modes.is_empty()
9575 && let Some(mode) = config.sandbox_mode.as_ref()
9576 {
9577 let mode = mode.to_ascii_lowercase();
9578 if !requirements
9579 .allowed_sandbox_modes
9580 .iter()
9581 .any(|m| m.eq_ignore_ascii_case(&mode))
9582 {
9583 anyhow::bail!(
9584 "sandbox_mode '{mode}' is not allowed by requirements ({})",
9585 requirements.allowed_sandbox_modes.join(", ")
9586 );
9587 }
9588 }
9589
9590 Ok(())
9591 }
9592
9593 fn merge_features(
9594 base: Option<FeaturesToml>,
9595 override_cfg: Option<FeaturesToml>,
9596 ) -> Option<FeaturesToml> {
9597 match (base, override_cfg) {
9598 (None, None) => None,
9599 (Some(mut base), Some(override_cfg)) => {
9600 for (key, value) in override_cfg.entries {
9601 base.entries.insert(key, value);
9602 }
9603 Some(base)
9604 }
9605 (Some(base), None) => Some(base),
9606 (None, Some(override_cfg)) => Some(override_cfg),
9607 }
9608 }
9609
9610 pub fn ensure_parent_dir(path: &Path) -> Result<()> {
9611 if let Some(parent) = path.parent() {
9612 fs::create_dir_all(parent)
9613 .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
9614 #[cfg(unix)]
9615 {
9616 // Tighten group/other bits on the parent dir as a hardening pass.
9617 // The dir lives under the user's home, so the chmod is best-effort:
9618 // filesystems that don't accept Unix permission bits (Docker
9619 // bind-mounts of NTFS, network shares, FAT, certain CI volumes —
9620 // see #897) return EPERM/ENOTSUP. The dir already exists by the
9621 // time we get here, so failing the whole save just because we
9622 // couldn't tighten perms strands the user mid-onboarding. Warn
9623 // loudly so a security-sensitive operator can still notice via
9624 // `RUST_LOG=warn`, then continue.
9625 if let Ok(meta) = fs::metadata(parent) {
9626 let mode = meta.permissions().mode();
9627 if mode & 0o077 != 0 {
9628 let mut perms = meta.permissions();
9629 perms.set_mode(mode & !0o077);
9630 if let Err(err) = fs::set_permissions(parent, perms) {
9631 tracing::warn!(
9632 target: "codewhale::config",
9633 path = %parent.display(),
9634 error = %err,
9635 "could not tighten parent dir permissions; \
9636 filesystem may not support Unix chmod \
9637 (Docker bind-mount, NTFS, network share). \
9638 Continuing — the file will still be written."
9639 );
9640 }
9641 }
9642 }
9643 }
9644 }
9645 Ok(())
9646 }
9647
9648 /// Write content to a config file with restrictive permissions (owner-only read/write).
9649 /// On Unix this sets mode 0o600 before writing.
9650 fn write_config_file_secure(path: &Path, content: &str) -> Result<()> {
9651 codewhale_config::create_config_document(path, content)
9652 }
9653
9654 /// Where a saved credential ended up. Returned by [`save_api_key`] so
9655 /// the caller can show a confirmation message without leaking the key.
9656 #[derive(Debug, Clone, PartialEq, Eq)]
9657 pub enum SavedCredential {
9658 /// Stored in the durable secret store. The config file contains only
9659 /// non-secret provider metadata and has any matching plaintext `api_key`
9660 /// entry removed. The `backend` label is the value of
9661 /// [`codewhale_secrets::Secrets::backend_name`] at write time so the toast
9662 /// text can name the actual backend (`"system keyring"`,
9663 /// `"file-based (~/.codewhale/secrets/)"`).
9664 KeyringAndConfigFile {
9665 /// `Secrets::backend_name()` at write time.
9666 backend: String,
9667 /// Absolute path to the credential-free config metadata file.
9668 path: PathBuf,
9669 },
9670 /// Stored in the Codewhale config file only under `cfg(test)` so unit tests
9671 /// without an explicitly isolated secret backend do not pollute the host
9672 /// credential store. Production save flows never automatically downgrade
9673 /// a failed secret-store write to plaintext.
9674 ConfigFile(PathBuf),
9675 }
9676
9677 impl SavedCredential {
9678 /// Human-readable description for status / log output. Never
9679 /// includes the key value.
9680 #[must_use]
9681 pub fn describe(&self) -> String {
9682 match self {
9683 Self::KeyringAndConfigFile { backend, path } => {
9684 format!(
9685 "secret store ({backend}); credential-free config metadata in {}",
9686 path.display()
9687 )
9688 }
9689 Self::ConfigFile(path) => path.display().to_string(),
9690 }
9691 }
9692 }
9693
9694 /// Resolve the config document for CREDENTIAL writes: api_key values,
9695 /// `auth_mode` markers, and oauth/external-credential pointers.
9696 ///
9697 /// Credentials are user-global — a key saved while working in one repo must be
9698 /// visible from every other repo (#5045, #5193). The ambient
9699 /// `CODEWHALE_CONFIG_PATH`/`DEEPSEEK_CONFIG_PATH` override can point at a
9700 /// workspace-scoped document (`<repo>/.codewhale/config.toml`, plaintext and
9701 /// easy to commit by accident), so credential writes that would land there are
9702 /// rescoped to the user-global config instead. Non-credential settings keep
9703 /// the ambient scoping, and callers that pass an explicit config path never
9704 /// consult this resolver; a per-workspace destination stays possible only as
9705 /// that kind of explicit opt-in.
9706 fn credential_config_path() -> anyhow::Result<PathBuf> {
9707 let resolved = try_default_config_path()?;
9708 if !codewhale_config::config_path_is_workspace_scoped(&resolved) {
9709 return Ok(resolved);
9710 }
9711 let global = home_config_path()
9712 .context("Failed to resolve user-global config path: home directory not found.")?;
9713 tracing::info!(
9714 ambient = %resolved.display(),
9715 global = %global.display(),
9716 "rescoping credential write from workspace config to user-global config"
9717 );
9718 Ok(global)
9719 }
9720
9721 /// Save the active provider's API key.
9722 ///
9723 /// The selected durable secret backend is attempted first. On success the
9724 /// config keeps only non-secret auth metadata and any older plaintext copy is
9725 /// removed. When the secret-store write fails (OS permission denied, corrupt
9726 /// or read-only file backend, etc.), the save fails loudly rather than writing
9727 /// the key to plaintext `config.toml`.
9728 ///
9729 /// Under `cfg(test)` the secret-store path is enabled only when the test sets
9730 /// both an isolated `CODEWHALE_HOME` and an explicit backend, preventing unit
9731 /// tests from touching the developer's real credential store.
9732 pub fn save_api_key(api_key: &str) -> Result<SavedCredential> {
9733 save_root_api_key_for_secret_slot(api_key, "deepseek", true)
9734 }
9735
9736 fn save_root_api_key_for_secret_slot(
9737 api_key: &str,
9738 secret_slot: &str,
9739 clear_deepseek_provider_slot: bool,
9740 ) -> Result<SavedCredential> {
9741 let trimmed = api_key.trim();
9742 if trimmed.is_empty() {
9743 anyhow::bail!("Refusing to save an empty API key.");
9744 }
9745
9746 let path = credential_config_path().context("Failed to resolve config path for API key.")?;
9747
9748 if let Some(secrets) = credential_secret_store() {
9749 let prior_secret = secrets.get(secret_slot);
9750 match prior_secret.as_ref() {
9751 Ok(prior) => match secrets.set(secret_slot, trimmed) {
9752 Ok(()) => {
9753 if let Err(error) = save_root_api_key_metadata_without_plaintext(
9754 &path,
9755 clear_deepseek_provider_slot,
9756 ) {
9757 let current = secrets.get(secret_slot).map_err(|rollback| {
9758 anyhow::anyhow!(
9759 "{error}; additionally could not verify secret-store rollback for {secret_slot}: {rollback}"
9760 )
9761 })?;
9762 if current.as_deref() == Some(trimmed) {
9763 match prior {
9764 Some(previous) => secrets.set(secret_slot, previous),
9765 None => secrets.delete(secret_slot),
9766 }
9767 .map_err(|rollback| {
9768 anyhow::anyhow!(
9769 "{error}; additionally failed to restore prior secret-store state for {secret_slot}: {rollback}"
9770 )
9771 })?;
9772 }
9773 return Err(error);
9774 }
9775 codewhale_config::scrub_plaintext_api_keys_from_config_backup(&path)?;
9776 let backend = secrets.backend_name().to_string();
9777 log_sensitive_event(
9778 "credential.save",
9779 json!({
9780 "backend": backend.clone(),
9781 "config_path": path.display().to_string(),
9782 "plaintext_config_fallback": false,
9783 }),
9784 );
9785 return Ok(SavedCredential::KeyringAndConfigFile { backend, path });
9786 }
9787 Err(err) => {
9788 return Err(plaintext_credential_fallback_refused("write", &path, &err));
9789 }
9790 },
9791 Err(error) => {
9792 return Err(plaintext_credential_fallback_refused(
9793 "snapshot", &path, &error,
9794 ));
9795 }
9796 }
9797 }
9798
9799 let path = save_api_key_to_config_file(trimmed)?;
9800 codewhale_config::scrub_plaintext_api_keys_from_config_backup(&path)?;
9801 Ok(SavedCredential::ConfigFile(path))
9802 }
9803
9804 fn plaintext_credential_fallback_refused(
9805 operation: &str,
9806 config_path: &Path,
9807 failure: &dyn std::fmt::Display,
9808 ) -> anyhow::Error {
9809 anyhow::anyhow!(
9810 "Secret storage {operation} failed: {failure}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.",
9811 codewhale_config::quote_os_path(config_path)
9812 )
9813 }
9814
9815 /// The durable secret store for credential saves and logout-time deletes.
9816 ///
9817 /// Under `cfg(test)` the store is only exposed when the test set both an
9818 /// isolated `CODEWHALE_HOME` and an explicit backend, so unit tests can never
9819 /// touch the developer's real credential store.
9820 #[cfg(not(test))]
9821 fn credential_secret_store() -> Option<codewhale_secrets::Secrets> {
9822 Some(codewhale_secrets::Secrets::auto_detect())
9823 }
9824
9825 #[cfg(test)]
9826 fn credential_secret_store() -> Option<codewhale_secrets::Secrets> {
9827 let isolated_home = codewhale_paths::codewhale_home_is_explicit();
9828 let explicit_backend = std::env::var_os("CODEWHALE_SECRET_BACKEND")
9829 .or_else(|| std::env::var_os("DEEPSEEK_SECRET_BACKEND"))
9830 .is_some_and(|value| !value.is_empty());
9831 (isolated_home && explicit_backend).then(codewhale_secrets::Secrets::auto_detect)
9832 }
9833
9834 fn save_root_api_key_metadata_without_plaintext(
9835 config_path: &Path,
9836 clear_deepseek_provider_slot: bool,
9837 ) -> Result<()> {
9838 ensure_parent_dir(config_path)?;
9839 crate::config_persistence::mutate_config_document(config_path, |doc| {
9840 crate::config_persistence::set_document_value(doc, &["auth_mode"], "api_key")?;
9841 if !doc.contains_key("default_text_model") {
9842 crate::config_persistence::set_document_value(
9843 doc,
9844 &["default_text_model"],
9845 DEFAULT_TEXT_MODEL,
9846 )?;
9847 }
9848 if !doc.contains_key("reasoning_effort") {
9849 crate::config_persistence::set_document_value(doc, &["reasoning_effort"], "max")?;
9850 }
9851 crate::config_persistence::unset_document_value(doc, &["api_key"])?;
9852 if clear_deepseek_provider_slot {
9853 crate::config_persistence::unset_document_value(
9854 doc,
9855 &["providers", "deepseek", "api_key"],
9856 )?;
9857 crate::config_persistence::unset_document_value(
9858 doc,
9859 &["providers", "deepseek-cn", "api_key"],
9860 )?;
9861 }
9862 Ok(())
9863 })
9864 .with_context(|| format!("Failed to write config to {}", config_path.display()))
9865 }
9866
9867 /// Write the `api_key` slot directly to `config.toml`.
9868 fn save_api_key_to_config_file(api_key: &str) -> Result<PathBuf> {
9869 let config_path =
9870 credential_config_path().context("Failed to resolve config path for API key.")?;
9871
9872 ensure_parent_dir(&config_path)?;
9873
9874 if config_path.exists() {
9875 // TOML-aware upsert. The old line scan keyed off
9876 // `existing.contains("api_key")`, so a comment that merely mentioned
9877 // api_key made it skip the insert entirely; editing the document
9878 // replaces or inserts the real key and keeps user comments.
9879 crate::config_persistence::mutate_config_document(&config_path, |doc| {
9880 crate::config_persistence::set_document_value(doc, &["api_key"], api_key)?;
9881 crate::config_persistence::set_document_value(doc, &["auth_mode"], "api_key")
9882 })
9883 .with_context(|| format!("Failed to write config to {}", config_path.display()))?;
9884 } else {
9885 // Create new minimal config
9886 let content = format!(
9887 r#"# codewhale Configuration
9888 # Set provider credentials in this file or via environment variables.
9889 # See /links in the TUI for provider-specific credential pages.
9890
9891 api_key = "{api_key}"
9892 auth_mode = "api_key"
9893
9894 # Base URL (default: https://api.deepseek.com/beta)
9895 # Set https://api.deepseek.com to opt out of beta features.
9896 # base_url = "https://api.deepseek.com/beta"
9897
9898 # Default model
9899 default_text_model = "{DEFAULT_TEXT_MODEL}"
9900
9901 # Thinking mode (DeepSeek V4 reasoning effort):
9902 # "off" | "low" | "medium" | "high" | "max"
9903 # Shift+Tab in the TUI cycles between off / high / max.
9904 reasoning_effort = "max"
9905 "#
9906 );
9907 crate::config_persistence::write_config_toml_atomic(&config_path, &content)
9908 .with_context(|| format!("Failed to write config to {}", config_path.display()))?;
9909 }
9910
9911 log_sensitive_event(
9912 "credential.save",
9913 json!({
9914 "backend": "config_file",
9915 "config_path": config_path.display().to_string(),
9916 }),
9917 );
9918
9919 Ok(config_path)
9920 }
9921
9922 /// Check if the active provider has any API key configured anywhere the
9923 /// runtime can resolve it.
9924 ///
9925 /// The default secret store is file-backed and prompt-free. An OS credential
9926 /// store is queried only when the user explicitly selects the system backend.
9927 ///
9928 /// Used by the TUI app constructor to decide whether to gate
9929 /// the user behind the in-TUI api-key onboarding screen — getting
9930 /// this wrong made users get prompted for credentials in situations
9931 /// where normal env/config auth was already available.
9932 pub fn has_api_key(config: &Config) -> bool {
9933 has_api_key_for(config, config.api_provider())
9934 }
9935
9936 fn provider_uses_oauth_credentials(config: &Config, provider: ApiProvider) -> bool {
9937 !auth_mode_disables_api_key(config.auth_mode_for_provider(provider).as_deref())
9938 && !config.provider_uses_custom_endpoint(provider)
9939 && (provider == ApiProvider::OpenaiCodex
9940 || (provider == ApiProvider::Moonshot
9941 && config
9942 .provider_config_for(provider)
9943 .is_some_and(provider_config_uses_kimi_imported_token))
9944 || (provider == ApiProvider::Xai
9945 && config
9946 .provider_config_for(provider)
9947 .is_some_and(provider_config_uses_xai_oauth)))
9948 }
9949
9950 /// The environment variable name a provider route explicitly binds via
9951 /// `[providers.<name>] api_key_env`, when credentials are bound to the active
9952 /// endpoint. `None` when the route declares no binding.
9953 fn bound_provider_api_key_env_name(config: &Config, provider: ApiProvider) -> Option<String> {
9954 if !config.config_credentials_are_bound_to_provider_endpoint(provider) {
9955 return None;
9956 }
9957 config
9958 .provider_config_for(provider)
9959 .and_then(|entry| entry.api_key_env.as_deref())
9960 .map(str::trim)
9961 .filter(|name| !name.is_empty())
9962 .map(str::to_string)
9963 }
9964
9965 fn provider_config_env_api_key(config: &Config, provider: ApiProvider) -> Option<String> {
9966 let env_name = bound_provider_api_key_env_name(config, provider)?;
9967 std::env::var(env_name)
9968 .ok()
9969 .filter(|value| !value.trim().is_empty())
9970 }
9971
9972 #[must_use]
9973 pub fn active_provider_has_config_api_key(config: &Config) -> bool {
9974 let provider = config.api_provider();
9975 if auth_mode_disables_api_key(config.auth_mode_for_provider(provider).as_deref()) {
9976 return false;
9977 }
9978 let custom_endpoint = config.provider_uses_custom_endpoint(provider);
9979
9980 if provider == ApiProvider::Moonshot
9981 && !custom_endpoint
9982 && config
9983 .provider_config_for(provider)
9984 .is_some_and(provider_config_uses_kimi_imported_token)
9985 {
9986 return false;
9987 }
9988 if provider == ApiProvider::OpenaiCodex && !custom_endpoint {
9989 // The persistent Codex login is the OAuth credential file, analogous to
9990 // a stored config key. Token env overrides are scored separately by
9991 // active_provider_has_env_api_key.
9992 let path = crate::oauth::auth_file_path();
9993 return config
9994 .external_credential_read_grant(
9995 provider,
9996 codewhale_config::ExternalCredentialSource::CodexCli,
9997 &path,
9998 )
9999 .is_ok_and(|grant| crate::oauth::stored_credentials_present(&grant));
10000 }
10001 if !custom_endpoint
10002 && matches!(provider, ApiProvider::Huggingface)
10003 && std::env::var("HUGGINGFACE_API_KEY")
10004 .or_else(|_| std::env::var("HF_TOKEN"))
10005 .is_ok_and(|k| !k.trim().is_empty())
10006 {
10007 return true;
10008 }
10009
10010 if config.config_credentials_are_bound_to_provider_endpoint(provider)
10011 && config
10012 .provider_config_string_with_runtime_fallback(provider, |entry| entry.api_key.clone())
10013 .is_some_and(|key| {
10014 classify_config_api_key_value(&key) == ConfigApiKeyValueKind::Literal
10015 })
10016 {
10017 return true;
10018 }
10019 if !config.should_skip_secret_store_for_provider(provider)
10020 && provider_secret_store_api_key(config, provider).is_some()
10021 {
10022 return true;
10023 }
10024
10025 matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
10026 && config.config_credentials_are_bound_to_provider_endpoint(provider)
10027 && config
10028 .api_key
10029 .as_ref()
10030 .is_some_and(|key| classify_config_api_key_value(key) == ConfigApiKeyValueKind::Literal)
10031 }
10032
10033 #[must_use]
10034 pub fn active_provider_has_env_api_key(config: &Config) -> bool {
10035 let provider = config.api_provider();
10036 if auth_mode_disables_api_key(config.auth_mode_for_provider(provider).as_deref()) {
10037 return false;
10038 }
10039 (!provider_uses_oauth_credentials(config, provider)
10040 && explicit_cli_api_key_override().is_some())
10041 || provider_config_env_api_key(config, provider).is_some()
10042 || (!config.should_skip_secret_store_for_provider(provider)
10043 && provider_env_api_key(provider).is_some())
10044 }
10045
10046 #[must_use]
10047 pub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {
10048 active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)
10049 }
10050
10051 /// A key saved in the user-global config file stays visible even when this
10052 /// process loaded a DIFFERENT config (e.g. an explicit workspace `--config`
10053 /// path). Credentials are user-global: a workspace override may select a
10054 /// different route, but it must never make a global credential appear locked.
10055 ///
10056 /// Bounded, read-only, non-migrating: parses the default config file's raw
10057 /// provider table directly (never runs legacy migration, never opens a
10058 /// write-capable backend). Returns the key only when it reads as a real
10059 /// literal, not a placeholder.
10060 fn user_global_config_api_key(provider: ApiProvider) -> Option<String> {
10061 if provider == ApiProvider::Custom {
10062 // Custom providers are per-config by nature; the probe applies to
10063 // built-in ids whose keys are saved under the user-global file.
10064 return None;
10065 }
10066 let path = codewhale_config::default_config_path().ok()?;
10067 let text = std::fs::read_to_string(path).ok()?;
10068 let doc: codewhale_config::ConfigToml = toml::from_str(&text).ok()?;
10069 let json = serde_json::to_value(&doc).ok()?;
10070 let key = json
10071 .get("providers")?
10072 .get(provider.as_str())?
10073 .get("api_key")?
10074 .as_str()?;
10075 let key = key.trim();
10076 if key.is_empty() || classify_config_api_key_value(key) != ConfigApiKeyValueKind::Literal {
10077 return None;
10078 }
10079 Some(key.to_string())
10080 }
10081
10082 /// Check whether the given provider has any usable API key — via env var,
10083 /// provider/root config. Used by the `/provider` picker to decide whether to
10084 /// prompt for a key inline.
10085 #[must_use]
10086 pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {
10087 let auth_mode = config.auth_mode_for_provider(provider);
10088 if auth_mode_disables_api_key(auth_mode.as_deref()) {
10089 return true;
10090 }
10091
10092 if provider == config.api_provider()
10093 && !provider_uses_oauth_credentials(config, provider)
10094 && explicit_cli_api_key_override().is_some()
10095 {
10096 return true;
10097 }
10098 if provider_config_env_api_key(config, provider).is_some() {
10099 return true;
10100 }
10101
10102 if !config.should_skip_secret_store_for_provider(provider)
10103 && provider
10104 .env_vars()
10105 .iter()
10106 .any(|var| std::env::var(var).is_ok_and(|k| !k.trim().is_empty()))
10107 {
10108 return true;
10109 }
10110
10111 if provider == ApiProvider::Moonshot && provider_uses_oauth_credentials(config, provider) {
10112 return false;
10113 }
10114 if provider == ApiProvider::OpenaiCodex && !config.provider_uses_custom_endpoint(provider) {
10115 // Token env overrides are checked above. An external Codex login is
10116 // considered only after exact read-only consent has been validated.
10117 let path = crate::oauth::auth_file_path();
10118 return config
10119 .external_credential_read_grant(
10120 provider,
10121 codewhale_config::ExternalCredentialSource::CodexCli,
10122 &path,
10123 )
10124 .is_ok_and(|grant| crate::oauth::stored_credentials_present(&grant));
10125 }
10126 if provider == ApiProvider::Xai
10127 && !config.provider_uses_custom_endpoint(provider)
10128 && crate::xai_oauth::credentials_present(config)
10129 {
10130 // xAI supports both API keys and OAuth. A Grok-compatible token file is
10131 // sufficient, but its absence must fall through to the ordinary API-key
10132 // checks below instead of masking a configured key.
10133 return true;
10134 }
10135
10136 if !auth_mode_requires_api_key(auth_mode.as_deref())
10137 && (provider.is_self_hosted()
10138 || (provider == config.api_provider()
10139 && base_url_uses_local_host(&config.deepseek_base_url())))
10140 {
10141 return true;
10142 }
10143
10144 if config.config_credentials_are_bound_to_provider_endpoint(provider)
10145 && config
10146 .provider_config_string_with_runtime_fallback(provider, |entry| entry.api_key.clone())
10147 .is_some_and(|key| {
10148 classify_config_api_key_value(&key) == ConfigApiKeyValueKind::Literal
10149 })
10150 {
10151 return true;
10152 }
10153 // Probe the active provider, plus any provider whose persisted
10154 // `[providers.<name>]` table carries the marker the secret-store save
10155 // path itself writes (an api-key auth mode with no config literal). A
10156 // configured-but-inactive provider must not render as unconfigured just
10157 // because the operator switched providers after saving its key (#5033).
10158 // Shared-slot families (one account, several provider variants — e.g.
10159 // Model Studio Token/Coding Plan × OpenAI/Anthropic dialects) honor the
10160 // marker written by ANY sibling variant, since the save path stores one
10161 // key under the family's canonical slot. The probe stays bounded to
10162 // explicitly configured providers, and the non-active case is strictly
10163 // read-only so rendering the catalog never migrates a legacy store or
10164 // opens a write-capable backend.
10165 if !config.should_skip_secret_store_for_provider(provider) {
10166 if provider == config.api_provider() {
10167 if provider_secret_store_api_key(config, provider).is_some() {
10168 return true;
10169 }
10170 } else if secret_slot_save_marker_on_shared_slot(config, provider)
10171 && provider_secret_store_api_key_with_mode(config, provider, true).is_some()
10172 {
10173 return true;
10174 }
10175 }
10176
10177 if (matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
10178 || (provider == ApiProvider::Custom && config.uses_legacy_literal_custom_route()))
10179 && config.config_credentials_are_bound_to_provider_endpoint(provider)
10180 && config
10181 .api_key
10182 .as_ref()
10183 .is_some_and(|key| classify_config_api_key_value(key) == ConfigApiKeyValueKind::Literal)
10184 {
10185 return true;
10186 }
10187
10188 // Last resort: the user-global config file. A key saved there must not
10189 // disappear just because this process loaded a workspace config.
10190 if user_global_config_api_key(provider).is_some() {
10191 return true;
10192 }
10193
10194 false
10195 }
10196
10197 impl Config {
10198 /// Resolve one coherent Codex OAuth snapshot. The bearer and account id
10199 /// must come from the same secure file handle; opening the external JSON a
10200 /// second time could pair identities across an atomic owner refresh or a
10201 /// hostile path swap.
10202 pub(crate) fn codex_credentials(&self) -> Result<crate::oauth::CodexCredentials> {
10203 if let Some(credentials) = crate::oauth::credentials_from_env() {
10204 return Ok(credentials);
10205 }
10206 anyhow::ensure!(
10207 self.api_provider() == ApiProvider::OpenaiCodex
10208 && !self.provider_uses_custom_endpoint(ApiProvider::OpenaiCodex),
10209 "Codex OAuth credentials are only available on the official OpenAI Codex route"
10210 );
10211 let path = crate::oauth::auth_file_path();
10212 let grant = self.external_credential_read_grant(
10213 ApiProvider::OpenaiCodex,
10214 codewhale_config::ExternalCredentialSource::CodexCli,
10215 &path,
10216 )?;
10217 crate::oauth::get_credentials(&grant)
10218 }
10219
10220 /// ChatGPT account id for the already-selected Codex route. Environment
10221 /// metadata remains independent; the external file is read only when the
10222 /// exact provider/source/path consent tuple is valid.
10223 #[cfg(test)]
10224 pub(crate) fn codex_account_id(&self) -> Option<String> {
10225 self.codex_credentials()
10226 .ok()
10227 .and_then(|credentials| credentials.account_id)
10228 }
10229 }
10230
10231 /// Whether a provider counts as "configured" for the default `/provider`
10232 /// and `/model` manager views (#3830). Shared by both pickers so "what shows
10233 /// up without browsing the full catalog" stays a single definition.
10234 /// Self-hosted providers (Ollama/Sglang/Vllm) report `has_key = true`
10235 /// unconditionally in [`has_api_key_for`] since they don't require auth to
10236 /// route to — that's correct for routing, but wrong for "did the user set
10237 /// this up," so a self-hosted provider only qualifies via an explicit
10238 /// `[providers.<name>]` entry or being active, never via `has_key` alone
10239 /// (otherwise every self-hosted provider type would always show up).
10240 #[must_use]
10241 pub(crate) fn provider_is_configured(
10242 provider: ApiProvider,
10243 is_active: bool,
10244 has_key: bool,
10245 configured: Option<&ProviderConfig>,
10246 is_named_custom_entry: bool,
10247 ) -> bool {
10248 // A *named* custom provider entry (one the user actually added) always
10249 // counts. The unconfigured `Custom` placeholder row that fills the slot
10250 // when no custom provider exists yet is not itself "configured" — it's
10251 // the catalog's invitation to add one.
10252 if is_active || is_named_custom_entry {
10253 return true;
10254 }
10255 if configured.is_some_and(provider_config_is_explicit) {
10256 return true;
10257 }
10258 if provider.is_self_hosted() {
10259 return false;
10260 }
10261 has_key
10262 }
10263
10264 /// Convenience wrapper around [`provider_is_configured`] for callers that
10265 /// just want "is this provider configured given the active one," without
10266 /// the provider picker's multi-row named-custom-provider bookkeeping
10267 /// (`is_named_custom_entry`) — e.g. the `/model` picker (#3830), which only
10268 /// ever resolves the single, currently-selected `Custom` slot via
10269 /// [`Config::provider_config_for`], the same way model/route resolution
10270 /// does everywhere else.
10271 #[must_use]
10272 pub(crate) fn provider_is_configured_for_active(
10273 config: &Config,
10274 provider: ApiProvider,
10275 active: ApiProvider,
10276 ) -> bool {
10277 provider_is_configured(
10278 provider,
10279 provider == active,
10280 has_api_key_for(config, provider),
10281 config.provider_config_for(provider),
10282 false,
10283 )
10284 }
10285
10286 /// True when a `[providers.<name>]` table entry has any field the user would
10287 /// have had to set explicitly — base URL, model, auth, etc. Used by
10288 /// [`provider_is_configured`]: merely existing in the
10289 /// (always-`Some`-once-any-provider-is-configured) `ProvidersConfig` struct
10290 /// isn't enough, since untouched providers still resolve to a
10291 /// `ProviderConfig::default()` there.
10292 fn provider_config_is_explicit(entry: &ProviderConfig) -> bool {
10293 let non_empty = |value: Option<&String>| value.is_some_and(|value| !value.trim().is_empty());
10294
10295 non_empty(entry.api_key.as_ref())
10296 || non_empty(entry.base_url.as_ref())
10297 || non_empty(entry.model.as_ref())
10298 || non_empty(entry.auth_mode.as_ref())
10299 || entry
10300 .auth
10301 .as_ref()
10302 .is_some_and(|auth| auth.validate().is_ok())
10303 || entry.context_window.is_some()
10304 || non_empty(entry.mode.as_ref())
10305 || entry.max_concurrency.is_some()
10306 || entry.http_headers.as_ref().is_some_and(|headers| {
10307 headers
10308 .iter()
10309 .any(|(name, value)| !name.trim().is_empty() && !value.trim().is_empty())
10310 })
10311 || non_empty(entry.path_suffix.as_ref())
10312 || non_empty(entry.reasoning_stream_style.as_ref())
10313 || entry.insecure_skip_tls_verify.is_some()
10314 || non_empty(entry.kind.as_ref())
10315 || non_empty(entry.api_key_env.as_ref())
10316 || entry.external_credentials.is_some()
10317 || non_empty(entry.oauth_credential_generation.as_ref())
10318 }
10319
10320 /// Save an API key to the appropriate place for the given provider.
10321 /// DeepSeek goes through [`save_api_key`]. Other providers write
10322 /// `[providers.<name>] api_key = "..."` to `~/.codewhale/config.toml`.
10323 /// Returns the config file path.
10324 #[cfg(test)]
10325 pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result<PathBuf> {
10326 match save_api_key_for_identity(
10327 &ProviderIdentity {
10328 provider,
10329 key: provider.as_str().to_string(),
10330 exact_id: Some(provider.as_str().to_string()),
10331 },
10332 &Config {
10333 provider: Some(provider.as_str().to_string()),
10334 ..Config::default()
10335 },
10336 api_key,
10337 )? {
10338 SavedCredential::KeyringAndConfigFile { path, .. } | SavedCredential::ConfigFile(path) => {
10339 Ok(path)
10340 }
10341 }
10342 }
10343
10344 /// Save an API key for the given provider identity and return where the
10345 /// credential actually landed ([`SavedCredential`]) so callers can state the
10346 /// true destination — the durable secret store plus credential-free config
10347 /// metadata, or (tests only) the plaintext config file (#5195).
10348 pub(crate) fn save_api_key_for_identity(
10349 identity: &ProviderIdentity,
10350 route_config: &Config,
10351 api_key: &str,
10352 ) -> Result<SavedCredential> {
10353 if identity.provider == ApiProvider::Xai {
10354 return codewhale_config::with_xai_oauth_revocation_transaction(|| {
10355 save_api_key_for_identity_unlocked(identity, route_config, api_key)
10356 });
10357 }
10358 save_api_key_for_identity_unlocked(identity, route_config, api_key)
10359 }
10360
10361 fn save_api_key_for_identity_unlocked(
10362 identity: &ProviderIdentity,
10363 route_config: &Config,
10364 api_key: &str,
10365 ) -> Result<SavedCredential> {
10366 let provider = identity.provider;
10367 if provider == ApiProvider::OpenaiCodex {
10368 anyhow::bail!(
10369 "OpenAI Codex uses OAuth. Run `codex login`, then grant exact read-only access with `codewhale auth external-consent --provider openai-codex --mode read-only`, or set OPENAI_CODEX_ACCESS_TOKEN for this process; Codewhale does not store an API key for this provider."
10370 );
10371 }
10372 let is_legacy_literal_custom = provider == ApiProvider::Custom
10373 && identity.key.trim() == ApiProvider::Custom.as_str()
10374 && identity.persisted_id().is_none();
10375 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
10376 return save_api_key(api_key);
10377 }
10378 if is_legacy_literal_custom {
10379 return save_root_api_key_for_secret_slot(api_key, "custom", false);
10380 }
10381
10382 let api_key = api_key.trim();
10383 anyhow::ensure!(!api_key.is_empty(), "Refusing to save an empty API key.");
10384
10385 let config_path =
10386 credential_config_path().context("Failed to resolve config path for provider API key.")?;
10387 ensure_parent_dir(&config_path)?;
10388
10389 let key_inside = if provider == ApiProvider::Custom {
10390 let key = identity.key.trim();
10391 anyhow::ensure!(!key.is_empty(), "custom provider id cannot be empty");
10392 key
10393 } else {
10394 provider_config_key(provider).context("provider api key table")?
10395 };
10396 // A legacy, manually-selected Kimi CLI import implicitly routed Moonshot
10397 // traffic to Kimi Code. Once the user replaces that import with the
10398 // supported API-key route, persist the endpoint before changing auth_mode
10399 // so the key is not silently sent to the ordinary Moonshot endpoint.
10400 // Respect an explicit user-owned endpoint.
10401 let pin_kimi_code_base_url = provider == ApiProvider::Moonshot
10402 && route_config
10403 .provider_config_for(provider)
10404 .is_some_and(|entry| {
10405 provider_config_uses_kimi_imported_token(entry)
10406 && entry
10407 .base_url
10408 .as_deref()
10409 .is_none_or(|base_url| base_url.trim().is_empty())
10410 });
10411
10412 if !route_config.should_skip_secret_store_for_provider(provider)
10413 && let Some(secrets) = credential_secret_store()
10414 {
10415 let secret_slot = provider_secret_store_slot(provider);
10416 let prior_secret = secrets.get(secret_slot);
10417 match prior_secret.as_ref() {
10418 Ok(prior) => match secrets.set(secret_slot, api_key) {
10419 Ok(()) => {
10420 let config_result =
10421 crate::config_persistence::mutate_config_document(&config_path, |doc| {
10422 if pin_kimi_code_base_url {
10423 crate::config_persistence::set_document_value(
10424 doc,
10425 &["providers", key_inside, "base_url"],
10426 DEFAULT_KIMI_CODE_BASE_URL,
10427 )?;
10428 }
10429 crate::config_persistence::set_document_value(
10430 doc,
10431 &["providers", key_inside, "auth_mode"],
10432 "api_key",
10433 )?;
10434 crate::config_persistence::unset_document_value(
10435 doc,
10436 &["providers", key_inside, "external_credentials"],
10437 )?;
10438 if provider == ApiProvider::Xai {
10439 crate::config_persistence::unset_document_value(
10440 doc,
10441 &["providers", key_inside, "oauth_credential_generation"],
10442 )?;
10443 }
10444 crate::config_persistence::unset_document_value(
10445 doc,
10446 &["providers", key_inside, "api_key"],
10447 )?;
10448 Ok(())
10449 })
10450 .with_context(|| {
10451 format!("Failed to write config to {}", config_path.display())
10452 });
10453 if let Err(error) = config_result {
10454 let current = secrets.get(secret_slot).map_err(|rollback| {
10455 anyhow::anyhow!(
10456 "{error}; additionally could not verify secret-store rollback for {secret_slot}: {rollback}"
10457 )
10458 })?;
10459 if current.as_deref() == Some(api_key) {
10460 match prior {
10461 Some(previous) => secrets.set(secret_slot, previous),
10462 None => secrets.delete(secret_slot),
10463 }
10464 .map_err(|rollback| {
10465 anyhow::anyhow!(
10466 "{error}; additionally failed to restore prior secret-store state for {secret_slot}: {rollback}"
10467 )
10468 })?;
10469 }
10470 return Err(error);
10471 }
10472 codewhale_config::scrub_plaintext_api_keys_from_config_backup(&config_path)?;
10473 let backend = secrets.backend_name().to_string();
10474 log_sensitive_event(
10475 "credential.save",
10476 json!({
10477 "backend": backend.clone(),
10478 "provider": identity.key,
10479 "config_path": config_path.display().to_string(),
10480 "plaintext_config_fallback": false,
10481 }),
10482 );
10483 return Ok(SavedCredential::KeyringAndConfigFile {
10484 backend,
10485 path: config_path,
10486 });
10487 }
10488 Err(err) => {
10489 return Err(plaintext_credential_fallback_refused(
10490 "write",
10491 &config_path,
10492 &err,
10493 ));
10494 }
10495 },
10496 Err(error) => {
10497 return Err(plaintext_credential_fallback_refused(
10498 "snapshot",
10499 &config_path,
10500 &error,
10501 ));
10502 }
10503 }
10504 }
10505
10506 // Edit the `[providers.<name>]` table in place so unrelated sections,
10507 // comments, and formatting survive the write.
10508 crate::config_persistence::mutate_config_document(&config_path, |doc| {
10509 if pin_kimi_code_base_url {
10510 crate::config_persistence::set_document_value(
10511 doc,
10512 &["providers", key_inside, "base_url"],
10513 DEFAULT_KIMI_CODE_BASE_URL,
10514 )?;
10515 }
10516 crate::config_persistence::set_document_value(
10517 doc,
10518 &["providers", key_inside, "auth_mode"],
10519 "api_key",
10520 )?;
10521 crate::config_persistence::unset_document_value(
10522 doc,
10523 &["providers", key_inside, "external_credentials"],
10524 )?;
10525 if provider == ApiProvider::Xai {
10526 crate::config_persistence::unset_document_value(
10527 doc,
10528 &["providers", key_inside, "oauth_credential_generation"],
10529 )?;
10530 }
10531 crate::config_persistence::set_document_value(
10532 doc,
10533 &["providers", key_inside, "api_key"],
10534 api_key,
10535 )
10536 })
10537 .with_context(|| format!("Failed to write config to {}", config_path.display()))?;
10538 log_sensitive_event(
10539 "credential.save",
10540 json!({
10541 "backend": "config_file",
10542 "provider": identity.key,
10543 "config_path": config_path.display().to_string(),
10544 }),
10545 );
10546 codewhale_config::scrub_plaintext_api_keys_from_config_backup(&config_path)?;
10547
10548 Ok(SavedCredential::ConfigFile(config_path))
10549 }
10550
10551 /// Persist a default model for `provider` via the comment-preserving config
10552 /// path used by guided provider setup (#3875). DeepSeek writes root
10553 /// `default_text_model`; other hosted providers write `[providers.<name>] model`.
10554 pub(crate) fn save_provider_model_for_identity(
10555 identity: &ProviderIdentity,
10556 _route_config: &Config,
10557 model: &str,
10558 ) -> Result<PathBuf> {
10559 let provider = identity.provider;
10560 let model = model.trim();
10561 anyhow::ensure!(!model.is_empty(), "model cannot be empty");
10562
10563 let config_path =
10564 try_default_config_path().context("Failed to resolve config path for provider model.")?;
10565 ensure_parent_dir(&config_path)?;
10566
10567 let is_legacy_literal_custom = provider == ApiProvider::Custom
10568 && identity.key.trim() == ApiProvider::Custom.as_str()
10569 && identity.persisted_id().is_none();
10570 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
10571 || is_legacy_literal_custom
10572 {
10573 crate::config_persistence::mutate_config_document(&config_path, |doc| {
10574 crate::config_persistence::set_document_value(doc, &["default_text_model"], model)
10575 })
10576 .with_context(|| format!("Failed to write config to {}", config_path.display()))?;
10577 return Ok(config_path);
10578 }
10579
10580 let key_inside = if provider == ApiProvider::Custom {
10581 let key = identity.key.trim();
10582 anyhow::ensure!(!key.is_empty(), "custom provider id cannot be empty");
10583 key
10584 } else {
10585 provider_config_key(provider).context("provider model table")?
10586 };
10587 crate::config_persistence::mutate_config_document(&config_path, |doc| {
10588 crate::config_persistence::set_document_value(
10589 doc,
10590 &["providers", key_inside, "model"],
10591 model,
10592 )
10593 })
10594 .with_context(|| format!("Failed to write config to {}", config_path.display()))?;
10595 Ok(config_path)
10596 }
10597
10598 /// Persist a guided-setup endpoint choice into the provider's own
10599 /// `[providers.<name>] base_url` (#4526).
10600 ///
10601 /// Deliberately narrow: it never touches the root `base_url`, another
10602 /// provider's table, or any other key, so a billing-route choice cannot
10603 /// repoint an unrelated route.
10604 pub(crate) fn save_provider_base_url_for_identity(
10605 identity: &ProviderIdentity,
10606 _route_config: &Config,
10607 base_url: &str,
10608 ) -> Result<PathBuf> {
10609 let base_url = base_url.trim();
10610 anyhow::ensure!(!base_url.is_empty(), "base URL cannot be empty");
10611 let config_path = try_default_config_path()
10612 .context("Failed to resolve config path for provider base URL.")?;
10613 ensure_parent_dir(&config_path)?;
10614 let key_inside = if identity.provider == ApiProvider::Custom {
10615 let key = identity.key.trim();
10616 anyhow::ensure!(!key.is_empty(), "custom provider id cannot be empty");
10617 key
10618 } else {
10619 provider_config_key(identity.provider).context("provider base URL table")?
10620 };
10621 crate::config_persistence::mutate_config_document(&config_path, |doc| {
10622 crate::config_persistence::set_document_value(
10623 doc,
10624 &["providers", key_inside, "base_url"],
10625 base_url,
10626 )
10627 })
10628 .with_context(|| format!("Failed to write config to {}", config_path.display()))?;
10629 Ok(config_path)
10630 }
10631
10632 /// Persist a guided-setup context-window choice without replacing the user's
10633 /// surrounding TOML comments or formatting.
10634 pub(crate) fn save_provider_context_window_for_identity(
10635 identity: &ProviderIdentity,
10636 _route_config: &Config,
10637 context_window: u32,
10638 ) -> Result<PathBuf> {
10639 anyhow::ensure!(context_window > 0, "context window must be greater than 0");
10640 let config_path = try_default_config_path()
10641 .context("Failed to resolve config path for provider context window.")?;
10642 ensure_parent_dir(&config_path)?;
10643 let key_inside = if identity.provider == ApiProvider::Custom {
10644 let key = identity.key.trim();
10645 anyhow::ensure!(!key.is_empty(), "custom provider id cannot be empty");
10646 key
10647 } else {
10648 provider_config_key(identity.provider).context("provider context window table")?
10649 };
10650 crate::config_persistence::mutate_config_document(&config_path, |doc| {
10651 crate::config_persistence::set_document_value(
10652 doc,
10653 &["providers", key_inside, "context_window"],
10654 i64::from(context_window),
10655 )
10656 })
10657 .with_context(|| format!("Failed to write config to {}", config_path.display()))?;
10658 Ok(config_path)
10659 }
10660
10661 /// Persist an explicitly confirmed read-only external credential grant and
10662 /// update the live mirror only after the comment-preserving disk mutation
10663 /// succeeds. This function never inspects the external path.
10664 pub(crate) fn persist_external_credential_consent_for_at(
10665 config_path: Option<&Path>,
10666 live_config: &mut Config,
10667 provider: ApiProvider,
10668 consent_provider: codewhale_config::ProviderKind,
10669 source: codewhale_config::ExternalCredentialSource,
10670 path: &Path,
10671 ) -> Result<PathBuf> {
10672 let expected = match provider {
10673 ApiProvider::OpenaiCodex => (
10674 codewhale_config::ProviderKind::OpenaiCodex,
10675 codewhale_config::ExternalCredentialSource::CodexCli,
10676 ),
10677 ApiProvider::Xai => (
10678 codewhale_config::ProviderKind::Xai,
10679 codewhale_config::ExternalCredentialSource::GrokCli,
10680 ),
10681 _ => anyhow::bail!(
10682 "{} has no supported external credential owner",
10683 provider.as_str()
10684 ),
10685 };
10686 anyhow::ensure!(
10687 (consent_provider, source) == expected,
10688 "external credential owner does not match provider {}",
10689 provider.as_str()
10690 );
10691 let path = codewhale_config::resolve_external_credential_path(path)?;
10692 let path_value = path.to_str().context(
10693 "external credential path cannot be persisted losslessly because it is not valid UTF-8",
10694 )?;
10695 let config_path = match config_path {
10696 Some(path) => path.to_path_buf(),
10697 None => credential_config_path()
10698 .context("Failed to resolve config path for external credential consent.")?,
10699 };
10700 ensure_parent_dir(&config_path)?;
10701 let key_inside = provider_config_key(provider).context("external credential provider key")?;
10702 crate::config_persistence::mutate_config_document(&config_path, |doc| {
10703 crate::config_persistence::set_document_value(
10704 doc,
10705 &["providers", key_inside, "auth_mode"],
10706 "oauth",
10707 )?;
10708 let prefix = &["providers", key_inside, "external_credentials"];
10709 crate::config_persistence::set_document_value(
10710 doc,
10711 &[prefix[0], prefix[1], prefix[2], "access"],
10712 "read_only",
10713 )?;
10714 crate::config_persistence::set_document_value(
10715 doc,
10716 &[prefix[0], prefix[1], prefix[2], "provider"],
10717 consent_provider.as_str(),
10718 )?;
10719 crate::config_persistence::set_document_value(
10720 doc,
10721 &[prefix[0], prefix[1], prefix[2], "source"],
10722 source.as_str(),
10723 )?;
10724 crate::config_persistence::set_document_value(
10725 doc,
10726 &[prefix[0], prefix[1], prefix[2], "path"],
10727 path_value,
10728 )?;
10729 crate::config_persistence::set_document_value(
10730 doc,
10731 &[prefix[0], prefix[1], prefix[2], "consent_version"],
10732 i64::from(codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION),
10733 )
10734 })
10735 .with_context(|| {
10736 format!(
10737 "Failed to write config to {}",
10738 codewhale_config::quote_os_path(&config_path)
10739 )
10740 })?;
10741 live_config
10742 .providers
10743 .get_or_insert_with(ProvidersConfig::default);
10744 let entry = live_config.provider_config_for_mut(provider);
10745 entry.auth_mode = Some("oauth".to_string());
10746 entry.external_credentials = Some(codewhale_config::ExternalCredentialConsentToml::read_only(
10747 consent_provider,
10748 source,
10749 path,
10750 ));
10751 Ok(config_path)
10752 }
10753
10754 /// Revoke one provider's external-file access without inspecting that file.
10755 pub(crate) fn revoke_external_credential_consent_for_at(
10756 config_path: Option<&Path>,
10757 live_config: &mut Config,
10758 provider: ApiProvider,
10759 ) -> Result<PathBuf> {
10760 anyhow::ensure!(
10761 matches!(provider, ApiProvider::OpenaiCodex | ApiProvider::Xai),
10762 "{} has no supported external credential owner",
10763 provider.as_str()
10764 );
10765 let config_path = match config_path {
10766 Some(path) => path.to_path_buf(),
10767 None => credential_config_path()
10768 .context("Failed to resolve config path for external credential consent.")?,
10769 };
10770 ensure_parent_dir(&config_path)?;
10771 let key_inside = provider_config_key(provider).context("external credential provider key")?;
10772 crate::config_persistence::mutate_config_document(&config_path, |doc| {
10773 crate::config_persistence::unset_document_value(
10774 doc,
10775 &["providers", key_inside, "external_credentials"],
10776 )?;
10777 Ok(())
10778 })
10779 .with_context(|| {
10780 format!(
10781 "Failed to write config to {}",
10782 codewhale_config::quote_os_path(&config_path)
10783 )
10784 })?;
10785 live_config
10786 .provider_config_for_mut(provider)
10787 .external_credentials = None;
10788 Ok(config_path)
10789 }
10790
10791 pub(crate) fn provider_config_key(provider: ApiProvider) -> Result<&'static str> {
10792 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
10793 anyhow::bail!("DeepSeek stores auth at the root config level");
10794 }
10795 provider
10796 .metadata()
10797 .map(|metadata| metadata.provider_config_key())
10798 .context("provider config key")
10799 }
10800
10801 fn provider_config_table_name(provider: ApiProvider) -> Result<String> {
10802 Ok(format!("providers.{}", provider_config_key(provider)?))
10803 }
10804
10805 fn provider_env_api_key(provider: ApiProvider) -> Option<String> {
10806 if provider == ApiProvider::Huggingface {
10807 return std::env::var("HUGGINGFACE_API_KEY")
10808 .ok()
10809 .filter(|value| !value.trim().is_empty())
10810 .or_else(|| {
10811 std::env::var("HF_TOKEN")
10812 .ok()
10813 .filter(|value| !value.trim().is_empty())
10814 });
10815 }
10816
10817 provider.env_vars().iter().find_map(|var| {
10818 std::env::var(var)
10819 .ok()
10820 .filter(|value| !value.trim().is_empty())
10821 })
10822 }
10823
10824 /// Canonical durable-credential slot shared with the CLI dispatcher.
10825 fn provider_secret_store_slot(provider: ApiProvider) -> &'static str {
10826 match provider {
10827 // TUI compatibility variants share the canonical CLI provider slots.
10828 ApiProvider::DeepseekCN => "deepseek",
10829 // Shared-account families (SiliconFlow China, the four Model Studio
10830 // variants) collapse onto one slot via ProviderKind::secret_store_slot.
10831 _ => provider
10832 .kind()
10833 .map_or_else(|| provider.as_str(), |kind| kind.secret_store_slot()),
10834 }
10835 }
10836
10837 /// Whether the secret-store save marker (`auth_mode = "api_key"` with no
10838 /// config literal, written by the save path) exists for `provider` or for any
10839 /// provider sharing its durable credential slot.
10840 ///
10841 /// One Model Studio account authenticates all four plan/dialect variants, so
10842 /// saving a key on `modelstudio-token-plan` marks only that variant's config
10843 /// table; the sibling variants must still treat the family slot as saved.
10844 fn secret_slot_save_marker_on_shared_slot(config: &Config, provider: ApiProvider) -> bool {
10845 let slot = provider_secret_store_slot(provider);
10846 ApiProvider::all()
10847 .iter()
10848 .copied()
10849 .chain(std::iter::once(ApiProvider::DeepseekCN))
10850 .filter(|candidate| provider_secret_store_slot(*candidate) == slot)
10851 .any(|candidate| {
10852 config
10853 .provider_config_for(candidate)
10854 .is_some_and(|entry| auth_mode_requires_api_key(entry.auth_mode.as_deref()))
10855 })
10856 }
10857
10858 /// Read only the durable secret-store layer (no environment fallback).
10859 ///
10860 /// This keeps `config -> secret store -> env` precedence explicit in the TUI
10861 /// and lets status surfaces distinguish a saved key from an ambient export.
10862 pub(crate) fn provider_secret_store_api_key(
10863 config: &Config,
10864 provider: ApiProvider,
10865 ) -> Option<String> {
10866 provider_secret_store_api_key_with_mode(config, provider, false)
10867 }
10868
10869 fn provider_secret_store_api_key_with_mode(
10870 config: &Config,
10871 provider: ApiProvider,
10872 read_only: bool,
10873 ) -> Option<String> {
10874 // Keep the named-custom exclusion at the credential boundary itself.
10875 // Callers also use this policy to avoid unnecessary keyring probes, but a
10876 // future caller must not be able to read the legacy `custom` slot for an
10877 // arbitrary `[providers.<name>]` endpoint by omitting that outer guard.
10878 if config.should_skip_secret_store_for_provider(provider) {
10879 return None;
10880 }
10881
10882 // Unit tests must never inspect the developer's real credential store.
10883 // Secret-store regressions opt in with an isolated CODEWHALE_HOME and an
10884 // explicit backend, matching the secrets crate's own test discipline.
10885 #[cfg(test)]
10886 if !codewhale_paths::codewhale_home_is_explicit()
10887 || std::env::var_os("CODEWHALE_SECRET_BACKEND").is_none()
10888 {
10889 return None;
10890 }
10891
10892 let secrets = if read_only {
10893 codewhale_secrets::Secrets::auto_detect_read_only()
10894 } else {
10895 codewhale_secrets::Secrets::auto_detect()
10896 };
10897 secrets
10898 .get(provider_secret_store_slot(provider))
10899 .ok()
10900 .flatten()
10901 .filter(|value| !value.trim().is_empty())
10902 }
10903
10904 /// The shadowing warning for a config-file `api_key` that wins over a live
10905 /// secret-store credential, if both exist (#5194).
10906 ///
10907 /// The config file intentionally outranks the secret store in the read
10908 /// chain, but a shadowed slot is invisible: the user rotates the key with
10909 /// `codewhale auth set` and nothing changes, because the stale plaintext
10910 /// copy still wins. Mirror the fleet-roster shadowing rule (#5098):
10911 /// precedence is normal, but it must be VISIBLE. The message names both
10912 /// sources, which one won, and the command that resolves the shadow.
10913 /// Split from [`warn_on_config_api_key_shadowing`] so the decision is
10914 /// testable without capturing tracing output.
10915 fn config_api_key_shadow_warning(
10916 config: &Config,
10917 provider: ApiProvider,
10918 config_source: &str,
10919 ) -> Option<String> {
10920 if config.should_skip_secret_store_for_provider(provider) {
10921 return None;
10922 }
10923 provider_secret_store_api_key_with_mode(config, provider, true).map(|_| {
10924 let slot = provider_secret_store_slot(provider);
10925 let id = provider.as_str();
10926 format!(
10927 "both {config_source} in the config file and secret-store slot \"{slot}\" \
10928 hold a credential for provider {id}; the config-file key won. Run \
10929 `codewhale auth set --provider {id}` to move the key into the secret store \
10930 and strip the plaintext copy, or remove the config-file api_key."
10931 )
10932 })
10933 }
10934
10935 /// Emit the #5194 shadowing warning at most once per provider slot per
10936 /// process: credential resolution runs on every request, and a repeating
10937 /// warning is noise, not signal.
10938 fn warn_on_config_api_key_shadowing(config: &Config, provider: ApiProvider, config_source: &str) {
10939 let Some(message) = config_api_key_shadow_warning(config, provider, config_source) else {
10940 return;
10941 };
10942 static WARNED_SLOTS: std::sync::OnceLock<
10943 std::sync::Mutex<std::collections::HashSet<&'static str>>,
10944 > = std::sync::OnceLock::new();
10945 let mut warned = WARNED_SLOTS
10946 .get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
10947 .lock()
10948 .unwrap_or_else(|poisoned| poisoned.into_inner());
10949 if !warned.insert(provider_secret_store_slot(provider)) {
10950 return;
10951 }
10952 drop(warned);
10953 tracing::warn!("{message}");
10954 }
10955
10956 /// The model this launch was explicitly asked for, if any.
10957 ///
10958 /// The `codewhale` dispatcher forwards `--model` to this binary as
10959 /// `CODEWHALE_MODEL` (with the legacy `DEEPSEEK_MODEL` alias), so an explicit
10960 /// flag and an explicit shell export are the same signal here: *the user named
10961 /// a model for this run*. That has to outrank the remembered per-provider
10962 /// selection in `settings.toml`, which is a convenience memory of the last
10963 /// `/model` pick — never a reason to run something the user did not ask for
10964 /// (v0.9.1 kimi-k3 dogfood report).
10965 pub(crate) fn explicit_launch_model_override() -> Option<String> {
10966 codewhale_env_var("CODEWHALE_MODEL", "DEEPSEEK_MODEL")
10967 .ok()
10968 .map(|value| value.trim().to_string())
10969 .filter(|value| !value.is_empty())
10970 }
10971
10972 /// The provider this launch was explicitly asked for, if any.
10973 ///
10974 /// An environment/CLI override is a one-run instruction and must outrank the
10975 /// user's saved startup default. A provider merely named in config.toml is a
10976 /// seed instead: the user can deliberately replace that seed from `/model`.
10977 pub(crate) fn explicit_launch_provider_override() -> Option<String> {
10978 codewhale_env_var("CODEWHALE_PROVIDER", "DEEPSEEK_PROVIDER")
10979 .ok()
10980 .map(|value| value.trim().to_string())
10981 .filter(|value| !value.is_empty())
10982 }
10983
10984 pub(crate) fn explicit_cli_api_key_override() -> Option<String> {
10985 (std::env::var("DEEPSEEK_API_KEY_SOURCE").as_deref() == Ok("cli"))
10986 .then(|| {
10987 std::env::var("CODEWHALE_CLI_API_KEY")
10988 .ok()
10989 .filter(|value| !value.trim().is_empty())
10990 })
10991 .flatten()
10992 }
10993
10994 fn missing_provider_api_key_message(provider: ApiProvider) -> Result<String> {
10995 let credential_hint = provider
10996 .credential_url()
10997 .map(|url| format!(" Get a key: {url}."))
10998 .unwrap_or_default();
10999 Ok(format!(
11000 "{} API key not found.{} Run 'codewhale auth set --provider {}', set {}, or add [{}] api_key in ~/.codewhale/config.toml.",
11001 provider.display_name(),
11002 credential_hint,
11003 provider.as_str(),
11004 provider.env_vars_label(),
11005 provider_config_table_name(provider)?
11006 ))
11007 }
11008
11009 /// Clear every saved API key from config-file storage AND the durable
11010 /// secret store.
11011 ///
11012 /// The full-wipe logout path (`codewhale-tui --logout`, `auth logout`)
11013 /// calls this to remove credentials so the next request can't
11014 /// silently use a stale config key (#343). The function removes the legacy
11015 /// root `api_key` entry *and* every `api_key` entry nested in a
11016 /// `[providers.<name>]` table, leaving keys like `api_key_env`, comments,
11017 /// and formatting untouched, then deletes every provider's secret-store
11018 /// slot — symmetric with CLI logout (#5159) — so a stored credential cannot
11019 /// survive logout and reappear through the read chain (#5196). The TUI
11020 /// `/logout` command stays single-provider and goes through
11021 /// [`clear_active_provider_api_key`] instead.
11022 ///
11023 /// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally
11024 /// **not** unset — they are managed by the user's shell and outside the
11025 /// CLI's purview. `Config::deepseek_api_key`'s explicit-override path
11026 /// (Path 0) ensures a freshly-entered key still wins over a stale env
11027 /// var that lingers from a previous session.
11028 pub fn clear_api_key() -> Result<()> {
11029 codewhale_config::with_xai_oauth_revocation_transaction(clear_api_key_unlocked)
11030 }
11031
11032 fn clear_api_key_unlocked() -> Result<()> {
11033 // Strip api_key entries from config.toml, including provider-scoped
11034 // nested entries. Clearing a config file must not trigger platform
11035 // credential prompts. Clears target the same user-global document that
11036 // credential saves write, so logout removes what login stored (#5045).
11037 let config_path = credential_config_path()
11038 .context("Failed to resolve config path while clearing API keys.")?;
11039
11040 if config_path.exists() {
11041 crate::config_persistence::mutate_config_document(&config_path, |doc| {
11042 crate::config_persistence::remove_document_key_recursive(doc.as_table_mut(), "api_key");
11043 crate::config_persistence::unset_document_value(
11044 doc,
11045 &["providers", "xai", "oauth_credential_generation"],
11046 )?;
11047 crate::config_persistence::unset_document_value(
11048 doc,
11049 &["providers", "xai", "auth_mode"],
11050 )?;
11051 crate::config_persistence::unset_document_value(
11052 doc,
11053 &["providers", "xai", "external_credentials"],
11054 )?;
11055 Ok(())
11056 })
11057 .with_context(|| format!("Failed to write config to {}", config_path.display()))?;
11058 log_sensitive_event(
11059 "credential.clear",
11060 json!({
11061 "backend": "config_file",
11062 "config_path": config_path.display().to_string(),
11063 "scope": "root_and_provider_keys",
11064 }),
11065 );
11066 }
11067
11068 // The config scrub alone leaves the durable secret-store credential
11069 // alive, and the read chain prefers the secret store over the file, so a
11070 // "cleared" key silently came back on the next launch (#5196). Delete
11071 // every provider slot too, symmetric with CLI logout (#5159). This runs
11072 // even when the config file is absent: the slot survives independently
11073 // of the file.
11074 if let Some(secrets) = credential_secret_store() {
11075 let failures = clear_all_provider_api_keys_from_secret_store(&secrets);
11076 if !failures.is_empty() {
11077 anyhow::bail!(
11078 "failed to delete stored credentials for: {}",
11079 failures.join(", ")
11080 );
11081 }
11082 }
11083
11084 Ok(())
11085 }
11086
11087 /// Delete the credential slot of every provider that has one stored.
11088 ///
11089 /// Mirrors the CLI logout helper (#5159): each slot is probed first so
11090 /// backends that error on deleting a missing item stay quiet, slots shared
11091 /// by several providers (e.g. the historical `siliconflow` slot) are deleted
11092 /// once, and every deletion failure is returned as a human-readable entry so
11093 /// the caller can fail loudly instead of claiming a clean logout while
11094 /// credentials linger in the store (#5196).
11095 fn clear_all_provider_api_keys_from_secret_store(
11096 secrets: &codewhale_secrets::Secrets,
11097 ) -> Vec<String> {
11098 let mut failures = Vec::new();
11099 let mut cleared_slots = std::collections::HashSet::new();
11100 for provider in ApiProvider::all() {
11101 let slot = provider_secret_store_slot(*provider);
11102 if !cleared_slots.insert(slot) {
11103 continue;
11104 }
11105 let has_value = secrets
11106 .get(slot)
11107 .ok()
11108 .flatten()
11109 .is_some_and(|value| !value.trim().is_empty());
11110 if !has_value {
11111 continue;
11112 }
11113 if let Err(error) = secrets.delete(slot) {
11114 failures.push(format!("{slot}: {error}"));
11115 }
11116 }
11117 failures
11118 }
11119
11120 /// Clear only the active provider's API key from the config file and delete
11121 /// that provider's durable secret-store slot (#5196).
11122 /// Unlike `clear_api_key()` which strips ALL api_key entries, this
11123 /// removes only the key for the specified provider section (plus the
11124 /// legacy root `api_key` when the provider is DeepSeek).
11125 pub fn clear_active_provider_api_key(provider: &str) -> Result<()> {
11126 if provider == ApiProvider::Xai.as_str() {
11127 return codewhale_config::with_xai_oauth_revocation_transaction(|| {
11128 clear_active_provider_api_key_unlocked(provider)
11129 });
11130 }
11131 clear_active_provider_api_key_unlocked(provider)
11132 }
11133
11134 fn clear_active_provider_api_key_unlocked(provider: &str) -> Result<()> {
11135 let config_path = credential_config_path()
11136 .context("Failed to resolve config path while clearing API keys.")?;
11137
11138 if config_path.exists() {
11139 // `custom` is both the legacy root-shaped route id and a valid exact
11140 // `[providers.custom]` table key. Inspect the persisted shape before the
11141 // mutation so logout clears exactly one credential scope.
11142 let persisted = fs::read_to_string(&config_path)
11143 .with_context(|| format!("Failed to read config from {}", config_path.display()))?;
11144 let persisted_config: Config = toml::from_str(&persisted).map_err(|_| {
11145 anyhow::anyhow!(
11146 "Failed to parse config from {}; file contents were omitted",
11147 codewhale_config::quote_os_path(&config_path)
11148 )
11149 })?;
11150 let exact_literal_custom_table = provider == ApiProvider::Custom.as_str()
11151 && persisted_config
11152 .providers
11153 .as_ref()
11154 .and_then(|providers| providers.custom_provider_config(provider))
11155 .is_some();
11156
11157 crate::config_persistence::mutate_config_document(&config_path, |doc| {
11158 // The root-level api_key is shared by the legacy DeepSeek and released
11159 // literal-custom config shapes. Exact named custom ids remain scoped
11160 // to their own table.
11161 if matches!(
11162 provider,
11163 value if value == ApiProvider::Deepseek.as_str()
11164 || value == ApiProvider::DeepseekCN.as_str()
11165 ) || (provider == ApiProvider::Custom.as_str() && !exact_literal_custom_table)
11166 {
11167 crate::config_persistence::unset_document_value(doc, &["api_key"])?;
11168 }
11169 if provider != ApiProvider::Custom.as_str() || exact_literal_custom_table {
11170 crate::config_persistence::unset_document_value(
11171 doc,
11172 &["providers", provider, "api_key"],
11173 )?;
11174 }
11175 if provider == ApiProvider::Xai.as_str() {
11176 crate::config_persistence::unset_document_value(
11177 doc,
11178 &["providers", "xai", "oauth_credential_generation"],
11179 )?;
11180 crate::config_persistence::unset_document_value(
11181 doc,
11182 &["providers", "xai", "auth_mode"],
11183 )?;
11184 crate::config_persistence::unset_document_value(
11185 doc,
11186 &["providers", "xai", "external_credentials"],
11187 )?;
11188 }
11189 Ok(())
11190 })
11191 .with_context(|| format!("Failed to write config to {}", config_path.display()))?;
11192 log_sensitive_event(
11193 "credential.clear",
11194 json!({
11195 "backend": "config_file",
11196 "config_path": config_path.display().to_string(),
11197 "scope": provider,
11198 }),
11199 );
11200 }
11201
11202 // The durable secret-store slot survives a config-file scrub and the
11203 // read chain prefers it, so the cleared key would silently come back
11204 // (#5196). Delete the provider's slot too — even when the config file
11205 // itself is absent. Exact named custom providers have no secret-store
11206 // slot, so an unmatched provider string skips this step.
11207 if let Some(secrets) = credential_secret_store()
11208 && let Some(slot) = ApiProvider::all()
11209 .iter()
11210 .find(|candidate| candidate.as_str() == provider)
11211 .map(|candidate| provider_secret_store_slot(*candidate))
11212 {
11213 let has_value = secrets
11214 .get(slot)
11215 .ok()
11216 .flatten()
11217 .is_some_and(|value| !value.trim().is_empty());
11218 if has_value {
11219 secrets
11220 .delete(slot)
11221 .with_context(|| format!("failed to delete stored credential for {slot}"))?;
11222 }
11223 }
11224
11225 Ok(())
11226 }
11227
11228 #[cfg(test)]
11229 mod tests;
11230
11231 /// #5045 regression coverage: credential writes must never land in a
11232 /// workspace-scoped `.codewhale/config.toml`.
11233 #[cfg(test)]
11234 mod credential_scope_tests {
11235 use super::*;
11236 use crate::test_support::{EnvVarGuard, lock_test_env};
11237
11238 /// With the ambient config path pointing at a workspace-local
11239 /// `.codewhale/config.toml` (a checkout the user works in), saving an
11240 /// API key must write the user-global config under the isolated
11241 /// `CODEWHALE_HOME`, never the project file. The `.git` marker stands in
11242 /// for cwd-inside-the-workspace: chdir is process-global and unsafe in a
11243 /// parallel test binary, and production classifies on either signal.
11244 #[test]
11245 fn api_key_save_rescopes_workspace_config_to_user_global() -> Result<()> {
11246 let _lock = lock_test_env();
11247 let temp = tempfile::tempdir()?;
11248 let workspace = temp.path().join("repo");
11249 fs::create_dir_all(workspace.join(".git"))?;
11250 let project_dir = workspace.join(".codewhale");
11251 fs::create_dir_all(&project_dir)?;
11252 let project_config = project_dir.join("config.toml");
11253 fs::write(&project_config, "approval_policy = \"never\"\n")?;
11254
11255 let user_home = temp.path().join("user-global-home");
11256 let _home = EnvVarGuard::set("CODEWHALE_HOME", user_home.as_os_str());
11257 let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", project_config.as_os_str());
11258 let _legacy_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
11259 // No explicit secret backend: under cfg(test) the save takes the
11260 // plaintext config-file path, which is exactly the surface this
11261 // regression guards.
11262 let _backend = EnvVarGuard::remove("CODEWHALE_SECRET_BACKEND");
11263 let _legacy_backend = EnvVarGuard::remove("DEEPSEEK_SECRET_BACKEND");
11264
11265 let saved = save_api_key("workspace-rescope-test-key")?;
11266
11267 let global_config = user_home.join("config.toml");
11268 // Compare canonicalized paths: the resolved config path runs through
11269 // `normalize_config_file_path`, which canonicalizes the parent, so on
11270 // macOS the lexical `/var/folders/…` tempdir and its canonical
11271 // `/private/var/folders/…` form are the same file. A lexical compare
11272 // both false-fails and false-passes on that symlink.
11273 let saved_path = match saved {
11274 SavedCredential::ConfigFile(path) => path,
11275 other => panic!("expected a config-file save, got {}", other.describe()),
11276 };
11277 assert_eq!(
11278 canonicalize_or_keep(&saved_path),
11279 canonicalize_or_keep(&global_config),
11280 "credential save must surface the user-global destination"
11281 );
11282 let global = fs::read_to_string(&global_config)?;
11283 assert!(
11284 global.contains("workspace-rescope-test-key"),
11285 "user-global config must hold the saved key: {global}"
11286 );
11287 let project = fs::read_to_string(&project_config)?;
11288 assert!(
11289 !project.contains("workspace-rescope-test-key"),
11290 "credential leaked into workspace config: {project}"
11291 );
11292 assert!(
11293 !project.contains("api_key"),
11294 "workspace config must stay credential-free: {project}"
11295 );
11296 Ok(())
11297 }
11298
11299 /// Provider-table saves go through the same resolver: an OpenRouter key
11300 /// saved with a workspace-scoped ambient config path must land in the
11301 /// user-global document.
11302 #[test]
11303 fn provider_api_key_save_rescopes_workspace_config_to_user_global() -> Result<()> {
11304 let _lock = lock_test_env();
11305 let temp = tempfile::tempdir()?;
11306 let workspace = temp.path().join("repo");
11307 fs::create_dir_all(workspace.join(".git"))?;
11308 let project_dir = workspace.join(".codewhale");
11309 fs::create_dir_all(&project_dir)?;
11310 let project_config = project_dir.join("config.toml");
11311 fs::write(&project_config, "approval_policy = \"never\"\n")?;
11312
11313 let user_home = temp.path().join("user-global-home");
11314 let _home = EnvVarGuard::set("CODEWHALE_HOME", user_home.as_os_str());
11315 let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", project_config.as_os_str());
11316 let _legacy_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
11317 let _backend = EnvVarGuard::remove("CODEWHALE_SECRET_BACKEND");
11318 let _legacy_backend = EnvVarGuard::remove("DEEPSEEK_SECRET_BACKEND");
11319
11320 let path = save_api_key_for(ApiProvider::Openrouter, "workspace-rescope-openrouter-key")?;
11321
11322 // Canonicalized comparison: see the root-key test above.
11323 assert_eq!(
11324 canonicalize_or_keep(&path),
11325 canonicalize_or_keep(&user_home.join("config.toml")),
11326 "provider save must report the user-global destination"
11327 );
11328 let global = fs::read_to_string(&path)?;
11329 assert!(global.contains("workspace-rescope-openrouter-key"));
11330 let project = fs::read_to_string(&project_config)?;
11331 assert!(
11332 !project.contains("workspace-rescope-openrouter-key"),
11333 "credential leaked into workspace config: {project}"
11334 );
11335 Ok(())
11336 }
11337 }
11338
11338 lines RUST