返回 CodeWhale
lib.rs
根目录 / crates / config / src / lib.rs
1 pub mod app_mode;
2 pub mod auth_source;
3 pub mod auto_model;
4 pub mod catalog;
5 pub mod cloud_facts;
6 mod config_document;
7 pub mod credentials;
8 pub mod descriptors;
9 pub mod device_code;
10 pub mod external_credentials;
11 pub mod legacy_root;
12 pub mod model_reference;
13 pub mod models_dev;
14 pub mod notifications;
15 mod opencode_go;
16 pub use opencode_go::{opencode_go_endpoint_key, opencode_go_model_id, opencode_go_models};
17 pub mod persistence;
18 pub mod pricing;
19 pub mod private_directory;
20 pub mod provider;
21 mod provider_defaults;
22 mod provider_kind;
23 pub mod redaction;
24 pub mod resolve;
25 pub mod route;
26 pub mod settings_schema;
27 pub mod setup_state;
28 pub mod user_constitution;
29 #[cfg(windows)]
30 pub mod windows_identity;
31 mod xai_credentials;
32 pub use config_document::{
33 ConfigDocumentUndo, create_config_document, migrate_legacy_root_config, mutate_config_document,
34 mutate_config_document_undoable, mutate_config_document_undoable_with_migration,
35 mutate_config_document_with_migration, preview_legacy_root_config,
36 replace_config_document_if_unchanged, set_config_document_value, unset_config_document_value,
37 with_config_write_lock,
38 };
39 pub use model_reference::{Modality, ModelReferenceCard, ModelReferenceDatabase};
40 pub(crate) use provider_defaults::*;
41 pub use provider_kind::ProviderKind;
42 pub use route::ProviderId;
43 pub use settings_schema::{
44 SETTINGS_SCHEMA, SettingDef, SettingKind, SettingOption, SettingUi, schema_groups, schema_rows,
45 schema_tabs, setting, setting_index,
46 };
47 pub use setup_state::{
48 ConstitutionAuthoring, ConstitutionChoice, ConstitutionSource, ConstitutionValidity,
49 InheritedConfigFacts, RuntimePostureSource, SetupState, SetupStep, StepEntry, StepStatus,
50 TELEMETRY_NOTICE_VERSION,
51 };
52 pub use user_constitution::{
53 APPROX_BYTES_PER_TOKEN, AutonomyPreference, CacheProjection, ClauseOrigin, ClauseStatus,
54 ConstitutionClause, ConstitutionRecommendation, MigrationOutcome, MigrationReceipt,
55 MigrationRejection, Ratification, RatificationError, RecommendationParse,
56 USER_CONSTITUTION_SCHEMA_VERSION, USER_CONSTITUTION_SCHEMA_VERSION_V1, UntrustedDraftParse,
57 UserConstitution, UserConstitutionLoad,
58 };
59 pub use xai_credentials::{
60 CHATGPT_HOST_FILE_NAME, CHATGPT_OAUTH_GENERATION_PREFIX, CHATGPT_OAUTH_GENERATION_SUFFIX,
61 LEGACY_CHATGPT_OAUTH_FILE_NAME, LEGACY_XAI_OAUTH_FILE_NAME, XAI_OAUTH_GENERATION_PREFIX,
62 XAI_OAUTH_GENERATION_SUFFIX, XaiOAuthCredentialStore, XaiOAuthRevocation,
63 chatgpt_oauth_generation_path, clear_all_chatgpt_oauth_credentials,
64 clear_all_chatgpt_oauth_credentials_locked, clear_all_xai_oauth_credentials,
65 is_valid_chatgpt_oauth_generation, is_valid_xai_oauth_generation, legacy_chatgpt_oauth_path,
66 legacy_xai_oauth_path, remove_chatgpt_oauth_generation, remove_xai_oauth_generation,
67 validate_chatgpt_oauth_generation, validate_xai_oauth_generation,
68 with_xai_oauth_lifecycle_lock, with_xai_oauth_revocation_transaction,
69 xai_oauth_credentials_dir, xai_oauth_generation_path,
70 };
71
72 use std::collections::{BTreeMap, BTreeSet};
73 use std::ffi::{OsStr, OsString};
74 use std::fmt;
75 use std::fs;
76 use std::io::Read;
77 use std::io::Write;
78 use std::path::{Component, Path, PathBuf};
79
80 use anyhow::{Context, Result, bail};
81 pub use app_mode::AppMode;
82 pub use auth_source::{AuthSourceKind, ProviderAuthSourceToml};
83 pub use codewhale_execpolicy::ToolAskRule;
84 use codewhale_execpolicy::{ExecPolicyEngine, PermissionAction, Ruleset};
85 use codewhale_secrets::SecretSource;
86 pub use codewhale_secrets::Secrets;
87 pub use external_credentials::{
88 EXTERNAL_CREDENTIAL_CONSENT_VERSION, EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS,
89 ExternalCredentialAccess, ExternalCredentialConsentStatus, ExternalCredentialConsentToml,
90 ExternalCredentialReadGrant, ExternalCredentialSource, default_dsh_credentials_path,
91 external_credential_consent_status, quote_os_path, resolve_external_credential_path,
92 };
93 use serde::{Deserialize, Serialize};
94 use sha2::{Digest as _, Sha256};
95
96 #[cfg(unix)]
97 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
98
99 pub const CONFIG_FILE_NAME: &str = "config.toml";
100 pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml";
101 pub const LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE: &str = "Antigravity is a retired, non-runnable legacy provider. Clear Codewhale-owned legacy state with `codewhale auth clear --provider antigravity`; this does not alter Google or Antigravity sessions. For Gemini use provider `google` with `GEMINI_API_KEY`.";
102
103 /// Secret-store routing metadata; never credential material.
104 pub const API_KEYRING_SENTINEL: &str = "__KEYRING__";
105
106 /// Canonical structural classification for configured API-key values.
107 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
108 pub enum ConfigApiKeyValueKind {
109 Empty,
110 SecretStoreSentinel,
111 Literal,
112 }
113
114 #[must_use]
115 pub fn classify_config_api_key_value(value: &str) -> ConfigApiKeyValueKind {
116 match value.trim() {
117 "" => ConfigApiKeyValueKind::Empty,
118 API_KEYRING_SENTINEL => ConfigApiKeyValueKind::SecretStoreSentinel,
119 _ => ConfigApiKeyValueKind::Literal,
120 }
121 }
122
123 fn http_headers_are_effectively_empty(headers: &BTreeMap<String, String>) -> bool {
124 !headers
125 .iter()
126 .any(|(name, value)| !name.trim().is_empty() && !value.trim().is_empty())
127 }
128
129 /// Whether an HTTP header can carry the model provider's primary credential.
130 ///
131 /// Header names are case-insensitive. Keeping this classifier in shared config
132 /// prevents `auth_mode = "none"` from disabling a generated bearer token while
133 /// still leaking the same credential through a configured alternate dialect.
134 #[must_use]
135 pub fn is_upstream_auth_header(name: &str) -> bool {
136 let name = name.trim();
137 // Configured gateways use more credential dialects than the three headers
138 // generated by Codewhale itself. `auth_mode = "none"` is an endpoint
139 // contract, so suppress every credential-shaped request header instead of
140 // allowing the same secret through Proxy-Authorization, X-Auth-Token,
141 // X-Access-Token, X-Goog-Api-Key, or another *-token/*-api-key spelling.
142 is_sensitive_config_key(name)
143 }
144
145 /// Preserve OpenRouter endpoint slugs verbatim; an empty value clears a pin.
146 /// The service owns the vendor catalog, so validation must not freeze one here.
147 pub fn validate_openrouter_vendor(value: &str) -> Result<Option<&str>> {
148 if value.trim().is_empty() {
149 return Ok(None);
150 }
151 if value
152 .chars()
153 .any(|ch| ch.is_whitespace() || ch.is_control())
154 {
155 bail!(
156 "providers.openrouter.vendor must be an OpenRouter slug without whitespace or control characters"
157 );
158 }
159 Ok(Some(value))
160 }
161
162 /// Apply a validated pin to an OpenRouter request without dropping unrelated
163 /// caller policies such as data collection or zero-data-retention constraints.
164 pub fn apply_openrouter_vendor(body: &mut serde_json::Value, vendor: Option<&str>) {
165 if let Some(vendor) = vendor {
166 if !body["provider"].is_object() {
167 body["provider"] = serde_json::json!({});
168 }
169 body["provider"]["order"] = serde_json::json!([vendor]);
170 body["provider"]["allow_fallbacks"] = serde_json::json!(false);
171 }
172 }
173
174 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
175 pub struct ProviderConfigToml {
176 /// OpenRouter upstream slug, including an optional endpoint variant.
177 /// Requests with a vendor pin disable OpenRouter's upstream fallbacks.
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub vendor: Option<String>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub api_key: Option<String>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub base_url: Option<String>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub model: Option<String>,
186 #[serde(
187 default,
188 skip_serializing_if = "Option::is_none",
189 alias = "contextWindow",
190 alias = "context_window_tokens",
191 alias = "contextWindowTokens",
192 alias = "context_length",
193 alias = "contextLength"
194 )]
195 pub context_window: Option<u32>,
196 /// Per-model context-window overrides keyed by exact wire model id
197 /// (`[providers.<id>.model_context_windows]`, #6108). A matching entry
198 /// wins over this provider's `context_window` for that model only, so one
199 /// gateway can front models with heterogeneous windows.
200 #[serde(
201 default,
202 skip_serializing_if = "BTreeMap::is_empty",
203 alias = "modelContextWindows"
204 )]
205 pub model_context_windows: BTreeMap<String, u32>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub mode: Option<String>,
208 /// Wire dialect preference for dual-protocol vendors (DeepSeek, MiniMax,
209 /// Model Studio): `openai` (Chat Completions, default) or `anthropic`
210 /// (Messages). Not a separate catalog provider — a power-user toggle.
211 #[serde(
212 default,
213 skip_serializing_if = "Option::is_none",
214 alias = "api_style",
215 alias = "protocol",
216 alias = "wire_format",
217 alias = "dialect"
218 )]
219 pub wire: Option<String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub auth_mode: Option<String>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub insecure_skip_tls_verify: Option<bool>,
224 /// Explicit consent to a plain-HTTP `base_url` for this provider (a
225 /// llama.cpp box on the LAN, an internal gateway). Loopback hosts are
226 /// always allowed without it. Distinct from `insecure_skip_tls_verify`,
227 /// which skips TLS certificate verification on HTTPS URLs and does not
228 /// permit plain HTTP (#5991).
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub allow_insecure_http: Option<bool>,
231 #[serde(default, skip_serializing_if = "http_headers_are_effectively_empty")]
232 pub http_headers: BTreeMap<String, String>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub path_suffix: Option<String>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub auth: Option<ProviderAuthSourceToml>,
237 /// Explicit consent for reading one exact credential file owned by
238 /// another CLI. Absence means disabled and must not trigger discovery.
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub external_credentials: Option<ExternalCredentialConsentToml>,
241 /// Codewhale-owned xAI OAuth generation selected by config. The value is a
242 /// validated basename under `$CODEWHALE_HOME/credentials`, never an
243 /// arbitrary path.
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub oauth_credential_generation: Option<String>,
246 /// Preserve provider fields introduced by newer Codewhale versions and by
247 /// custom provider adapters when an older typed writer saves this file.
248 #[serde(flatten)]
249 pub extras: BTreeMap<String, toml::Value>,
250 }
251
252 impl ProviderConfigToml {
253 #[must_use]
254 pub fn is_empty(&self) -> bool {
255 let blank = |value: Option<&String>| value.is_none_or(|value| value.trim().is_empty());
256
257 blank(self.api_key.as_ref())
258 && self.vendor.is_none()
259 && blank(self.base_url.as_ref())
260 && blank(self.model.as_ref())
261 && self.context_window.is_none()
262 && self.model_context_windows.is_empty()
263 && blank(self.mode.as_ref())
264 && blank(self.wire.as_ref())
265 && blank(self.auth_mode.as_ref())
266 && self.insecure_skip_tls_verify.is_none()
267 && self.allow_insecure_http.is_none()
268 && http_headers_are_effectively_empty(&self.http_headers)
269 && blank(self.path_suffix.as_ref())
270 && self.auth.is_none()
271 && self.external_credentials.is_none()
272 && self.oauth_credential_generation.is_none()
273 && self.extras.is_empty()
274 }
275 }
276
277 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
278 pub struct ProvidersToml {
279 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
280 pub deepseek: ProviderConfigToml,
281 #[serde(
282 default,
283 skip_serializing_if = "ProviderConfigToml::is_empty",
284 alias = "deepseek-anthropic",
285 alias = "deepseekAnthropic",
286 alias = "deepseek-claude",
287 alias = "deepseek_claude"
288 )]
289 pub deepseek_anthropic: ProviderConfigToml,
290 #[serde(
291 default,
292 skip_serializing_if = "ProviderConfigToml::is_empty",
293 // The canonical provider id is the kebab `nvidia-nim` (see
294 // `provider.rs`); without these aliases a `[providers.nvidia-nim]`
295 // TOML section was silently dropped (2026-08-04 review).
296 alias = "nvidia-nim",
297 alias = "nvidia",
298 alias = "nim"
299 )]
300 pub nvidia_nim: ProviderConfigToml,
301 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
302 pub openai: ProviderConfigToml,
303 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
304 pub atlascloud: ProviderConfigToml,
305 #[serde(
306 default,
307 skip_serializing_if = "ProviderConfigToml::is_empty",
308 alias = "wanjie-ark",
309 alias = "wanjie",
310 alias = "ark-wanjie",
311 alias = "ark_wanjie"
312 )]
313 pub wanjie_ark: ProviderConfigToml,
314 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
315 pub volcengine: ProviderConfigToml,
316 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
317 pub openrouter: ProviderConfigToml,
318 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
319 pub orcarouter: ProviderConfigToml,
320 #[serde(
321 default,
322 skip_serializing_if = "ProviderConfigToml::is_empty",
323 alias = "xiaomi-mimo",
324 alias = "xiaomi",
325 alias = "mimo",
326 alias = "xiaomimimo"
327 )]
328 pub xiaomi_mimo: ProviderConfigToml,
329 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
330 pub novita: ProviderConfigToml,
331 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
332 pub fireworks: ProviderConfigToml,
333 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
334 pub siliconflow: ProviderConfigToml,
335 #[serde(
336 default,
337 skip_serializing_if = "ProviderConfigToml::is_empty",
338 alias = "siliconflow-CN",
339 alias = "siliconflow-cn"
340 )]
341 pub siliconflow_cn: ProviderConfigToml,
342 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
343 pub arcee: ProviderConfigToml,
344 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
345 pub moonshot: ProviderConfigToml,
346 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
347 pub sglang: ProviderConfigToml,
348 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
349 pub vllm: ProviderConfigToml,
350 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
351 pub ollama: ProviderConfigToml,
352 #[serde(
353 default,
354 skip_serializing_if = "ProviderConfigToml::is_empty",
355 alias = "ollama-cloud"
356 )]
357 pub ollama_cloud: ProviderConfigToml,
358 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
359 pub huggingface: ProviderConfigToml,
360 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
361 pub modelscope: ProviderConfigToml,
362 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
363 pub together: ProviderConfigToml,
364 #[serde(
365 default,
366 skip_serializing_if = "ProviderConfigToml::is_empty",
367 alias = "baidu-qianfan",
368 alias = "baidu_qianfan",
369 alias = "baidu"
370 )]
371 pub qianfan: ProviderConfigToml,
372 #[serde(
373 default,
374 skip_serializing_if = "ProviderConfigToml::is_empty",
375 alias = "openai-codex",
376 alias = "openai_codex",
377 alias = "codex",
378 alias = "chatgpt",
379 alias = "chatgpt-codex"
380 )]
381 pub openai_codex: ProviderConfigToml,
382 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
383 pub anthropic: ProviderConfigToml,
384 #[serde(
385 default,
386 skip_serializing_if = "ProviderConfigToml::is_empty",
387 alias = "open-model",
388 alias = "open_model"
389 )]
390 pub openmodel: ProviderConfigToml,
391 #[serde(
392 default,
393 skip_serializing_if = "ProviderConfigToml::is_empty",
394 alias = "z-ai",
395 alias = "z_ai",
396 alias = "z.ai",
397 alias = "zhipu",
398 alias = "zhipuai",
399 alias = "bigmodel",
400 alias = "big-model"
401 )]
402 pub zai: ProviderConfigToml,
403 #[serde(
404 default,
405 skip_serializing_if = "ProviderConfigToml::is_empty",
406 alias = "step-fun",
407 alias = "step_fun",
408 alias = "stepfun",
409 alias = "stepflash",
410 alias = "step-flash",
411 alias = "step_flash"
412 )]
413 pub stepfun: ProviderConfigToml,
414 #[serde(
415 default,
416 skip_serializing_if = "ProviderConfigToml::is_empty",
417 alias = "mini-max",
418 alias = "mini_max",
419 alias = "minimax"
420 )]
421 pub minimax: ProviderConfigToml,
422 #[serde(
423 default,
424 skip_serializing_if = "ProviderConfigToml::is_empty",
425 alias = "minimax-anthropic",
426 alias = "minimaxAnthropic",
427 alias = "mini-max-anthropic",
428 alias = "mini_max_anthropic"
429 )]
430 pub minimax_anthropic: ProviderConfigToml,
431 #[serde(
432 default,
433 skip_serializing_if = "ProviderConfigToml::is_empty",
434 alias = "deep-infra",
435 alias = "deep_infra"
436 )]
437 pub deepinfra: ProviderConfigToml,
438 #[serde(
439 default,
440 skip_serializing_if = "ProviderConfigToml::is_empty",
441 alias = "sakana-ai",
442 alias = "sakana_ai",
443 alias = "fugu"
444 )]
445 pub sakana: ProviderConfigToml,
446 #[serde(
447 default,
448 skip_serializing_if = "ProviderConfigToml::is_empty",
449 alias = "long-cat",
450 alias = "meituan-longcat",
451 alias = "meituan"
452 )]
453 pub longcat: ProviderConfigToml,
454 #[serde(
455 default,
456 skip_serializing_if = "ProviderConfigToml::is_empty",
457 alias = "opencode-go",
458 alias = "opencodego"
459 )]
460 pub opencode_go: ProviderConfigToml,
461 #[serde(
462 default,
463 skip_serializing_if = "ProviderConfigToml::is_empty",
464 alias = "opencode-zen",
465 alias = "opencodezen",
466 alias = "zen",
467 alias = "opencode"
468 )]
469 pub opencode_zen: ProviderConfigToml,
470 #[serde(
471 default,
472 skip_serializing_if = "ProviderConfigToml::is_empty",
473 alias = "meta-ai",
474 alias = "meta_ai",
475 alias = "meta-model-api",
476 alias = "meta_model_api",
477 alias = "muse",
478 alias = "muse-spark"
479 )]
480 pub meta: ProviderConfigToml,
481 #[serde(
482 default,
483 skip_serializing_if = "ProviderConfigToml::is_empty",
484 alias = "x-ai",
485 alias = "x_ai",
486 alias = "grok"
487 )]
488 pub xai: ProviderConfigToml,
489 #[serde(
490 default,
491 skip_serializing_if = "ProviderConfigToml::is_empty",
492 alias = "mistral-ai",
493 alias = "mistral_ai",
494 alias = "mistralai",
495 alias = "la-plateforme",
496 alias = "la_plateforme"
497 )]
498 pub mistral: ProviderConfigToml,
499 /// Google Gemini — official OpenAI-compatible endpoint with thought
500 /// signatures on tool calls.
501 #[serde(
502 default,
503 skip_serializing_if = "ProviderConfigToml::is_empty",
504 alias = "google-gemini",
505 alias = "google_gemini",
506 alias = "gemini"
507 )]
508 pub google: ProviderConfigToml,
509 /// Retired Antigravity configuration. This table exists only so old
510 /// Codewhale-owned state can deserialize and be cleared safely.
511 #[serde(
512 default,
513 skip_serializing_if = "ProviderConfigToml::is_empty",
514 alias = "agy"
515 )]
516 pub antigravity: ProviderConfigToml,
517 /// Jiangsu Telecom TokenHub — OpenAI-compatible AI gateway.
518 #[serde(
519 default,
520 skip_serializing_if = "ProviderConfigToml::is_empty",
521 alias = "telecom-js",
522 alias = "telecom_js",
523 alias = "telecomjs-cn",
524 alias = "tokenhub"
525 )]
526 pub telecomjs: ProviderConfigToml,
527 /// Eden AI — OpenAI-compatible AI gateway (aggregator).
528 #[serde(
529 default,
530 skip_serializing_if = "ProviderConfigToml::is_empty",
531 alias = "eden-ai",
532 alias = "eden_ai"
533 )]
534 pub edenai: ProviderConfigToml,
535 /// ZenMux — OpenAI-compatible AI gateway (aggregator).
536 #[serde(
537 default,
538 skip_serializing_if = "ProviderConfigToml::is_empty",
539 alias = "zen-mux",
540 alias = "zen_mux"
541 )]
542 pub zenmux: ProviderConfigToml,
543 /// CSDN 星图 — hosted OpenAI-compatible platform and Coding Plan.
544 #[serde(
545 default,
546 skip_serializing_if = "ProviderConfigToml::is_empty",
547 alias = "csdn-ai",
548 alias = "csdn_ai",
549 alias = "csdn-coding-plan",
550 alias = "csdn_coding_plan",
551 alias = "starmap"
552 )]
553 pub csdn: ProviderConfigToml,
554 /// Concentrate — OpenAI Responses-compatible AI gateway (aggregator).
555 #[serde(
556 default,
557 skip_serializing_if = "ProviderConfigToml::is_empty",
558 alias = "concentrate-ai",
559 alias = "concentrate_ai",
560 alias = "concentrateai"
561 )]
562 pub concentrate: ProviderConfigToml,
563 /// Codewhale API — account-backed model access over connected provider keys.
564 #[serde(
565 default,
566 skip_serializing_if = "ProviderConfigToml::is_empty",
567 alias = "codewhale-api",
568 alias = "codewhale_api",
569 alias = "cw-api",
570 alias = "codewhale-cloud"
571 )]
572 pub codewhale: ProviderConfigToml,
573 /// Alibaba Cloud Model Studio — Token Plan (OpenAI-compatible endpoint).
574 #[serde(
575 default,
576 skip_serializing_if = "ProviderConfigToml::is_empty",
577 alias = "modelstudio-token-plan",
578 alias = "modelstudio_token_plan",
579 alias = "alibaba-token-plan",
580 alias = "dashscope-token-plan"
581 )]
582 pub modelstudio_token_plan: ProviderConfigToml,
583 /// Alibaba Cloud Model Studio — Token Plan Anthropic-compatible endpoint.
584 #[serde(
585 default,
586 skip_serializing_if = "ProviderConfigToml::is_empty",
587 alias = "modelstudio-token-plan-anthropic",
588 alias = "modelstudio_token_plan_anthropic",
589 alias = "alibaba-token-plan-anthropic"
590 )]
591 pub modelstudio_token_plan_anthropic: ProviderConfigToml,
592 /// Alibaba Cloud Model Studio — Coding Plan (OpenAI-compatible endpoint).
593 #[serde(
594 default,
595 skip_serializing_if = "ProviderConfigToml::is_empty",
596 alias = "modelstudio-coding-plan",
597 alias = "modelstudio_coding_plan",
598 alias = "alibaba-coding-plan",
599 alias = "dashscope-coding-plan"
600 )]
601 pub modelstudio_coding_plan: ProviderConfigToml,
602 /// Alibaba Cloud Model Studio — Coding Plan Anthropic-compatible endpoint.
603 #[serde(
604 default,
605 skip_serializing_if = "ProviderConfigToml::is_empty",
606 alias = "modelstudio-coding-plan-anthropic",
607 alias = "modelstudio_coding_plan_anthropic",
608 alias = "alibaba-coding-plan-anthropic"
609 )]
610 pub modelstudio_coding_plan_anthropic: ProviderConfigToml,
611 /// Catch-all table for the dynamic OpenAI-compatible custom provider
612 /// identity (#1519). Arbitrary `[providers.<name>]` tables are handled by
613 /// the tui-side flatten map; this named slot keeps the canonical
614 /// `ProviderKind::Custom` lookups total without leaking into another
615 /// provider's config.
616 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
617 pub custom: ProviderConfigToml,
618 /// Preserve dynamically named provider tables and providers added by a
619 /// newer Codewhale version.
620 #[serde(flatten)]
621 pub extras: BTreeMap<String, toml::Value>,
622 }
623
624 /// Sibling `permissions.toml` schema.
625 ///
626 /// Each rule is a typed condition that can deny, allow, or ask before a tool
627 /// invocation. The approval card persists ask rules and narrowly scoped,
628 /// exact allow grants; deny rules remain manually authored.
629 #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
630 #[serde(deny_unknown_fields)]
631 pub struct PermissionsToml {
632 #[serde(default, skip_serializing_if = "Vec::is_empty")]
633 pub rules: Vec<ToolAskRule>,
634 }
635
636 /// On-disk state of the active sibling `permissions.toml`.
637 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
638 pub enum PermissionsFileState {
639 /// No sibling permission file exists.
640 Missing,
641 /// The sibling permission file exists but contains no TOML content.
642 Empty,
643 /// The sibling permission file contains a parsed TOML document.
644 Present,
645 }
646
647 /// A parsed, read-only view of the active sibling `permissions.toml`.
648 ///
649 /// Removal tokens bind a displayed rule index to the exact file bytes that
650 /// produced this snapshot. A later editor must present the rule again when
651 /// another process changed the file instead of deleting whichever rule moved
652 /// into the old index.
653 #[derive(Debug, Clone, PartialEq, Eq)]
654 pub struct PermissionsSnapshot {
655 path: PathBuf,
656 file_state: PermissionsFileState,
657 permissions: PermissionsToml,
658 removal_tokens: Vec<String>,
659 }
660
661 impl PermissionsSnapshot {
662 #[must_use]
663 pub fn path(&self) -> &Path {
664 &self.path
665 }
666
667 #[must_use]
668 pub fn file_exists(&self) -> bool {
669 self.file_state != PermissionsFileState::Missing
670 }
671
672 #[must_use]
673 pub fn file_state(&self) -> PermissionsFileState {
674 self.file_state
675 }
676
677 #[must_use]
678 pub fn permissions(&self) -> &PermissionsToml {
679 &self.permissions
680 }
681
682 #[must_use]
683 pub fn rules(&self) -> &[ToolAskRule] {
684 &self.permissions.rules
685 }
686
687 /// Return the opaque confirmation token for a zero-based rule index.
688 #[must_use]
689 pub fn removal_token(&self, index: usize) -> Option<&str> {
690 self.removal_tokens.get(index).map(String::as_str)
691 }
692 }
693
694 impl PermissionsToml {
695 #[must_use]
696 pub fn is_empty(&self) -> bool {
697 self.rules.is_empty()
698 }
699
700 #[must_use]
701 pub fn ruleset(&self) -> Ruleset {
702 let mut denied = Vec::new();
703 let mut trusted = Vec::new();
704 let mut ask_rules = Vec::new();
705
706 for rule in &self.rules {
707 match rule.action {
708 PermissionAction::Deny => {
709 // Command-based deny rules are promoted to denied_prefixes
710 // so they are caught by execpolicy's deny-always-wins check.
711 if let Some(cmd) = &rule.command
712 && !rule.command_exact
713 && rule.workspace.is_none()
714 {
715 denied.push(cmd.clone());
716 }
717 // Always keep in ask_rules for path-based and tool-only matching.
718 ask_rules.push(rule.clone());
719 }
720 PermissionAction::Allow => {
721 // Command-based allow rules are promoted to trusted_prefixes
722 // for arity-aware matching. Path-only allow rules are
723 // handled through ask_rules (they skip the approval prompt).
724 if let Some(cmd) = &rule.command
725 && !rule.command_exact
726 && rule.workspace.is_none()
727 {
728 trusted.push(cmd.clone());
729 }
730 // Keep in ask_rules so path-only allow rules also work.
731 ask_rules.push(rule.clone());
732 }
733 PermissionAction::Ask => {
734 ask_rules.push(rule.clone());
735 }
736 }
737 }
738
739 Ruleset::user(trusted, denied).with_ask_rules(ask_rules)
740 }
741 }
742
743 impl ProvidersToml {
744 #[must_use]
745 pub fn is_empty(&self) -> bool {
746 // The full registry, not the selectable catalog: legacy dialect and
747 // plan tables (`deepseek_anthropic`, `modelstudio_coding_plan`, ...)
748 // are still fields here, and skipping the whole `[providers]` section
749 // when only one of them is set would erase it on the next typed save.
750 self.extras.is_empty()
751 && provider::all_providers()
752 .iter()
753 .all(|provider| self.for_provider(provider.kind()).is_empty())
754 && self.antigravity.is_empty()
755 }
756
757 #[must_use]
758 pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
759 match provider {
760 ProviderKind::Deepseek => &self.deepseek,
761 ProviderKind::DeepseekAnthropic => &self.deepseek_anthropic,
762 ProviderKind::NvidiaNim => &self.nvidia_nim,
763 ProviderKind::Openai => &self.openai,
764 ProviderKind::Atlascloud => &self.atlascloud,
765 ProviderKind::WanjieArk => &self.wanjie_ark,
766 ProviderKind::Volcengine => &self.volcengine,
767 ProviderKind::Openrouter => &self.openrouter,
768 ProviderKind::Orcarouter => &self.orcarouter,
769 ProviderKind::XiaomiMimo => &self.xiaomi_mimo,
770 ProviderKind::Novita => &self.novita,
771 ProviderKind::Fireworks => &self.fireworks,
772 ProviderKind::Siliconflow => &self.siliconflow,
773 ProviderKind::SiliconflowCN => &self.siliconflow_cn,
774 ProviderKind::Arcee => &self.arcee,
775 ProviderKind::Moonshot => &self.moonshot,
776 ProviderKind::Sglang => &self.sglang,
777 ProviderKind::Vllm => &self.vllm,
778 ProviderKind::Ollama => &self.ollama,
779 ProviderKind::OllamaCloud => &self.ollama_cloud,
780 ProviderKind::Huggingface => &self.huggingface,
781 ProviderKind::Modelscope => &self.modelscope,
782 ProviderKind::Together => &self.together,
783 ProviderKind::Qianfan => &self.qianfan,
784 ProviderKind::OpenaiCodex => &self.openai_codex,
785 ProviderKind::Anthropic => &self.anthropic,
786 ProviderKind::Openmodel => &self.openmodel,
787 ProviderKind::Zai => &self.zai,
788 ProviderKind::Stepfun => &self.stepfun,
789 ProviderKind::Minimax => &self.minimax,
790 ProviderKind::MinimaxAnthropic => &self.minimax_anthropic,
791 ProviderKind::Deepinfra => &self.deepinfra,
792 ProviderKind::Sakana => &self.sakana,
793 ProviderKind::LongCat => &self.longcat,
794 ProviderKind::OpencodeGo => &self.opencode_go,
795 ProviderKind::OpencodeZen => &self.opencode_zen,
796 ProviderKind::Meta => &self.meta,
797 ProviderKind::Xai => &self.xai,
798 ProviderKind::Mistral => &self.mistral,
799 ProviderKind::Google => &self.google,
800 ProviderKind::Antigravity => &self.antigravity,
801 ProviderKind::Telecomjs => &self.telecomjs,
802 ProviderKind::Edenai => &self.edenai,
803 ProviderKind::Zenmux => &self.zenmux,
804 ProviderKind::Csdn => &self.csdn,
805 ProviderKind::Concentrate => &self.concentrate,
806 ProviderKind::Codewhale => &self.codewhale,
807 ProviderKind::ModelstudioTokenPlan => &self.modelstudio_token_plan,
808 ProviderKind::ModelstudioTokenPlanAnthropic => &self.modelstudio_token_plan_anthropic,
809 ProviderKind::ModelstudioCodingPlan => &self.modelstudio_coding_plan,
810 ProviderKind::ModelstudioCodingPlanAnthropic => &self.modelstudio_coding_plan_anthropic,
811 ProviderKind::Custom => &self.custom,
812 }
813 }
814
815 pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
816 match provider {
817 ProviderKind::Deepseek => &mut self.deepseek,
818 ProviderKind::DeepseekAnthropic => &mut self.deepseek_anthropic,
819 ProviderKind::NvidiaNim => &mut self.nvidia_nim,
820 ProviderKind::Openai => &mut self.openai,
821 ProviderKind::Atlascloud => &mut self.atlascloud,
822 ProviderKind::WanjieArk => &mut self.wanjie_ark,
823 ProviderKind::Volcengine => &mut self.volcengine,
824 ProviderKind::Openrouter => &mut self.openrouter,
825 ProviderKind::Orcarouter => &mut self.orcarouter,
826 ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo,
827 ProviderKind::Novita => &mut self.novita,
828 ProviderKind::Fireworks => &mut self.fireworks,
829 ProviderKind::Siliconflow => &mut self.siliconflow,
830 ProviderKind::SiliconflowCN => &mut self.siliconflow_cn,
831 ProviderKind::Arcee => &mut self.arcee,
832 ProviderKind::Moonshot => &mut self.moonshot,
833 ProviderKind::Sglang => &mut self.sglang,
834 ProviderKind::Vllm => &mut self.vllm,
835 ProviderKind::Ollama => &mut self.ollama,
836 ProviderKind::OllamaCloud => &mut self.ollama_cloud,
837 ProviderKind::Huggingface => &mut self.huggingface,
838 ProviderKind::Modelscope => &mut self.modelscope,
839 ProviderKind::Together => &mut self.together,
840 ProviderKind::Qianfan => &mut self.qianfan,
841 ProviderKind::OpenaiCodex => &mut self.openai_codex,
842 ProviderKind::Anthropic => &mut self.anthropic,
843 ProviderKind::Openmodel => &mut self.openmodel,
844 ProviderKind::Zai => &mut self.zai,
845 ProviderKind::Stepfun => &mut self.stepfun,
846 ProviderKind::Minimax => &mut self.minimax,
847 ProviderKind::MinimaxAnthropic => &mut self.minimax_anthropic,
848 ProviderKind::Deepinfra => &mut self.deepinfra,
849 ProviderKind::Sakana => &mut self.sakana,
850 ProviderKind::LongCat => &mut self.longcat,
851 ProviderKind::OpencodeGo => &mut self.opencode_go,
852 ProviderKind::OpencodeZen => &mut self.opencode_zen,
853 ProviderKind::Meta => &mut self.meta,
854 ProviderKind::Xai => &mut self.xai,
855 ProviderKind::Mistral => &mut self.mistral,
856 ProviderKind::Google => &mut self.google,
857 ProviderKind::Antigravity => &mut self.antigravity,
858 ProviderKind::Telecomjs => &mut self.telecomjs,
859 ProviderKind::Edenai => &mut self.edenai,
860 ProviderKind::Zenmux => &mut self.zenmux,
861 ProviderKind::Csdn => &mut self.csdn,
862 ProviderKind::Concentrate => &mut self.concentrate,
863 ProviderKind::Codewhale => &mut self.codewhale,
864 ProviderKind::ModelstudioTokenPlan => &mut self.modelstudio_token_plan,
865 ProviderKind::ModelstudioTokenPlanAnthropic => {
866 &mut self.modelstudio_token_plan_anthropic
867 }
868 ProviderKind::ModelstudioCodingPlan => &mut self.modelstudio_coding_plan,
869 ProviderKind::ModelstudioCodingPlanAnthropic => {
870 &mut self.modelstudio_coding_plan_anthropic
871 }
872 ProviderKind::Custom => &mut self.custom,
873 }
874 }
875 }
876
877 fn deserialize_root_provider<'de, D>(deserializer: D) -> std::result::Result<ProviderKind, D::Error>
878 where
879 D: serde::Deserializer<'de>,
880 {
881 let value = String::deserialize(deserializer)?;
882 let strict = serde::de::value::StringDeserializer::<D::Error>::new(value);
883 Ok(ProviderKind::deserialize(strict).unwrap_or(ProviderKind::Custom))
884 }
885
886 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
887 pub struct ConfigToml {
888 // There is no top-level `api_key` or `base_url`: every parse moves the
889 // legacy root keys into `[providers.<name>]` first (`legacy_root`, #6394).
890 /// Optional extra HTTP headers forwarded to model API requests.
891 #[serde(default, skip_serializing_if = "http_headers_are_effectively_empty")]
892 pub http_headers: BTreeMap<String, String>,
893 /// TUI-compatible default DeepSeek model.
894 pub default_text_model: Option<String>,
895 #[serde(default, deserialize_with = "deserialize_root_provider")]
896 pub provider: ProviderKind,
897 /// Exact saved selector for a named custom provider or a built-in alias.
898 ///
899 /// This is runtime parse state rather than a second on-disk key. The
900 /// serialized `provider` value is restored by [`ConfigStore`] so a typed
901 /// dispatcher read/write cannot collapse a named route to `custom` or a
902 /// regional selector to its catalog parent.
903 #[doc(hidden)]
904 #[serde(skip)]
905 pub selected_provider_id: Option<String>,
906 pub model: Option<String>,
907 pub auth_mode: Option<String>,
908 pub verbosity: Option<String>,
909 pub log_level: Option<String>,
910 pub telemetry: Option<bool>,
911 /// Where telemetry batches are sent, when telemetry is enabled at all.
912 ///
913 /// Unset here means "take the shipped default",
914 /// [`DEFAULT_TELEMETRY_ENDPOINT`] — not "send nowhere". Setting it to the
915 /// empty string is the way to say send nowhere: that resolves to no
916 /// endpoint, which appends batches to `dryrun.jsonl` and constructs no HTTP
917 /// client. Either way a persistent or run-scoped opt-out still prevents any
918 /// batch from being constructed.
919 ///
920 /// Kept as a scalar sibling of `telemetry` rather than folded into a
921 /// `[telemetry]` table. `telemetry` is already a scalar and every section
922 /// table is declared after it, so a table of that name would be a hard
923 /// `toml::from_str` failure — and one whose cause `ConfigStore::load`
924 /// deliberately hides, leaving the user with an unloadable config and no
925 /// explanation. It would also be a `ValueAfterTable` serialization hazard
926 /// against the scalars that follow.
927 pub telemetry_endpoint: Option<String>,
928 pub approval_policy: Option<String>,
929 pub sandbox_mode: Option<String>,
930 /// Native tool catalog controls shared with `codewhale-tui`.
931 #[serde(default)]
932 pub tools: Option<ToolsToml>,
933 #[serde(default, skip_serializing_if = "ProvidersToml::is_empty")]
934 pub providers: ProvidersToml,
935 /// Operator declarations for exact provider/endpoint/model tuples.
936 #[serde(
937 default,
938 skip_serializing_if = "Option::is_none",
939 deserialize_with = "catalog::configured::deserialize_configured_models"
940 )]
941 pub custom_models: Option<Vec<catalog::configured::ConfiguredModel>>,
942 /// Provider fallback chain (#2574). TUI runtime code may advance through
943 /// these providers after recoverable provider errors; config resolution
944 /// itself still reports the selected primary provider.
945 #[serde(default, skip_serializing_if = "Vec::is_empty")]
946 pub fallback_providers: Vec<ProviderKind>,
947 /// Per-domain network policy (#135). When absent, network tools fall back
948 /// to a permissive default that mirrors pre-v0.7.0 behavior.
949 #[serde(default)]
950 pub network: Option<NetworkPolicyToml>,
951 /// Verifier-preview behavior (#2093). When absent, verifier tools keep the
952 /// shipped defaults: disabled automatic preview and hunt verdict mapping.
953 #[serde(default)]
954 pub verifier: Option<VerifierConfigToml>,
955 /// Community skill installer settings (#140). Mirrors
956 /// [`SkillsToml`] from the TUI side; the dispatcher consults
957 /// `registry_url` when running `deepseek skill install`.
958 #[serde(default)]
959 pub skills: Option<SkillsToml>,
960 /// Workspace side-git snapshots (#137). The live TUI defaults this to
961 /// enabled with 7-day retention when absent.
962 #[serde(default)]
963 pub snapshots: Option<SnapshotsToml>,
964 /// Post-edit LSP diagnostics injection (#136). When absent, the engine
965 /// applies the defaults documented in [`LspConfigToml`].
966 #[serde(default)]
967 pub lsp: Option<LspConfigToml>,
968 /// Optional 1-8 hotbar slot bindings (#2064). When absent, the TUI falls
969 /// back to the built-in default slots.
970 #[serde(default, skip_serializing_if = "Option::is_none")]
971 pub hotbar: Option<Vec<HotbarBindingToml>>,
972 /// App-server hook sink configuration. Kept separate from the TUI
973 /// lifecycle `[hooks]` table so config rewrites preserve existing hooks.
974 #[serde(default)]
975 pub hook_sinks: Option<HookSinksToml>,
976 /// Lifecycle event outbox (`[lifecycle_outbox]`). Opt-in: an unset or
977 /// empty `path` disables the feature and leaves behavior unchanged.
978 #[serde(default)]
979 pub lifecycle_outbox: Option<LifecycleOutboxToml>,
980 /// Per-session control socket (`[control_socket]`). Opt-in: an absent
981 /// table or `enabled = false` (the default) leaves the feature off and
982 /// behavior unchanged.
983 #[serde(default)]
984 pub control_socket: Option<ControlSocketToml>,
985 /// Agent Fleet trust and security policy (#3165). When absent, fleet
986 /// workers inherit conservative Sandbox defaults.
987 #[serde(default)]
988 pub fleet: Option<FleetConfigToml>,
989 /// Workflow automatic-launch, approval, isolation, and activity
990 /// persistence knobs (#4128 / Section 2.11). When absent, consumers use
991 /// [`WorkflowConfigToml::default`].
992 #[serde(default)]
993 pub workflow: Option<WorkflowConfigToml>,
994 /// Model-bound credential redaction policy (`[redaction]`). When absent,
995 /// masking is enabled — the shipped security default.
996 #[serde(default, skip_serializing_if = "Option::is_none")]
997 pub redaction: Option<crate::redaction::RedactionToml>,
998 #[serde(flatten)]
999 pub extras: BTreeMap<String, toml::Value>,
1000 }
1001
1002 impl ConfigToml {
1003 /// The requested model-bound masking mode, defaulting to enabled.
1004 ///
1005 /// The request only takes effect once the interactive TUI has recorded a
1006 /// confirmation on its startup gate; see
1007 /// [`crate::redaction::effective_masking`].
1008 #[must_use]
1009 pub fn redaction_model_bound_masking(&self) -> crate::redaction::ModelBoundMasking {
1010 self.redaction
1011 .as_ref()
1012 .map(crate::redaction::RedactionToml::model_bound_masking)
1013 .unwrap_or_default()
1014 }
1015 }
1016
1017 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1018 enum ProviderConfigField {
1019 Vendor,
1020 ApiKey,
1021 BaseUrl,
1022 Model,
1023 ContextWindow,
1024 Mode,
1025 Wire,
1026 AuthMode,
1027 InsecureSkipTlsVerify,
1028 AllowInsecureHttp,
1029 HttpHeaders,
1030 PathSuffix,
1031 }
1032
1033 impl ProviderConfigField {
1034 fn parse(key: &str) -> Option<Self> {
1035 Some(match key {
1036 "vendor" => Self::Vendor,
1037 "api_key" => Self::ApiKey,
1038 "base_url" => Self::BaseUrl,
1039 "model" => Self::Model,
1040 "context_window" | "context_window_tokens" => Self::ContextWindow,
1041 "mode" => Self::Mode,
1042 "wire" | "api_style" | "protocol" | "wire_format" | "dialect" => Self::Wire,
1043 "auth_mode" => Self::AuthMode,
1044 "insecure_skip_tls_verify" => Self::InsecureSkipTlsVerify,
1045 "allow_insecure_http" => Self::AllowInsecureHttp,
1046 "http_headers" => Self::HttpHeaders,
1047 "path_suffix" => Self::PathSuffix,
1048 _ => return None,
1049 })
1050 }
1051
1052 fn key(self) -> &'static str {
1053 match self {
1054 Self::Vendor => "vendor",
1055 Self::ApiKey => "api_key",
1056 Self::BaseUrl => "base_url",
1057 Self::Model => "model",
1058 Self::ContextWindow => "context_window",
1059 Self::Mode => "mode",
1060 Self::Wire => "wire",
1061 Self::AuthMode => "auth_mode",
1062 Self::InsecureSkipTlsVerify => "insecure_skip_tls_verify",
1063 Self::AllowInsecureHttp => "allow_insecure_http",
1064 Self::HttpHeaders => "http_headers",
1065 Self::PathSuffix => "path_suffix",
1066 }
1067 }
1068 }
1069
1070 fn parse_provider_config_key(key: &str) -> Option<(ProviderKind, ProviderConfigField)> {
1071 let suffix = key.strip_prefix("providers.")?;
1072 let (provider_key, field_key) = suffix.split_once('.')?;
1073 let field = ProviderConfigField::parse(field_key)?;
1074 // Full registry, not ProviderKind::ALL: legacy dialect/plan kinds keep
1075 // their own [providers.*] tables even though they left the catalog.
1076 let provider = provider::all_providers()
1077 .iter()
1078 .map(|p| p.kind())
1079 .find(|kind| kind.provider().provider_config_key() == provider_key)?;
1080 Some((provider, field))
1081 }
1082
1083 /// Split a `providers.<id>.<field>` key without resolving the provider. Used
1084 /// for custom providers, whose ids live in `[providers.<id>]` tables inside
1085 /// `ProvidersToml::extras` rather than in [`ProviderKind::ALL`].
1086 fn parse_custom_provider_config_key(key: &str) -> Option<(&str, &str)> {
1087 let suffix = key.strip_prefix("providers.")?;
1088 let (provider_id, field_key) = suffix.split_once('.')?;
1089 (!provider_id.is_empty()).then_some((provider_id, field_key))
1090 }
1091
1092 fn is_builtin_provider_config_id(provider_id: &str) -> bool {
1093 provider::all_providers()
1094 .iter()
1095 .any(|p| p.provider_config_key() == provider_id)
1096 }
1097
1098 fn builtin_provider_kind_for_config_id(provider_id: &str) -> Option<ProviderKind> {
1099 provider::all_providers()
1100 .iter()
1101 .map(|p| p.kind())
1102 .find(|kind| kind.provider().provider_config_key() == provider_id)
1103 }
1104
1105 /// Split `providers.<id>.model_context_windows.<model>` (#6108). The model leg
1106 /// is the whole remainder, so dotted wire ids like `qwen3.5` stay intact.
1107 fn parse_model_context_window_key(key: &str) -> Option<(&str, &str)> {
1108 let (provider_id, field_key) = parse_custom_provider_config_key(key)?;
1109 let model = field_key.strip_prefix("model_context_windows.")?;
1110 (!model.is_empty()).then_some((provider_id, model))
1111 }
1112
1113 /// Field legs a `[providers.<id>]` custom table accepts through
1114 /// `config set`, including the required `kind` marker.
1115 const CUSTOM_PROVIDER_FIELD_HINT: &str = "api_key, base_url, model, context_window, mode, wire, auth_mode, \
1116 insecure_skip_tls_verify, allow_insecure_http, http_headers, path_suffix, kind";
1117
1118 fn provider_config_key(provider: ProviderKind, field: ProviderConfigField) -> String {
1119 format!(
1120 "providers.{}.{}",
1121 provider.provider().provider_config_key(),
1122 field.key()
1123 )
1124 }
1125
1126 fn get_provider_config_value(
1127 config: &ProviderConfigToml,
1128 field: ProviderConfigField,
1129 ) -> Option<String> {
1130 match field {
1131 ProviderConfigField::Vendor => config.vendor.clone(),
1132 ProviderConfigField::ApiKey => config.api_key.clone(),
1133 ProviderConfigField::BaseUrl => config.base_url.clone(),
1134 ProviderConfigField::Model => config.model.clone(),
1135 ProviderConfigField::ContextWindow => config.context_window.map(|value| value.to_string()),
1136 ProviderConfigField::Mode => config.mode.clone(),
1137 ProviderConfigField::Wire => config.wire.clone(),
1138 ProviderConfigField::AuthMode => config.auth_mode.clone(),
1139 ProviderConfigField::InsecureSkipTlsVerify => config
1140 .insecure_skip_tls_verify
1141 .map(|value| value.to_string()),
1142 ProviderConfigField::AllowInsecureHttp => {
1143 config.allow_insecure_http.map(|value| value.to_string())
1144 }
1145 ProviderConfigField::HttpHeaders => serialize_http_headers(&config.http_headers),
1146 ProviderConfigField::PathSuffix => config.path_suffix.clone(),
1147 }
1148 }
1149
1150 fn get_provider_config_display_value(
1151 config: &ProviderConfigToml,
1152 field: ProviderConfigField,
1153 ) -> Option<String> {
1154 match field {
1155 ProviderConfigField::ApiKey => config.api_key.as_deref().map(redact_secret),
1156 ProviderConfigField::HttpHeaders => {
1157 serialize_http_headers_for_display(&config.http_headers)
1158 }
1159 _ => get_provider_config_value(config, field),
1160 }
1161 }
1162
1163 fn parse_context_window(value: &str) -> Result<u32> {
1164 let parsed = value.trim().parse::<u32>().with_context(|| {
1165 format!("invalid context_window '{value}': expected a positive token count")
1166 })?;
1167 if parsed == 0 {
1168 bail!("context_window must be greater than 0");
1169 }
1170 Ok(parsed)
1171 }
1172
1173 fn set_provider_config_value(
1174 config: &mut ConfigToml,
1175 provider: ProviderKind,
1176 field: ProviderConfigField,
1177 value: &str,
1178 ) -> Result<()> {
1179 if provider == ProviderKind::Antigravity {
1180 bail!(LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE);
1181 }
1182 match field {
1183 ProviderConfigField::Vendor => {
1184 if provider != ProviderKind::Openrouter {
1185 bail!("vendor is only supported by providers.openrouter");
1186 }
1187 validate_openrouter_vendor(value)?;
1188 config.providers.for_provider_mut(provider).vendor = Some(value.to_string());
1189 }
1190 ProviderConfigField::ApiKey => {
1191 config.providers.for_provider_mut(provider).api_key = Some(value.to_string());
1192 }
1193 ProviderConfigField::BaseUrl => {
1194 config.providers.for_provider_mut(provider).base_url = Some(value.to_string());
1195 }
1196 // Provider-scoped values stay in their `[providers.<name>]` table.
1197 // The root `http_headers` / `default_text_model` apply to every
1198 // provider, so mirroring DeepSeek's values there sent them to other
1199 // providers after a switch.
1200 ProviderConfigField::Model => {
1201 config.providers.for_provider_mut(provider).model = Some(value.to_string());
1202 }
1203 ProviderConfigField::ContextWindow => {
1204 config.providers.for_provider_mut(provider).context_window =
1205 Some(parse_context_window(value)?);
1206 }
1207 ProviderConfigField::Mode => {
1208 config.providers.for_provider_mut(provider).mode = Some(value.to_string());
1209 }
1210 ProviderConfigField::Wire => {
1211 config.providers.for_provider_mut(provider).wire = Some(value.to_string());
1212 }
1213 ProviderConfigField::AuthMode => {
1214 config.providers.for_provider_mut(provider).auth_mode = Some(value.to_string());
1215 }
1216 ProviderConfigField::InsecureSkipTlsVerify => {
1217 config
1218 .providers
1219 .for_provider_mut(provider)
1220 .insecure_skip_tls_verify = Some(parse_bool(value)?);
1221 }
1222 ProviderConfigField::AllowInsecureHttp => {
1223 config
1224 .providers
1225 .for_provider_mut(provider)
1226 .allow_insecure_http = Some(parse_bool(value)?);
1227 }
1228 ProviderConfigField::HttpHeaders => {
1229 config.providers.for_provider_mut(provider).http_headers = parse_http_headers(value)?;
1230 }
1231 ProviderConfigField::PathSuffix => {
1232 config.providers.for_provider_mut(provider).path_suffix = Some(value.to_string());
1233 }
1234 }
1235 Ok(())
1236 }
1237
1238 fn unset_provider_config_value(
1239 config: &mut ConfigToml,
1240 provider: ProviderKind,
1241 field: ProviderConfigField,
1242 ) {
1243 match field {
1244 ProviderConfigField::Vendor => {
1245 config.providers.for_provider_mut(provider).vendor = None;
1246 }
1247 ProviderConfigField::ApiKey => {
1248 config.providers.for_provider_mut(provider).api_key = None;
1249 }
1250 ProviderConfigField::BaseUrl => {
1251 config.providers.for_provider_mut(provider).base_url = None;
1252 }
1253 ProviderConfigField::Model => {
1254 let removed = config.providers.for_provider_mut(provider).model.take();
1255 // Earlier releases mirrored DeepSeek's model into the root key;
1256 // clear that copy too, but never a root value the user set apart.
1257 if provider == ProviderKind::Deepseek
1258 && removed.is_some()
1259 && config.default_text_model == removed
1260 {
1261 config.default_text_model = None;
1262 }
1263 }
1264 ProviderConfigField::ContextWindow => {
1265 config.providers.for_provider_mut(provider).context_window = None;
1266 }
1267 ProviderConfigField::Mode => {
1268 config.providers.for_provider_mut(provider).mode = None;
1269 }
1270 ProviderConfigField::Wire => {
1271 config.providers.for_provider_mut(provider).wire = None;
1272 }
1273 ProviderConfigField::AuthMode => {
1274 config.providers.for_provider_mut(provider).auth_mode = None;
1275 }
1276 ProviderConfigField::InsecureSkipTlsVerify => {
1277 config
1278 .providers
1279 .for_provider_mut(provider)
1280 .insecure_skip_tls_verify = None;
1281 }
1282 ProviderConfigField::AllowInsecureHttp => {
1283 config
1284 .providers
1285 .for_provider_mut(provider)
1286 .allow_insecure_http = None;
1287 }
1288 ProviderConfigField::HttpHeaders => {
1289 let removed =
1290 std::mem::take(&mut config.providers.for_provider_mut(provider).http_headers);
1291 // Earlier releases mirrored DeepSeek's headers into the root table;
1292 // clear that copy too, but never root headers the user set apart.
1293 if provider == ProviderKind::Deepseek
1294 && !removed.is_empty()
1295 && config.http_headers == removed
1296 {
1297 config.http_headers.clear();
1298 }
1299 }
1300 ProviderConfigField::PathSuffix => {
1301 config.providers.for_provider_mut(provider).path_suffix = None;
1302 }
1303 }
1304 }
1305
1306 fn insert_provider_config_values(
1307 out: &mut BTreeMap<String, String>,
1308 provider: ProviderKind,
1309 config: &ProviderConfigToml,
1310 ) {
1311 if let Some(v) = config.vendor.as_ref() {
1312 out.insert(
1313 provider_config_key(provider, ProviderConfigField::Vendor),
1314 v.clone(),
1315 );
1316 }
1317 if let Some(v) = config.api_key.as_ref() {
1318 out.insert(
1319 provider_config_key(provider, ProviderConfigField::ApiKey),
1320 redact_secret(v),
1321 );
1322 }
1323 if let Some(v) = config.base_url.as_ref() {
1324 out.insert(
1325 provider_config_key(provider, ProviderConfigField::BaseUrl),
1326 v.clone(),
1327 );
1328 }
1329 if let Some(v) = config.model.as_ref() {
1330 out.insert(
1331 provider_config_key(provider, ProviderConfigField::Model),
1332 v.clone(),
1333 );
1334 }
1335 if let Some(v) = config.context_window {
1336 out.insert(
1337 provider_config_key(provider, ProviderConfigField::ContextWindow),
1338 v.to_string(),
1339 );
1340 }
1341 if let Some(v) = config.mode.as_ref() {
1342 out.insert(
1343 provider_config_key(provider, ProviderConfigField::Mode),
1344 v.clone(),
1345 );
1346 }
1347 if let Some(v) = config.auth_mode.as_ref() {
1348 out.insert(
1349 provider_config_key(provider, ProviderConfigField::AuthMode),
1350 v.clone(),
1351 );
1352 }
1353 if let Some(v) = config.insecure_skip_tls_verify {
1354 out.insert(
1355 provider_config_key(provider, ProviderConfigField::InsecureSkipTlsVerify),
1356 v.to_string(),
1357 );
1358 }
1359 if let Some(v) = config.allow_insecure_http {
1360 out.insert(
1361 provider_config_key(provider, ProviderConfigField::AllowInsecureHttp),
1362 v.to_string(),
1363 );
1364 }
1365 if let Some(v) = serialize_http_headers_for_display(&config.http_headers) {
1366 out.insert(
1367 provider_config_key(provider, ProviderConfigField::HttpHeaders),
1368 v,
1369 );
1370 }
1371 if let Some(v) = config.path_suffix.as_ref() {
1372 out.insert(
1373 provider_config_key(provider, ProviderConfigField::PathSuffix),
1374 v.clone(),
1375 );
1376 }
1377 }
1378
1379 impl ConfigToml {
1380 /// Resolve durable hotbar config into normalized 1-8 slot bindings.
1381 ///
1382 /// `known_action_ids` is supplied by the TUI action registry in later
1383 /// slices. Unknown actions are preserved so the UI can render a disabled
1384 /// `?` cell instead of silently deleting user config.
1385 #[must_use]
1386 pub fn resolve_hotbar_bindings(&self, known_action_ids: &[&str]) -> HotbarConfigResolution {
1387 resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids)
1388 }
1389 }
1390
1391 /// Ordered primary-plus-fallback provider list for future provider routing.
1392 ///
1393 /// The helper is intentionally dormant: constructing or parsing a chain does
1394 /// not change [`ConfigToml::resolve_runtime_options`].
1395 #[derive(Debug, Clone, PartialEq, Eq)]
1396 pub struct ProviderChain {
1397 providers: Vec<ProviderKind>,
1398 position: usize,
1399 }
1400
1401 pub const HOTBAR_SLOT_COUNT: u8 = 8;
1402
1403 pub const DEFAULT_HOTBAR_ACTIONS: [&str; HOTBAR_SLOT_COUNT as usize] = [
1404 "slash.workflow",
1405 "slash.goal",
1406 "slash.auto",
1407 "mode.plan",
1408 "mode.agent",
1409 "mode.operate",
1410 "palette.open",
1411 "sidebar.toggle",
1412 ];
1413
1414 /// On-disk schema for one `[[hotbar]]` table.
1415 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1416 #[serde(deny_unknown_fields)]
1417 pub struct HotbarBindingToml {
1418 pub slot: u8,
1419 pub action: String,
1420 #[serde(default)]
1421 pub label: Option<String>,
1422 }
1423
1424 /// Validated hotbar binding used by future render/dispatch layers.
1425 #[derive(Debug, Clone, PartialEq, Eq)]
1426 pub struct HotbarBinding {
1427 pub slot: u8,
1428 pub action: String,
1429 pub label: Option<String>,
1430 }
1431
1432 /// Non-fatal hotbar config issue. Invalid slots are skipped; duplicate slots
1433 /// use the last binding; unknown actions are kept for UI feedback.
1434 #[derive(Debug, Clone, PartialEq, Eq)]
1435 pub enum HotbarConfigWarning {
1436 SlotOutOfRange {
1437 slot: u8,
1438 action: String,
1439 },
1440 DuplicateSlot {
1441 slot: u8,
1442 previous_action: String,
1443 replacement_action: String,
1444 },
1445 UnknownAction {
1446 slot: u8,
1447 action: String,
1448 },
1449 }
1450
1451 impl fmt::Display for HotbarConfigWarning {
1452 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1453 match self {
1454 Self::SlotOutOfRange { slot, action } => write!(
1455 f,
1456 "hotbar slot {slot} for action '{action}' is outside 1-{HOTBAR_SLOT_COUNT}; skipped"
1457 ),
1458 Self::DuplicateSlot {
1459 slot,
1460 previous_action,
1461 replacement_action,
1462 } => write!(
1463 f,
1464 "hotbar slot {slot} was bound to '{previous_action}' more than once; using '{replacement_action}'"
1465 ),
1466 Self::UnknownAction { slot, action } => write!(
1467 f,
1468 "hotbar slot {slot} references unknown action '{action}'; keeping binding"
1469 ),
1470 }
1471 }
1472 }
1473
1474 #[derive(Debug, Clone, PartialEq, Eq)]
1475 pub struct HotbarConfigResolution {
1476 pub bindings: Vec<HotbarBinding>,
1477 pub warnings: Vec<HotbarConfigWarning>,
1478 }
1479
1480 #[must_use]
1481 pub fn default_hotbar_bindings() -> Vec<HotbarBinding> {
1482 DEFAULT_HOTBAR_ACTIONS
1483 .iter()
1484 .enumerate()
1485 .map(|(idx, action)| HotbarBinding {
1486 slot: u8::try_from(idx + 1).expect("default hotbar slot fits in u8"),
1487 action: (*action).to_string(),
1488 label: None,
1489 })
1490 .collect()
1491 }
1492
1493 /// The default hotbar slots in on-disk (`[[hotbar]]`) form. Since #3807 an
1494 /// absent `hotbar` key means "hidden", so `/hotbar on` persists these explicit
1495 /// bindings rather than deleting the key. Kept in terms of
1496 /// [`default_hotbar_bindings`] so `DEFAULT_HOTBAR_ACTIONS` stays the single
1497 /// source of truth.
1498 #[must_use]
1499 pub fn default_hotbar_bindings_toml() -> Vec<HotbarBindingToml> {
1500 default_hotbar_bindings()
1501 .into_iter()
1502 .map(|binding| HotbarBindingToml {
1503 slot: binding.slot,
1504 action: binding.action,
1505 label: binding.label,
1506 })
1507 .collect()
1508 }
1509
1510 #[must_use]
1511 pub fn resolve_hotbar_bindings(
1512 configured: Option<&[HotbarBindingToml]>,
1513 known_action_ids: &[&str],
1514 ) -> HotbarConfigResolution {
1515 let known = known_action_ids.iter().copied().collect::<BTreeSet<&str>>();
1516 let mut warnings = Vec::new();
1517
1518 let source = match configured {
1519 Some(bindings) => bindings
1520 .iter()
1521 .map(|binding| HotbarBinding {
1522 slot: binding.slot,
1523 action: binding.action.clone(),
1524 label: binding.label.clone(),
1525 })
1526 .collect::<Vec<_>>(),
1527 // #3807: an absent `hotbar` key means the Hotbar is hidden until the
1528 // user opts in (via the setup wizard or `/hotbar on`). Only an explicit
1529 // `[[hotbar]]` config produces bindings. `Some([])` stays "disabled".
1530 None => Vec::new(),
1531 };
1532
1533 let mut by_slot: BTreeMap<u8, HotbarBinding> = BTreeMap::new();
1534 for binding in source {
1535 if !(1..=HOTBAR_SLOT_COUNT).contains(&binding.slot) {
1536 warnings.push(HotbarConfigWarning::SlotOutOfRange {
1537 slot: binding.slot,
1538 action: binding.action,
1539 });
1540 continue;
1541 }
1542 if !known.is_empty() && !known.contains(binding.action.as_str()) {
1543 warnings.push(HotbarConfigWarning::UnknownAction {
1544 slot: binding.slot,
1545 action: binding.action.clone(),
1546 });
1547 }
1548 if let Some(previous) = by_slot.insert(binding.slot, binding.clone()) {
1549 warnings.push(HotbarConfigWarning::DuplicateSlot {
1550 slot: binding.slot,
1551 previous_action: previous.action,
1552 replacement_action: binding.action,
1553 });
1554 }
1555 }
1556
1557 HotbarConfigResolution {
1558 bindings: by_slot.into_values().collect(),
1559 warnings,
1560 }
1561 }
1562
1563 impl ProviderChain {
1564 #[must_use]
1565 pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self {
1566 let mut providers = vec![active];
1567 for fallback in fallbacks {
1568 if *fallback != active && !providers.contains(fallback) {
1569 providers.push(*fallback);
1570 }
1571 }
1572 Self {
1573 providers,
1574 position: 0,
1575 }
1576 }
1577
1578 #[must_use]
1579 pub fn providers(&self) -> &[ProviderKind] {
1580 &self.providers
1581 }
1582
1583 #[must_use]
1584 pub fn position(&self) -> usize {
1585 self.position
1586 }
1587
1588 #[must_use]
1589 pub fn current(&self) -> ProviderKind {
1590 self.providers
1591 .get(self.position)
1592 .copied()
1593 .or_else(|| self.providers.first().copied())
1594 .unwrap_or_default()
1595 }
1596
1597 #[must_use]
1598 pub fn has_next(&self) -> bool {
1599 self.position + 1 < self.providers.len()
1600 }
1601
1602 pub fn advance(&mut self) -> Option<ProviderKind> {
1603 if !self.has_next() {
1604 return None;
1605 }
1606 self.position += 1;
1607 Some(self.current())
1608 }
1609
1610 pub fn reset(&mut self) {
1611 self.position = 0;
1612 }
1613
1614 #[must_use]
1615 pub fn is_fallback_active(&self) -> bool {
1616 self.position > 0
1617 }
1618
1619 /// Count the current provider plus untried chain entries.
1620 #[must_use]
1621 pub fn remaining(&self) -> usize {
1622 self.providers.len() - self.position
1623 }
1624 }
1625
1626 #[cfg(test)]
1627 mod provider_chain_tests {
1628 use super::*;
1629
1630 #[test]
1631 fn current_on_empty_chain_returns_default_provider() {
1632 let chain = ProviderChain {
1633 providers: vec![],
1634 position: 0,
1635 };
1636 assert_eq!(chain.current(), ProviderKind::default());
1637 }
1638 }
1639
1640 /// On-disk schema for the `[hook_sinks]` table.
1641 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
1642 pub struct HookSinksToml {
1643 /// Unix domain socket path used by the app-server event sink.
1644 ///
1645 /// When unset, no Unix socket sink is registered. There is deliberately no
1646 /// shared `/tmp` default because socket ownership should be explicit.
1647 #[serde(default)]
1648 pub unix_socket_path: Option<PathBuf>,
1649 }
1650
1651 /// On-disk schema for the `[lifecycle_outbox]` table.
1652 ///
1653 /// Opt-in lifecycle event outbox: every emitted event is appended as one
1654 /// JSONL line to `path` in the `RuntimeEventEnvelope` shape
1655 /// (`schema_version, seq, event, kind, thread_id, turn_id, item_id,
1656 /// timestamp, payload`), and optionally POSTed to `webhook_url`. An unset or
1657 /// empty `path` disables the feature entirely — behavior is unchanged from a
1658 /// release without the table.
1659 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
1660 pub struct LifecycleOutboxToml {
1661 /// Path to the JSONL outbox file. Parent directories are created lazily
1662 /// on the first event. Unset or empty = feature OFF.
1663 #[serde(default)]
1664 pub path: Option<PathBuf>,
1665 /// Optional webhook URL. Events are POSTed as `{"at", "event"}` JSON
1666 /// only when this is set (in addition to, never instead of, `path`).
1667 /// Delivery is best-effort: failures are logged and dropped.
1668 #[serde(default)]
1669 pub webhook_url: Option<String>,
1670 /// Optional bearer token sent as `Authorization: Bearer <token>` on
1671 /// webhook POSTs. Ignored when `webhook_url` is unset.
1672 #[serde(default)]
1673 pub webhook_token: Option<String>,
1674 }
1675
1676 /// On-disk schema for the `[control_socket]` table.
1677 ///
1678 /// Opt-in per-session control surface: when `enabled`, the interactive TUI
1679 /// binds a unix domain socket at `<sessions-dir>/<session-id>/control.sock`
1680 /// for the running session. The socket speaks newline-framed JSON-RPC with
1681 /// the verbs `message`, `interrupt`, and `status`. An absent
1682 /// table, or `enabled = false` (the default), disables the feature entirely —
1683 /// behavior is unchanged from a release without the table.
1684 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
1685 pub struct ControlSocketToml {
1686 /// Bind the per-session control socket. Default: false (OFF).
1687 #[serde(default)]
1688 pub enabled: bool,
1689 }
1690
1691 /// On-disk schema for the `[skills]` table (#140). See `config.example.toml`
1692 /// for documentation.
1693 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
1694 pub struct SkillsToml {
1695 /// Curated registry index URL. When unset, the TUI falls back to the
1696 /// bundled default (community-curated GitHub raw).
1697 #[serde(default)]
1698 pub registry_url: Option<String>,
1699 /// Per-skill maximum *uncompressed* size in bytes. When unset, the TUI
1700 /// uses 5 MiB.
1701 #[serde(default)]
1702 pub max_install_size_bytes: Option<u64>,
1703 /// Keys owned by the TUI runtime (for example `scan_codewhale_only`) or
1704 /// added by newer releases must survive dispatcher reads and typed saves.
1705 #[serde(flatten)]
1706 pub extras: BTreeMap<String, toml::Value>,
1707 }
1708
1709 /// On-disk schema for the `[tools]` table (#2076).
1710 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
1711 pub struct ToolsToml {
1712 /// Native tool names to keep loaded outside the default core catalog.
1713 #[serde(default)]
1714 pub always_load: Vec<String>,
1715 /// Runtime-owned tool settings must survive dispatcher reads and saves.
1716 /// Their validation belongs to the runtime's ToolsConfig, not this facade.
1717 #[serde(flatten)]
1718 pub extras: BTreeMap<String, toml::Value>,
1719 }
1720
1721 /// On-disk schema for the `[snapshots]` table (#137). See
1722 /// `config.example.toml` for documentation.
1723 #[derive(Debug, Clone, Serialize, Deserialize)]
1724 pub struct SnapshotsToml {
1725 #[serde(default = "default_snapshots_enabled")]
1726 pub enabled: bool,
1727 #[serde(default = "default_snapshot_max_age_days")]
1728 pub max_age_days: u64,
1729 /// Keys owned by the TUI runtime (for example `max_workspace_gb`) or
1730 /// added by newer releases must survive dispatcher reads and typed saves.
1731 #[serde(flatten)]
1732 pub extras: BTreeMap<String, toml::Value>,
1733 }
1734
1735 fn default_snapshots_enabled() -> bool {
1736 true
1737 }
1738
1739 fn default_snapshot_max_age_days() -> u64 {
1740 7
1741 }
1742
1743 impl Default for SnapshotsToml {
1744 fn default() -> Self {
1745 Self {
1746 enabled: default_snapshots_enabled(),
1747 max_age_days: default_snapshot_max_age_days(),
1748 extras: BTreeMap::new(),
1749 }
1750 }
1751 }
1752
1753 /// On-disk schema for the `[fleet]` table (#3165). See `config.example.toml`
1754 /// and `docs/FLEET.md` for documentation.
1755 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
1756 pub struct FleetConfigToml {
1757 /// User-defined and built-in role presets.
1758 ///
1759 /// Each role defines default tool profiles, capabilities, and execution
1760 /// requests that task specs can reference by name. Built-in roles
1761 /// (`smoke-runner`, `reviewer`, `builder`, `read-only`) are always
1762 /// available; user-defined roles in config override or extend them.
1763 #[serde(default)]
1764 pub roles: BTreeMap<String, FleetRolePreset>,
1765 /// Fleet profile vocabulary (#3167). Profiles group role semantics,
1766 /// loadout hints, route identity, and delegation bounds. Runtime authority
1767 /// is intentionally not a profile property.
1768 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1769 pub profiles: BTreeMap<String, FleetProfile>,
1770 /// Headless worker execution hardening (#3027).
1771 #[serde(default)]
1772 pub exec: FleetExecConfig,
1773 }
1774
1775 /// Canonical recursion-depth policy for the headless worker runtime.
1776 ///
1777 /// Single source of truth shared by BOTH standalone sub-agents and fleet
1778 /// workers so the two cannot drift into "two moving targets":
1779 /// - [`DEFAULT_SPAWN_DEPTH`] is the default recursion budget (the sub-agent
1780 /// runtime's `DEFAULT_MAX_SPAWN_DEPTH` is defined as this value).
1781 /// - [`MAX_SPAWN_DEPTH_CEILING`] is the opt-in safety cap; every configured
1782 /// value (fleet `max_spawn_depth`, the `agent` tool's `max_depth`) clamps to it.
1783 ///
1784 /// A worker runs at `spawn_depth = 0` and may spawn while
1785 /// `spawn_depth + 1 <= max_spawn_depth`, so a depth of N affords N nested
1786 /// delegation levels below the root worker. The default of 3 affords at least
1787 /// three recursion levels out of the box; the root worker still runs at
1788 /// depth 0 even when the budget is 0.
1789 pub const DEFAULT_SPAWN_DEPTH: u32 = 3;
1790 pub const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 900;
1791 pub const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1;
1792 pub const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600;
1793
1794 /// Hard ceiling on recursion depth for any worker/sub-agent. The default stays
1795 /// conservative at [`DEFAULT_SPAWN_DEPTH`], while explicit config can opt into
1796 /// deeper trees for direct-API providers that can tolerate the fanout.
1797 /// Raising this single constant lifts the limit everywhere (the fleet clamp
1798 /// and `agent` validation both read it).
1799 pub const MAX_SPAWN_DEPTH_CEILING: u32 = 8;
1800
1801 /// Headless worker execution constraints (#3027).
1802 ///
1803 /// These limits apply to all fleet workers and sub-agents spawned through
1804 /// the headless worker runtime. Task specs can tighten but not loosen them.
1805 #[derive(Debug, Clone, Serialize, Deserialize)]
1806 pub struct FleetExecConfig {
1807 /// Tools that are always allowed regardless of role or task spec.
1808 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1809 pub allowed_tools: Vec<String>,
1810 /// Tools that are always disallowed, overriding role and task spec.
1811 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1812 pub disallowed_tools: Vec<String>,
1813 /// Optional hard ceiling on sub-agent steps (tool calls + model turns).
1814 /// Zero keeps the normal agent loop unbounded; a positive value terminates
1815 /// workers that exceed the explicit operator cap.
1816 #[serde(default = "default_fleet_max_turns")]
1817 pub max_turns: u32,
1818 /// Recursive child-agent budget for headless fleet workers.
1819 /// Defaults to [`DEFAULT_SPAWN_DEPTH`] (3) so a fleet worker has the SAME
1820 /// recursion budget as a standalone sub-agent — fleet and sub-agents are one
1821 /// substrate, not two. Set 0 to block child `agent` calls (the root worker
1822 /// still runs); the value is clamped to [`MAX_SPAWN_DEPTH_CEILING`].
1823 #[serde(default = "default_fleet_max_spawn_depth")]
1824 pub max_spawn_depth: u32,
1825 /// Extra system prompt text appended to every headless worker.
1826 /// Useful for injecting org-wide policy or behavior constraints.
1827 #[serde(default, skip_serializing_if = "String::is_empty")]
1828 pub append_system_prompt: String,
1829 /// Output format for fleet worker results.
1830 /// `"text"` (default) or `"stream-json"` for newline-delimited JSON events.
1831 #[serde(default = "default_fleet_output_format")]
1832 pub output_format: String,
1833 }
1834
1835 /// Fleet workers run until the model finishes unless an operator supplies a
1836 /// positive `max_turns` value. Individual task budgets may still opt into a
1837 /// narrower explicit model-turn cap through `budget.max_steps`; tool-call
1838 /// admission is enforced independently through `budget.max_tool_calls`.
1839 pub const FLEET_DEFAULT_MAX_TURNS: u32 = 0;
1840
1841 fn default_fleet_max_turns() -> u32 {
1842 FLEET_DEFAULT_MAX_TURNS
1843 }
1844
1845 fn default_fleet_max_spawn_depth() -> u32 {
1846 DEFAULT_SPAWN_DEPTH
1847 }
1848
1849 fn default_fleet_output_format() -> String {
1850 "text".to_string()
1851 }
1852
1853 impl Default for FleetExecConfig {
1854 fn default() -> Self {
1855 Self {
1856 allowed_tools: Vec::new(),
1857 disallowed_tools: Vec::new(),
1858 max_turns: default_fleet_max_turns(),
1859 max_spawn_depth: default_fleet_max_spawn_depth(),
1860 append_system_prompt: String::new(),
1861 output_format: default_fleet_output_format(),
1862 }
1863 }
1864 }
1865
1866 /// Fleet org-chart profile.
1867 ///
1868 /// A profile is an additive config record for future fleet scheduling policy.
1869 /// Loading one must not grant runtime permissions by itself: shell and trust
1870 /// escalation default off, and approvals default on.
1871 #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1872 pub struct FleetProfile {
1873 /// Org-chart slot this profile describes.
1874 #[serde(default)]
1875 pub slot: FleetSlot,
1876 /// Semantic role name and optional instruction overlay.
1877 #[serde(default)]
1878 pub role: FleetRole,
1879 /// Model class / route-role hint. This is data only in this slice.
1880 #[serde(default)]
1881 pub loadout: FleetLoadout,
1882 /// Optional explicit model id for this profile on the active/resolved route.
1883 ///
1884 /// This is not an auth or endpoint selector. Provider-scoped routing still
1885 /// validates the executable provider/model/wire-model decision.
1886 #[serde(default, skip_serializing_if = "Option::is_none")]
1887 pub model: Option<String>,
1888 /// Optional explicit provider id for this profile's model (#4093).
1889 ///
1890 /// Present only when the profile was created against a specific,
1891 /// credential-checked provider (e.g. via the Fleet setup model picker),
1892 /// so a worker can be pinned to a route independent of the parent/current
1893 /// session provider. `None` means "no route pin" (inherit), matching
1894 /// `model: None`; a profile must never carry `provider` without `model`.
1895 ///
1896 /// EPIC #2608 explicit-config-only mandate: this field is the ONLY
1897 /// authority for the profile's provider. It is never inferred by sniffing
1898 /// a substring/prefix out of `model` — callers that need the provider for
1899 /// this profile must read this field, not guess from the model id.
1900 #[serde(default, skip_serializing_if = "Option::is_none")]
1901 pub provider: Option<String>,
1902 /// Optional explicit reasoning/thinking tier for this profile (#4137).
1903 ///
1904 /// This is a safe, non-secret route tuning value. `None` means inherit the
1905 /// operator/session reasoning tier. Concrete values are normalized by the
1906 /// TUI loader before they are used at runtime.
1907 #[serde(default, skip_serializing_if = "Option::is_none")]
1908 pub reasoning_effort: Option<String>,
1909 /// Legacy ignored input retained for old Fleet-profile files. Runtime
1910 /// authority is derived after identity selection and is never persisted in
1911 /// this profile.
1912 #[doc(hidden)]
1913 #[serde(default, skip_serializing)]
1914 pub permissions: FleetProfilePermissions,
1915 /// Delegation hints for future manager policy.
1916 #[serde(default)]
1917 pub delegation: FleetDelegationHints,
1918 }
1919
1920 /// Semantic role declaration for a fleet profile.
1921 ///
1922 /// TOML may use either `role = "reviewer"` or a role table with `name` and
1923 /// `instructions`.
1924 #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
1925 pub struct FleetRole {
1926 /// Stable role name, e.g. `scout`, `implementer`, or `verifier`.
1927 pub name: String,
1928 /// Optional short description for config UIs and docs.
1929 #[serde(default, skip_serializing_if = "Option::is_none")]
1930 pub description: Option<String>,
1931 /// Optional instruction overlay to apply when the role is later consumed.
1932 #[serde(default, skip_serializing_if = "Option::is_none")]
1933 pub instructions: Option<String>,
1934 }
1935
1936 impl Default for FleetRole {
1937 fn default() -> Self {
1938 Self {
1939 name: "general".to_string(),
1940 description: None,
1941 instructions: None,
1942 }
1943 }
1944 }
1945
1946 impl<'de> Deserialize<'de> for FleetRole {
1947 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1948 where
1949 D: serde::Deserializer<'de>,
1950 {
1951 #[derive(Deserialize)]
1952 #[serde(untagged)]
1953 enum FleetRoleWire {
1954 Name(String),
1955 Full {
1956 #[serde(default)]
1957 name: Option<String>,
1958 #[serde(default)]
1959 description: Option<String>,
1960 #[serde(default)]
1961 instructions: Option<String>,
1962 },
1963 }
1964
1965 match FleetRoleWire::deserialize(deserializer)? {
1966 FleetRoleWire::Name(name) => Ok(Self {
1967 name,
1968 ..Self::default()
1969 }),
1970 FleetRoleWire::Full {
1971 name,
1972 description,
1973 instructions,
1974 } => Ok(Self {
1975 name: name.unwrap_or_else(|| Self::default().name),
1976 description,
1977 instructions,
1978 }),
1979 }
1980 }
1981 }
1982
1983 /// Org-chart slot for grouping fleet profiles.
1984 #[derive(Debug, Clone, PartialEq, Eq, Default)]
1985 pub enum FleetSlot {
1986 Manager,
1987 Scout,
1988 Planner,
1989 Implementer,
1990 Reviewer,
1991 Verifier,
1992 Operator,
1993 Summarizer,
1994 #[default]
1995 General,
1996 Custom(String),
1997 }
1998
1999 impl FleetSlot {
2000 #[must_use]
2001 pub fn as_str(&self) -> &str {
2002 match self {
2003 Self::Manager => "manager",
2004 Self::Scout => "scout",
2005 Self::Planner => "planner",
2006 Self::Implementer => "implementer",
2007 Self::Reviewer => "reviewer",
2008 Self::Verifier => "verifier",
2009 Self::Operator => "operator",
2010 Self::Summarizer => "summarizer",
2011 Self::General => "general",
2012 Self::Custom(value) => value.as_str(),
2013 }
2014 }
2015
2016 #[must_use]
2017 pub fn from_name(value: &str) -> Self {
2018 match value.trim() {
2019 "manager" | "coordinator" => Self::Manager,
2020 "scout" | "research" | "research-worker" => Self::Scout,
2021 "planner" | "plan" | "awaiter" => Self::Planner,
2022 "implementer" | "builder" => Self::Implementer,
2023 "reviewer" => Self::Reviewer,
2024 "verifier" | "tester" => Self::Verifier,
2025 "operator" | "incident" | "incident-worker" => Self::Operator,
2026 "summarizer" | "reducer" => Self::Summarizer,
2027 "general" | "" => Self::General,
2028 // Removed slots (e.g. the old "tool-heavy") and unknown names parse
2029 // as Custom. Note the runtime side no longer treats an undeclared
2030 // role as write-capable: it fails closed to the read-only `explore`
2031 // posture (#5575), so this is narrower than the removed variants.
2032 other => Self::Custom(other.to_string()),
2033 }
2034 }
2035 }
2036
2037 impl Serialize for FleetSlot {
2038 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2039 where
2040 S: serde::Serializer,
2041 {
2042 serializer.serialize_str(self.as_str())
2043 }
2044 }
2045
2046 impl<'de> Deserialize<'de> for FleetSlot {
2047 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
2048 where
2049 D: serde::Deserializer<'de>,
2050 {
2051 let value = String::deserialize(deserializer)?;
2052 Ok(Self::from_name(&value))
2053 }
2054 }
2055
2056 /// Model class or route-role hint for a profile.
2057 #[derive(Debug, Clone, PartialEq, Eq, Default)]
2058 pub enum FleetLoadout {
2059 /// Reuse the active session route (the operator's model). Default.
2060 #[default]
2061 Inherit,
2062 /// Route to the provider's faster/cheaper model class for wide fan-out.
2063 Fast,
2064 /// Unrecognized loadout names parse here (including the retired
2065 /// strong/balanced/deep-reasoning/code/review/tool-heavy tiers, which
2066 /// never routed differently). Treated as auto routing.
2067 Custom(String),
2068 }
2069
2070 impl FleetLoadout {
2071 #[must_use]
2072 pub fn as_str(&self) -> &str {
2073 match self {
2074 Self::Inherit => "inherit",
2075 Self::Fast => "fast",
2076 Self::Custom(value) => value.as_str(),
2077 }
2078 }
2079
2080 #[must_use]
2081 pub fn from_name(value: &str) -> Self {
2082 match value.trim() {
2083 "inherit" | "default" | "auto" | "" => Self::Inherit,
2084 "fast" => Self::Fast,
2085 // Retired tiers (strong/balanced/deep-reasoning/code/review/
2086 // tool-heavy) and unknown names parse as Custom → auto routing,
2087 // exactly what those tiers resolved to before removal.
2088 other => Self::Custom(other.to_string()),
2089 }
2090 }
2091 }
2092
2093 impl Serialize for FleetLoadout {
2094 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2095 where
2096 S: serde::Serializer,
2097 {
2098 serializer.serialize_str(self.as_str())
2099 }
2100 }
2101
2102 impl<'de> Deserialize<'de> for FleetLoadout {
2103 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
2104 where
2105 D: serde::Deserializer<'de>,
2106 {
2107 let value = String::deserialize(deserializer)?;
2108 Ok(Self::from_name(&value))
2109 }
2110 }
2111
2112 /// Legacy Fleet-profile permission payload retained only for source and input
2113 /// compatibility. Runtime ignores it; Fleet identity cannot grant authority.
2114 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2115 pub struct FleetProfilePermissions {
2116 #[doc(hidden)]
2117 #[serde(default, skip_serializing)]
2118 pub allow_shell: bool,
2119 #[doc(hidden)]
2120 #[serde(default, skip_serializing)]
2121 pub trust: bool,
2122 #[doc(hidden)]
2123 #[serde(default = "default_fleet_profile_approval_required", skip_serializing)]
2124 pub approval_required: bool,
2125 }
2126
2127 fn default_fleet_profile_approval_required() -> bool {
2128 true
2129 }
2130
2131 impl Default for FleetProfilePermissions {
2132 fn default() -> Self {
2133 Self {
2134 allow_shell: false,
2135 trust: false,
2136 approval_required: true,
2137 }
2138 }
2139 }
2140
2141 /// Delegation hints for future fleet manager scheduling.
2142 #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
2143 pub struct FleetDelegationHints {
2144 /// Optional profile-level child spawn depth. `None` means inherit existing
2145 /// fleet/sub-agent config.
2146 #[serde(default, skip_serializing_if = "Option::is_none")]
2147 pub max_spawn_depth: Option<u32>,
2148 /// Optional profile-level worker concurrency hint.
2149 #[serde(
2150 default,
2151 alias = "concurrency",
2152 skip_serializing_if = "Option::is_none"
2153 )]
2154 pub max_concurrency: Option<usize>,
2155 }
2156
2157 /// A named role preset that bundles common worker settings.
2158 ///
2159 /// Task specs reference a role name (e.g. `"role": "reviewer"`), and the
2160 /// fleet manager fills in any missing fields from the preset. User-defined
2161 /// roles in `[fleet.roles]` override built-in defaults with the same name.
2162 ///
2163 /// Token budgets and tool-call limits are task-level decisions — they don't
2164 /// belong on role presets. Use `timeout_seconds` as the safety bound.
2165 #[derive(Debug, Clone, Serialize, Deserialize)]
2166 pub struct FleetRolePreset {
2167 /// Short description of what this role is for.
2168 #[serde(skip_serializing_if = "Option::is_none")]
2169 pub description: Option<String>,
2170 /// Default tool profile (`"read-only"`, `"read-write"`, or `"custom"`).
2171 #[serde(skip_serializing_if = "Option::is_none")]
2172 pub tool_profile: Option<String>,
2173 /// Default set of tool names available to this role.
2174 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2175 pub tools: Vec<String>,
2176 /// Default capability tags (e.g. `"rust"`, `"git"`, `"gh"`).
2177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2178 pub capabilities: Vec<String>,
2179 /// Default timeout in seconds for tasks using this role.
2180 #[serde(skip_serializing_if = "Option::is_none")]
2181 pub timeout_seconds: Option<u64>,
2182 }
2183
2184 impl FleetConfigToml {
2185 /// Resolve a role preset by name. Checks user-defined roles first,
2186 /// then falls back to built-in role defaults.
2187 #[must_use]
2188 pub fn resolve_role(&self, name: &str) -> Option<FleetRolePreset> {
2189 self.roles
2190 .get(name)
2191 .cloned()
2192 .or_else(|| built_in_role_presets().get(name).cloned())
2193 }
2194 }
2195
2196 /// On-disk schema for the `[workflow]` table (#4128 / Section 2.11).
2197 ///
2198 /// Automatic Workflow launch, write/approval gates, child/isolation budgets,
2199 /// and completed-activity persistence all read from this one model. When the
2200 /// table is absent, consumers resolve [`WorkflowConfigToml::default`].
2201 /// See `config.example.toml` for documentation.
2202 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2203 pub struct WorkflowConfigToml {
2204 /// Allow the parent agent to auto-launch Workflow for multi-agent work.
2205 /// Product default is on; set `false` to require explicit `/workflow`.
2206 #[serde(default = "default_workflow_automatic")]
2207 pub automatic: bool,
2208 /// When automatic launch is enabled, start read-only child plans without
2209 /// an approval card. Write/shell/network plans still consult
2210 /// [`Self::require_approval_for_writes`].
2211 #[serde(default = "default_workflow_auto_start_read_only")]
2212 pub auto_start_read_only: bool,
2213 /// Require an operator approval card before launching plans that write,
2214 /// elevate shell/network, or otherwise leave the read-only envelope.
2215 #[serde(default = "default_workflow_require_approval_for_writes")]
2216 pub require_approval_for_writes: bool,
2217 /// Hard ceiling on total children in one Workflow run (product: 1000).
2218 #[serde(default = "default_workflow_max_children")]
2219 pub max_children: u32,
2220 /// Maximum concurrently live agents inside one Workflow run (product: 16).
2221 #[serde(default = "default_workflow_max_concurrent")]
2222 pub max_concurrent: u32,
2223 /// Maximum structural nesting depth accepted for Workflow IR.
2224 ///
2225 /// This is independent of Runtime child delegation, whose default is 3
2226 /// and whose opt-in hard ceiling is 8.
2227 #[serde(default = "default_workflow_max_depth")]
2228 pub max_depth: u32,
2229 /// Default shared token budget for a Workflow run and its children.
2230 /// `0` applies no shared cap — the run is advisory-only like the parent
2231 /// turn loop — while any positive value is enforced across the run.
2232 #[serde(default = "default_workflow_default_token_budget")]
2233 pub default_token_budget: u64,
2234 }
2235
2236 fn default_workflow_automatic() -> bool {
2237 true
2238 }
2239
2240 fn default_workflow_auto_start_read_only() -> bool {
2241 true
2242 }
2243
2244 fn default_workflow_require_approval_for_writes() -> bool {
2245 true
2246 }
2247
2248 fn default_workflow_max_children() -> u32 {
2249 1000
2250 }
2251
2252 fn default_workflow_max_concurrent() -> u32 {
2253 16
2254 }
2255
2256 fn default_workflow_max_depth() -> u32 {
2257 5
2258 }
2259
2260 fn default_workflow_default_token_budget() -> u64 {
2261 // Off by default: a cap the caller never asked for must not throttle a
2262 // run — a 120k default silently killed real fan-outs mid-task (#6189).
2263 // Spend discipline stays available as an explicit opt-in (tool
2264 // `token_budget`, spec `budget.max_tokens`, or a configured value here).
2265 0
2266 }
2267
2268 impl Default for WorkflowConfigToml {
2269 fn default() -> Self {
2270 Self {
2271 automatic: default_workflow_automatic(),
2272 auto_start_read_only: default_workflow_auto_start_read_only(),
2273 require_approval_for_writes: default_workflow_require_approval_for_writes(),
2274 max_children: default_workflow_max_children(),
2275 max_concurrent: default_workflow_max_concurrent(),
2276 max_depth: default_workflow_max_depth(),
2277 default_token_budget: default_workflow_default_token_budget(),
2278 }
2279 }
2280 }
2281
2282 /// Built-in role presets that are always available without config.
2283 #[must_use]
2284 pub fn built_in_role_presets() -> BTreeMap<String, FleetRolePreset> {
2285 [
2286 (
2287 "smoke-runner".to_string(),
2288 FleetRolePreset {
2289 description: Some("Lightweight read-only smoke check worker".to_string()),
2290 tool_profile: Some("read-only".to_string()),
2291 tools: vec![],
2292 capabilities: vec![],
2293 timeout_seconds: Some(300),
2294 },
2295 ),
2296 (
2297 "reviewer".to_string(),
2298 FleetRolePreset {
2299 description: Some("Read-only code and documentation review".to_string()),
2300 tool_profile: Some("read-only".to_string()),
2301 tools: vec![],
2302 capabilities: vec![],
2303 timeout_seconds: Some(600),
2304 },
2305 ),
2306 (
2307 "builder".to_string(),
2308 FleetRolePreset {
2309 description: Some(
2310 "Read-write builder with compilation and test access".to_string(),
2311 ),
2312 tool_profile: Some("read-write".to_string()),
2313 tools: vec![],
2314 capabilities: vec![],
2315 timeout_seconds: Some(1800),
2316 },
2317 ),
2318 (
2319 "read-only".to_string(),
2320 FleetRolePreset {
2321 description: Some(
2322 "Minimal read-only observer with no writes or secrets".to_string(),
2323 ),
2324 tool_profile: Some("read-only".to_string()),
2325 tools: vec![],
2326 capabilities: vec![],
2327 timeout_seconds: Some(300),
2328 },
2329 ),
2330 ]
2331 .into()
2332 }
2333
2334 /// On-disk schema for `[verifier]`.
2335 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
2336 pub struct VerifierConfigToml {
2337 /// Enable automatic verifier preview when the runtime wires a
2338 /// claim-of-done trigger. Manual `run_verifiers` remains available
2339 /// regardless.
2340 #[serde(default)]
2341 pub enabled: bool,
2342 }
2343
2344 /// On-disk schema for `[advisor]` (#3982).
2345 ///
2346 /// Advisor mode is **off by default**. When enabled, the engine spawns a
2347 /// short-lived background reviewer after each turn that contained tool calls.
2348 /// The reviewer reads a bounded slice of recent tool calls, makes a concise
2349 /// LLM advisory call, and emits the note as an `AdvisoryNote` event without
2350 /// blocking the parent turn.
2351 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2352 pub struct AdvisorConfigToml {
2353 /// Master on/off switch. `false` by default — no background reviewer is
2354 /// spawned until the user opts in via `[advisor] enabled = true` or
2355 /// `/advisor on`.
2356 #[serde(default)]
2357 pub enabled: bool,
2358 /// Maximum number of recent tool-call/result pairs to include in each
2359 /// advisory review. Keeps the reviewer's context window bounded regardless
2360 /// of turn length. Defaults to 10; clamped to 1–50.
2361 #[serde(default = "advisor_default_max_tool_calls")]
2362 pub max_tool_calls: u32,
2363 /// Minimum wall-clock seconds between two consecutive advisor emissions.
2364 /// Prevents noise on rapid multi-turn sequences. Defaults to 60 seconds;
2365 /// clamped to 5–3600.
2366 #[serde(default = "advisor_default_rate_limit_secs")]
2367 pub rate_limit_secs: u64,
2368 /// Deduplication window in seconds. An advisory note whose content hash
2369 /// matches the previous note within this window is silently dropped.
2370 /// Defaults to 300 seconds (5 minutes).
2371 #[serde(default = "advisor_default_dedup_window_secs")]
2372 pub dedup_window_secs: u64,
2373 /// Optional model override for the advisor LLM call. When absent, the
2374 /// advisor reuses the session's current model.
2375 #[serde(default)]
2376 pub model: Option<String>,
2377 }
2378
2379 fn advisor_default_max_tool_calls() -> u32 {
2380 10
2381 }
2382 fn advisor_default_rate_limit_secs() -> u64 {
2383 60
2384 }
2385 fn advisor_default_dedup_window_secs() -> u64 {
2386 300
2387 }
2388
2389 impl Default for AdvisorConfigToml {
2390 fn default() -> Self {
2391 Self {
2392 enabled: false,
2393 max_tool_calls: advisor_default_max_tool_calls(),
2394 rate_limit_secs: advisor_default_rate_limit_secs(),
2395 dedup_window_secs: advisor_default_dedup_window_secs(),
2396 model: None,
2397 }
2398 }
2399 }
2400
2401 /// On-disk schema for the `[network]` table (#135). See `config.example.toml`
2402 /// for documentation.
2403 #[derive(Debug, Clone, Serialize, Deserialize)]
2404 pub struct NetworkPolicyToml {
2405 /// Decision for hosts that are not in `allow` or `deny`. One of
2406 /// `"allow" | "deny" | "prompt"`. Defaults to `"prompt"`.
2407 #[serde(default = "default_network_decision")]
2408 pub default: String,
2409 /// Hosts that are always allowed. Subdomain rules: a leading dot
2410 /// (`.example.com`) matches subdomains but not the apex.
2411 #[serde(default)]
2412 pub allow: Vec<String>,
2413 /// Hosts that are always denied. Deny entries win over allow entries.
2414 #[serde(default)]
2415 pub deny: Vec<String>,
2416 /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an
2417 /// explicitly trusted proxy setup. Literal IP URLs remain blocked.
2418 #[serde(default)]
2419 pub proxy: Vec<String>,
2420 /// Explicit fake-IP placeholder CIDRs for those proxy hosts. The runtime
2421 /// accepts only subnets contained by `198.18.0.0/15`.
2422 #[serde(default)]
2423 pub proxy_fake_ip_cidrs: Vec<String>,
2424 /// Whether to record one audit-log line per outbound network call.
2425 #[serde(default = "default_network_audit")]
2426 pub audit: bool,
2427 /// Keys owned by the TUI runtime or added by newer releases must survive
2428 /// dispatcher reads and typed saves.
2429 #[serde(flatten)]
2430 pub extras: BTreeMap<String, toml::Value>,
2431 }
2432
2433 fn default_network_decision() -> String {
2434 "prompt".to_string()
2435 }
2436
2437 fn default_network_audit() -> bool {
2438 true
2439 }
2440
2441 impl Default for NetworkPolicyToml {
2442 fn default() -> Self {
2443 Self {
2444 default: default_network_decision(),
2445 allow: Vec::new(),
2446 deny: Vec::new(),
2447 proxy: Vec::new(),
2448 proxy_fake_ip_cidrs: Vec::new(),
2449 audit: default_network_audit(),
2450 extras: BTreeMap::new(),
2451 }
2452 }
2453 }
2454
2455 /// User-defined LSP server for one file extension (used inside
2456 /// [`LspConfigToml::custom`]).
2457 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2458 pub struct CustomLspDef {
2459 /// LSP `languageId` value used in `textDocument/didOpen`.
2460 pub language_id: String,
2461 /// Executable to spawn.
2462 pub command: String,
2463 /// Arguments passed to the executable.
2464 #[serde(default)]
2465 pub args: Vec<String>,
2466 }
2467
2468 /// On-disk schema for the `[lsp]` table (#136). See `config.example.toml`
2469 /// for documentation. All fields are optional so the TUI runtime can fall
2470 /// back to its own defaults when keys are absent.
2471 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
2472 pub struct LspConfigToml {
2473 /// Master switch.
2474 pub enabled: Option<bool>,
2475 /// Maximum time to wait for diagnostics after an edit, in milliseconds.
2476 pub poll_after_edit_ms: Option<u64>,
2477 /// Cap on diagnostics surfaced per file.
2478 pub max_diagnostics_per_file: Option<usize>,
2479 /// When `true`, warnings (severity 2) are surfaced in addition to errors.
2480 pub include_warnings: Option<bool>,
2481 /// Optional override for the `language -> [cmd, ...args]` table.
2482 pub servers: Option<BTreeMap<String, Vec<String>>>,
2483 /// User-defined LSP servers for file extensions not in the built-in
2484 /// registry. Keyed by extension (e.g. `"php"`, `"rb"`).
2485 pub custom: Option<BTreeMap<String, CustomLspDef>>,
2486 /// Keys owned by the TUI runtime or added by newer releases must survive
2487 /// dispatcher reads and typed saves.
2488 #[serde(flatten)]
2489 pub extras: BTreeMap<String, toml::Value>,
2490 }
2491
2492 impl ConfigToml {
2493 /// Exact configured provider id, including a dynamically named custom
2494 /// provider selected by the TUI.
2495 #[must_use]
2496 pub fn provider_id(&self) -> &str {
2497 self.selected_provider_id
2498 .as_deref()
2499 .filter(|id| {
2500 if self.provider == ProviderKind::Custom {
2501 self.providers.extras.contains_key(*id)
2502 } else {
2503 ProviderKind::parse_config_identity(id) == Some(self.provider)
2504 && self.providers.extras.get(*id).is_none_or(|value| {
2505 value
2506 .as_table()
2507 .is_some_and(|table| !table.contains_key("kind"))
2508 })
2509 }
2510 })
2511 .unwrap_or_else(|| self.provider.as_str())
2512 }
2513
2514 /// The real key behind a legacy top-level `api_key` / `base_url`.
2515 ///
2516 /// Those keys no longer exist at the top level (#6394); `config get|set|
2517 /// unset base_url` addresses the active provider's own table instead, so
2518 /// the value lands where the route reads it.
2519 #[must_use]
2520 pub fn root_alias_key(&self, key: &str) -> Option<String> {
2521 let field = match key {
2522 "api_key" | "apiKey" => "api_key",
2523 "base_url" | "baseUrl" => "base_url",
2524 _ => return None,
2525 };
2526 let table = self
2527 .named_custom_provider_id()
2528 .unwrap_or_else(|| self.provider.provider().provider_config_key());
2529 Some(format!("providers.{table}.{field}"))
2530 }
2531
2532 /// Return the exact id only when the root selection names a dynamic custom
2533 /// provider rather than the legacy literal `custom` route.
2534 #[must_use]
2535 pub fn named_custom_provider_id(&self) -> Option<&str> {
2536 (self.provider == ProviderKind::Custom)
2537 .then_some(self.selected_provider_id.as_deref())
2538 .flatten()
2539 .filter(|id| self.providers.extras.contains_key(*id))
2540 }
2541
2542 fn named_custom_provider_table(&self, provider_id: &str) -> Result<&toml::value::Table> {
2543 let table = self
2544 .providers
2545 .extras
2546 .get(provider_id)
2547 .and_then(toml::Value::as_table)
2548 .with_context(|| {
2549 format!(
2550 "custom provider '{provider_id}' requires a matching [providers.{provider_id}] table"
2551 )
2552 })?;
2553 let compatible = table
2554 .get("kind")
2555 .and_then(toml::Value::as_str)
2556 .is_some_and(|kind| {
2557 kind.trim()
2558 .to_ascii_lowercase()
2559 .replace('_', "-")
2560 .eq("openai-compatible")
2561 });
2562 if !compatible {
2563 bail!(
2564 "custom provider '{provider_id}' must set [providers.{provider_id}].kind = \"openai-compatible\""
2565 );
2566 }
2567 Ok(table)
2568 }
2569
2570 /// The typed `[providers.<id>]` table of a named custom provider. A table
2571 /// that fails validation is an error, never a reason to read another one.
2572 fn named_custom_provider_config_for(&self, provider_id: &str) -> Result<ProviderConfigToml> {
2573 let table = self.named_custom_provider_table(provider_id)?;
2574 // The deserializer error can quote the offending value, which may be a
2575 // credential, so report only which table is wrong.
2576 toml::Value::Table(table.clone()).try_into().map_err(|_| {
2577 anyhow::anyhow!(
2578 "custom provider '{provider_id}' has an invalid [providers.{provider_id}] table: a field has the wrong type"
2579 )
2580 })
2581 }
2582
2583 /// Mutable access to a custom provider's `[providers.<id>]` table,
2584 /// creating it on the first `config set providers.<id>.<field>`.
2585 fn custom_provider_table_mut(&mut self, provider_id: &str) -> Result<&mut toml::value::Table> {
2586 let entry = self
2587 .providers
2588 .extras
2589 .entry(provider_id.to_string())
2590 .or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
2591 entry.as_table_mut().with_context(|| {
2592 format!("custom provider '{provider_id}' must be a [providers.{provider_id}] table")
2593 })
2594 }
2595
2596 /// Write one leg of a custom provider table. Named custom providers are
2597 /// not in [`ProviderKind::ALL`], so without this path
2598 /// `config set providers.<custom>.<field>` fell through to a literal
2599 /// top-level extras key and silently never took effect (#5167).
2600 fn set_custom_provider_value(
2601 &mut self,
2602 provider_id: &str,
2603 field_key: &str,
2604 value: &str,
2605 ) -> Result<()> {
2606 if is_builtin_provider_config_id(provider_id) {
2607 bail!(
2608 "unknown field '{field_key}' for built-in provider '{provider_id}': \
2609 expected one of api_key, base_url, model, context_window, mode, auth_mode, \
2610 insecure_skip_tls_verify, http_headers, path_suffix"
2611 );
2612 }
2613 if field_key == "kind" {
2614 let compatible =
2615 value.trim().to_ascii_lowercase().replace('_', "-") == "openai-compatible";
2616 if !compatible {
2617 bail!(
2618 "custom provider '{provider_id}' must set [providers.{provider_id}].kind = \"openai-compatible\""
2619 );
2620 }
2621 self.custom_provider_table_mut(provider_id)?.insert(
2622 "kind".to_string(),
2623 toml::Value::String(value.trim().to_string()),
2624 );
2625 return Ok(());
2626 }
2627 let Some(field) = ProviderConfigField::parse(field_key) else {
2628 bail!(
2629 "unknown field '{field_key}' for custom provider '{provider_id}': \
2630 expected one of {CUSTOM_PROVIDER_FIELD_HINT}"
2631 );
2632 };
2633 let toml_value = match field {
2634 ProviderConfigField::Vendor => {
2635 bail!("vendor is only supported by providers.openrouter")
2636 }
2637 ProviderConfigField::ApiKey
2638 | ProviderConfigField::BaseUrl
2639 | ProviderConfigField::Model
2640 | ProviderConfigField::Mode
2641 | ProviderConfigField::Wire
2642 | ProviderConfigField::AuthMode
2643 | ProviderConfigField::PathSuffix => toml::Value::String(value.to_string()),
2644 ProviderConfigField::ContextWindow => {
2645 toml::Value::Integer(i64::from(parse_context_window(value)?))
2646 }
2647 ProviderConfigField::InsecureSkipTlsVerify | ProviderConfigField::AllowInsecureHttp => {
2648 toml::Value::Boolean(parse_bool(value)?)
2649 }
2650 ProviderConfigField::HttpHeaders => toml::Value::Table(
2651 parse_http_headers(value)?
2652 .into_iter()
2653 .map(|(name, header)| (name, toml::Value::String(header)))
2654 .collect(),
2655 ),
2656 };
2657 self.custom_provider_table_mut(provider_id)?
2658 .insert(field.key().to_string(), toml_value);
2659 Ok(())
2660 }
2661
2662 fn get_custom_provider_value_with(
2663 &self,
2664 provider_id: &str,
2665 field_key: &str,
2666 render: fn(&ProviderConfigToml, ProviderConfigField) -> Option<String>,
2667 ) -> Option<String> {
2668 let table = self.providers.extras.get(provider_id)?.as_table()?;
2669 if field_key == "kind" {
2670 return table.get("kind")?.as_str().map(str::to_string);
2671 }
2672 let field = ProviderConfigField::parse(field_key)?;
2673 let config: ProviderConfigToml = toml::Value::Table(table.clone()).try_into().ok()?;
2674 render(&config, field)
2675 }
2676
2677 fn unset_custom_provider_value(&mut self, provider_id: &str, field_key: &str) {
2678 let Some(table) = self
2679 .providers
2680 .extras
2681 .get_mut(provider_id)
2682 .and_then(toml::Value::as_table_mut)
2683 else {
2684 return;
2685 };
2686 let leg = if field_key == "kind" {
2687 "kind"
2688 } else {
2689 ProviderConfigField::parse(field_key).map_or(field_key, |field| field.key())
2690 };
2691 table.remove(leg);
2692 }
2693
2694 /// Write one `[providers.<id>.model_context_windows]` entry (#6108),
2695 /// whether `<id>` is a built-in provider key or a named custom table.
2696 fn set_model_context_window(
2697 &mut self,
2698 provider_id: &str,
2699 model: &str,
2700 value: &str,
2701 ) -> Result<()> {
2702 if model.eq_ignore_ascii_case("auto")
2703 || model.chars().any(|c| c.is_whitespace() || c.is_control())
2704 {
2705 bail!("invalid model id for `model_context_windows`");
2706 }
2707 let window = parse_context_window(value)?;
2708 if let Some(kind) = builtin_provider_kind_for_config_id(provider_id) {
2709 self.providers
2710 .for_provider_mut(kind)
2711 .model_context_windows
2712 .insert(model.to_string(), window);
2713 return Ok(());
2714 }
2715 let table = self.custom_provider_table_mut(provider_id)?;
2716 let windows = table
2717 .entry("model_context_windows".to_string())
2718 .or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
2719 windows
2720 .as_table_mut()
2721 .with_context(|| {
2722 format!("providers.{provider_id}.model_context_windows must be a table")
2723 })?
2724 .insert(model.to_string(), toml::Value::Integer(i64::from(window)));
2725 Ok(())
2726 }
2727
2728 fn model_context_window_value(&self, provider_id: &str, model: &str) -> Option<String> {
2729 if let Some(kind) = builtin_provider_kind_for_config_id(provider_id) {
2730 return self
2731 .providers
2732 .for_provider(kind)
2733 .model_context_windows
2734 .get(model)
2735 .map(u32::to_string);
2736 }
2737 self.providers
2738 .extras
2739 .get(provider_id)?
2740 .as_table()?
2741 .get("model_context_windows")?
2742 .as_table()?
2743 .get(model)?
2744 .as_integer()
2745 .map(|value| value.to_string())
2746 }
2747
2748 fn unset_model_context_window(&mut self, provider_id: &str, model: &str) {
2749 if let Some(kind) = builtin_provider_kind_for_config_id(provider_id) {
2750 self.providers
2751 .for_provider_mut(kind)
2752 .model_context_windows
2753 .remove(model);
2754 return;
2755 }
2756 if let Some(table) = self
2757 .providers
2758 .extras
2759 .get_mut(provider_id)
2760 .and_then(toml::Value::as_table_mut)
2761 && let Some(windows) = table
2762 .get_mut("model_context_windows")
2763 .and_then(toml::Value::as_table_mut)
2764 {
2765 windows.remove(model);
2766 if windows.is_empty() {
2767 table.remove("model_context_windows");
2768 }
2769 }
2770 }
2771
2772 /// Bind the raw selector after deserializing a document. Exact custom
2773 /// tables take precedence over built-in aliases, and regional spellings
2774 /// survive later typed saves. This does not apply environment overrides.
2775 pub fn bind_persisted_provider_id(&mut self, provider_id: &str) -> Result<()> {
2776 let provider_id = provider_id.trim();
2777 // Earlier typed saves wrote the serde kebab-case spelling
2778 // `siliconflow-c-n` instead of the canonical `siliconflow-CN`. Read it
2779 // back as the built-in so those files load and the next save repairs
2780 // them, unless the user really declared a table by that name.
2781 let provider_id = if provider_id.eq_ignore_ascii_case("siliconflow-c-n")
2782 && !self.providers.extras.contains_key(provider_id)
2783 {
2784 ProviderKind::SiliconflowCN.as_str()
2785 } else {
2786 provider_id
2787 };
2788 let parsed = ProviderKind::parse_config_identity(provider_id);
2789 // Kindless tables mirroring a built-in alias remain inert. An explicit
2790 // custom declaration must validate; never fall back to a different
2791 // credential/endpoint authority when its kind is invalid.
2792 let custom_table = self.providers.extras.get(provider_id);
2793 let kindless_alias = parsed.is_some()
2794 && custom_table
2795 .and_then(toml::Value::as_table)
2796 .is_some_and(|table| !table.contains_key("kind"));
2797 let provider = if parsed != Some(ProviderKind::Antigravity)
2798 && custom_table.is_some()
2799 && !kindless_alias
2800 {
2801 self.named_custom_provider_config_for(provider_id)?;
2802 ProviderKind::Custom
2803 } else if let Some(provider) = parsed {
2804 provider
2805 } else {
2806 self.named_custom_provider_config_for(provider_id)?;
2807 ProviderKind::Custom
2808 };
2809 self.provider = provider;
2810 self.selected_provider_id = (provider_id != provider.as_str()
2811 && (provider != ProviderKind::Custom
2812 || self.providers.extras.contains_key(provider_id)))
2813 .then(|| provider_id.to_string());
2814 Ok(())
2815 }
2816
2817 #[must_use]
2818 pub fn get_value(&self, key: &str) -> Option<String> {
2819 if let Some(alias) = self.root_alias_key(key) {
2820 return self.get_value(&alias);
2821 }
2822 if notifications::in_namespace(key) {
2823 let setting = notifications::NotificationSetting::parse(key)?;
2824 return Some(
2825 notifications::from_extras(&self.extras)
2826 .ok()?
2827 .display(setting),
2828 );
2829 }
2830 if let Some((provider_id, model)) = parse_model_context_window_key(key) {
2831 return self.model_context_window_value(provider_id, model);
2832 }
2833 if let Some((provider, field)) = parse_provider_config_key(key) {
2834 return get_provider_config_value(self.providers.for_provider(provider), field);
2835 }
2836 if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
2837 return self.get_custom_provider_value_with(
2838 provider_id,
2839 field_key,
2840 get_provider_config_value,
2841 );
2842 }
2843
2844 match key {
2845 "provider" => Some(self.provider_id().to_string()),
2846 "stream_chunk_timeout_secs" | "tui.stream_chunk_timeout_secs" => {
2847 Some(self.stream_chunk_timeout_secs().to_string())
2848 }
2849 "http_headers" => serialize_http_headers(&self.http_headers),
2850 "default_text_model" => self.default_text_model.clone(),
2851 "model" => self.model.clone(),
2852 "auth.mode" => self.auth_mode.clone(),
2853 "verbosity" => self.verbosity.clone(),
2854 "log_level" => self.log_level.clone(),
2855 "telemetry" => self.telemetry.map(|v| v.to_string()),
2856 "telemetry_endpoint" => self.telemetry_endpoint.clone(),
2857 "approval_policy" => self.approval_policy.clone(),
2858 "sandbox_mode" => self.sandbox_mode.clone(),
2859 "tools.always_load" => self.tools.as_ref().map(|tools| tools.always_load.join(",")),
2860 "hook_sinks.unix_socket_path" => self
2861 .hook_sinks
2862 .as_ref()
2863 .and_then(|sinks| sinks.unix_socket_path.as_ref())
2864 .map(|path| path.display().to_string()),
2865 _ => self
2866 .extras
2867 .get(key)
2868 .map(toml::Value::to_string)
2869 .or_else(|| {
2870 let document = toml::Value::try_from(self).ok()?;
2871 config_value_at_path(&document, key).map(toml::Value::to_string)
2872 }),
2873 }
2874 }
2875
2876 /// The unquoted contents of an extras key that holds a TOML string.
2877 ///
2878 /// [`ConfigToml::get_value`] renders extras through `toml::Value::to_string`,
2879 /// which re-applies TOML quoting — and switches to a single-quoted literal
2880 /// string whenever the payload contains a `"`. A JSON blob written with
2881 /// [`ConfigToml::set_value`] therefore comes back as `'[{"a":1}]'` and no
2882 /// longer parses as JSON (#4727). Callers that stored structured text want
2883 /// the payload, not its TOML rendering.
2884 #[must_use]
2885 pub fn get_raw_string(&self, key: &str) -> Option<&str> {
2886 self.extras.get(key).and_then(toml::Value::as_str)
2887 }
2888
2889 #[must_use]
2890 pub fn get_display_value(&self, key: &str) -> Option<String> {
2891 if let Some(alias) = self.root_alias_key(key) {
2892 return self.get_display_value(&alias);
2893 }
2894 if notifications::in_namespace(key) {
2895 return self.get_value(key);
2896 }
2897 if let Some((provider_id, model)) = parse_model_context_window_key(key) {
2898 return self.model_context_window_value(provider_id, model);
2899 }
2900 if let Some((provider, field)) = parse_provider_config_key(key) {
2901 return get_provider_config_display_value(self.providers.for_provider(provider), field);
2902 }
2903 if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
2904 return self.get_custom_provider_value_with(
2905 provider_id,
2906 field_key,
2907 get_provider_config_display_value,
2908 );
2909 }
2910
2911 if key == "telemetry" {
2912 // Report the resolved configuration preference even when the key is
2913 // absent. The telemetry owner also preserves recorded opt-outs
2914 // and fails closed when existing privacy state is unreadable.
2915 let (on, source) = resolved_telemetry_consent(self.telemetry);
2916 return Some(format!(
2917 "{} ({})",
2918 if on { "on" } else { "off" },
2919 source.as_str()
2920 ));
2921 }
2922
2923 if key == "http_headers" {
2924 return serialize_http_headers_for_display(&self.http_headers);
2925 }
2926
2927 if let Some(value) = self.extras.get(key) {
2928 return Some(redact_toml_value_for_display(key, value));
2929 }
2930
2931 // Table and nested lookups use the same recursively redacted tree as
2932 // `config dump`; a parent such as `credentials` must keep its children
2933 // secret even when the requested leaf itself has an innocuous name.
2934 let document = self.redacted_toml_value();
2935 if let Some(value) = config_value_at_path(&document, key)
2936 && (value.is_table() || value.is_array() || key.contains('.'))
2937 && !matches!(key, "tui.stream_chunk_timeout_secs")
2938 {
2939 return Some(
2940 value
2941 .as_str()
2942 .map(str::to_string)
2943 .unwrap_or_else(|| value.to_string()),
2944 );
2945 }
2946
2947 self.get_value(key).map(|value| {
2948 if is_sensitive_config_key(key) {
2949 redact_secret(&value)
2950 } else {
2951 value
2952 }
2953 })
2954 }
2955
2956 #[must_use]
2957 pub fn stream_chunk_timeout_secs(&self) -> u64 {
2958 let raw = self
2959 .extras
2960 .get("tui")
2961 .and_then(toml::Value::as_table)
2962 .and_then(|table| table.get("stream_chunk_timeout_secs"))
2963 .and_then(toml_value_as_u64)
2964 .or_else(|| {
2965 self.extras
2966 .get("tui.stream_chunk_timeout_secs")
2967 .and_then(toml_value_as_u64)
2968 })
2969 .or_else(|| {
2970 self.extras
2971 .get("stream_chunk_timeout_secs")
2972 .and_then(toml_value_as_u64)
2973 })
2974 .unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS);
2975 if raw == 0 {
2976 DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
2977 } else {
2978 raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS)
2979 }
2980 }
2981
2982 pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
2983 if let Some(alias) = self.root_alias_key(key) {
2984 return self.set_value(&alias, value);
2985 }
2986 check_config_toml_choice(key, value)?;
2987 if let Some(field) = key.strip_prefix("stream.") {
2988 let def = setting(key).with_context(|| format!("unknown stream setting `{key}`"))?;
2989 let stored = schema_toml_value(key, def, value)?;
2990 if let Some(number) = stored.as_integer() {
2991 // Retry counts are u32 in the runtime reader; other values
2992 // are u64. Reject invalid types before touching the document.
2993 let max = if matches!(
2994 field,
2995 "max_resumes" | "max_transparent_retries" | "max_stream_errors"
2996 ) {
2997 i64::from(u32::MAX)
2998 } else {
2999 i64::MAX
3000 };
3001 anyhow::ensure!(
3002 (0..=max).contains(&number),
3003 "invalid unsigned value for `{key}`"
3004 );
3005 }
3006 self.extras
3007 .entry("stream".to_string())
3008 .or_insert_with(|| toml::Value::Table(toml::Table::new()))
3009 .as_table_mut()
3010 .context("stream must be a TOML table")?
3011 .insert(field.to_string(), stored);
3012 return Ok(());
3013 }
3014 if notifications::in_namespace(key) {
3015 let setting = notifications::NotificationSetting::required(key)?;
3016 let update = notifications::NotificationConfigUpdate::parse(setting, value)?;
3017 return notifications::edit_extras(&mut self.extras, setting, update.value()?);
3018 }
3019 if parse_custom_provider_config_key(key).is_some_and(|(provider_id, _)| {
3020 ProviderKind::parse_config_identity(provider_id) == Some(ProviderKind::Antigravity)
3021 }) {
3022 bail!(LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE);
3023 }
3024 if let Some((provider_id, model)) = parse_model_context_window_key(key) {
3025 return self.set_model_context_window(provider_id, model, value);
3026 }
3027 if let Some((provider, field)) = parse_provider_config_key(key) {
3028 return set_provider_config_value(self, provider, field, value);
3029 }
3030 if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
3031 return self.set_custom_provider_value(provider_id, field_key, value);
3032 }
3033
3034 match key {
3035 "provider" => {
3036 if ProviderKind::parse_config_identity(value) == Some(ProviderKind::Antigravity) {
3037 bail!(LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE);
3038 }
3039 self.bind_persisted_provider_id(value).with_context(|| {
3040 format!(
3041 "unknown provider '{value}': expected {} or a configured custom provider",
3042 ProviderKind::names_hint()
3043 )
3044 })?;
3045 }
3046 "http_headers" => self.http_headers = parse_http_headers(value)?,
3047 "default_text_model" => self.default_text_model = Some(value.to_string()),
3048 "model" => self.model = Some(value.to_string()),
3049 "auth.mode" => self.auth_mode = Some(value.to_string()),
3050 "verbosity" => self.verbosity = Some(value.to_string()),
3051 "log_level" => self.log_level = Some(value.to_string()),
3052 "telemetry" => {
3053 self.telemetry = Some(parse_bool(value)?);
3054 }
3055 // Scheme rules (HTTPS, or loopback HTTP) are enforced where a
3056 // batch would actually be sent, not here: a user must be able to
3057 // stage a value before the machinery that reads it exists.
3058 "telemetry_endpoint" => self.telemetry_endpoint = Some(value.to_string()),
3059 "approval_policy" => self.approval_policy = Some(value.to_string()),
3060 "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
3061 // The TUI reader (`ReasoningEffort::parse_strict`) owns this
3062 // vocabulary and accepts aliases the schema's option list does
3063 // not name (`none`, `mid`, `maximum`, ...), so the schema check
3064 // below would refuse values that take effect. `codewhale config
3065 // set` validates against that reader before calling here.
3066 "reasoning_effort" => {
3067 self.extras
3068 .insert(key.to_string(), toml::Value::String(value.to_string()));
3069 }
3070 "hook_sinks.unix_socket_path" => {
3071 self.hook_sinks
3072 .get_or_insert_with(HookSinksToml::default)
3073 .unix_socket_path = Some(PathBuf::from(value));
3074 }
3075 // The MCP stdio dispatcher persists this established literal key
3076 // as JSON text; it is not a nested TOML setting.
3077 _ if key.contains('.') && key != "mcp.server_definitions" => {
3078 let (table, field) = key.rsplit_once('.').expect("dotted key");
3079 bail!(
3080 "`config set` does not support nested key `{key}`; edit `{field}` in the [{table}] table of config.toml instead (use a TOML value of the documented type). No value was changed."
3081 );
3082 }
3083 _ => {
3084 // A declared setting keeps its declared type (#6563): refuse a
3085 // value the schema cannot hold, and store booleans and numbers
3086 // as TOML values so typed readers deserialize them. Whether an
3087 // undeclared key is read by anything is the caller's question;
3088 // `codewhale config set` refuses those before reaching here.
3089 let stored = match setting(key) {
3090 Some(def) => schema_toml_value(key, def, value)?,
3091 None => toml::Value::String(value.to_string()),
3092 };
3093 self.extras.insert(key.to_string(), stored);
3094 }
3095 }
3096 Ok(())
3097 }
3098
3099 pub fn unset_value(&mut self, key: &str) -> Result<()> {
3100 if let Some(alias) = self.root_alias_key(key) {
3101 return self.unset_value(&alias);
3102 }
3103 if let Some(field) = key.strip_prefix("stream.") {
3104 anyhow::ensure!(setting(key).is_some(), "unknown stream setting `{key}`");
3105 if let Some(stream) = self.extras.get_mut("stream") {
3106 stream
3107 .as_table_mut()
3108 .context("stream must be a TOML table")?
3109 .remove(field);
3110 }
3111 return Ok(());
3112 }
3113 if notifications::in_namespace(key) {
3114 let setting = notifications::NotificationSetting::required(key)?;
3115 return notifications::edit_extras(&mut self.extras, setting, None);
3116 }
3117 if let Some((provider_id, model)) = parse_model_context_window_key(key) {
3118 self.unset_model_context_window(provider_id, model);
3119 return Ok(());
3120 }
3121 if let Some((provider, field)) = parse_provider_config_key(key) {
3122 unset_provider_config_value(self, provider, field);
3123 return Ok(());
3124 }
3125 if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
3126 self.unset_custom_provider_value(provider_id, field_key);
3127 return Ok(());
3128 }
3129
3130 match key {
3131 "provider" => {
3132 self.provider = ProviderKind::Deepseek;
3133 self.selected_provider_id = None;
3134 }
3135 "http_headers" => self.http_headers.clear(),
3136 "default_text_model" => self.default_text_model = None,
3137 "model" => self.model = None,
3138 "auth.mode" => self.auth_mode = None,
3139 "verbosity" => self.verbosity = None,
3140 "log_level" => self.log_level = None,
3141 "telemetry" => self.telemetry = None,
3142 "telemetry_endpoint" => self.telemetry_endpoint = None,
3143 "approval_policy" => self.approval_policy = None,
3144 "sandbox_mode" => self.sandbox_mode = None,
3145 "hook_sinks.unix_socket_path" => {
3146 if let Some(sinks) = self.hook_sinks.as_mut() {
3147 sinks.unix_socket_path = None;
3148 }
3149 }
3150 _ => {
3151 self.extras.remove(key);
3152 }
3153 }
3154 Ok(())
3155 }
3156
3157 #[must_use]
3158 pub fn list_values(&self) -> BTreeMap<String, String> {
3159 let mut out = BTreeMap::new();
3160 out.insert("provider".to_string(), self.provider_id().to_string());
3161
3162 if let Some(v) = serialize_http_headers_for_display(&self.http_headers) {
3163 out.insert("http_headers".to_string(), v);
3164 }
3165 if let Some(v) = self.default_text_model.as_ref() {
3166 out.insert("default_text_model".to_string(), v.clone());
3167 }
3168 if let Some(v) = self.model.as_ref() {
3169 out.insert("model".to_string(), v.clone());
3170 }
3171 if let Some(v) = self.auth_mode.as_ref() {
3172 out.insert("auth.mode".to_string(), v.clone());
3173 }
3174 if let Some(v) = self.verbosity.as_ref() {
3175 out.insert("verbosity".to_string(), v.clone());
3176 }
3177 if let Some(v) = self.log_level.as_ref() {
3178 out.insert("log_level".to_string(), v.clone());
3179 }
3180 if let Some(v) = self.telemetry {
3181 out.insert("telemetry".to_string(), v.to_string());
3182 }
3183 if let Some(v) = self.telemetry_endpoint.as_ref() {
3184 out.insert("telemetry_endpoint".to_string(), v.clone());
3185 }
3186 if let Some(v) = self.approval_policy.as_ref() {
3187 out.insert("approval_policy".to_string(), v.clone());
3188 }
3189 if let Some(v) = self.sandbox_mode.as_ref() {
3190 out.insert("sandbox_mode".to_string(), v.clone());
3191 }
3192 if let Some(v) = self
3193 .hook_sinks
3194 .as_ref()
3195 .and_then(|sinks| sinks.unix_socket_path.as_ref())
3196 {
3197 out.insert(
3198 "hook_sinks.unix_socket_path".to_string(),
3199 v.display().to_string(),
3200 );
3201 }
3202
3203 for provider in provider::all_providers().iter().map(|p| p.kind()) {
3204 insert_provider_config_values(
3205 &mut out,
3206 provider,
3207 self.providers.for_provider(provider),
3208 );
3209 }
3210
3211 for (k, v) in &self.extras {
3212 if k == "notifications" {
3213 if let Ok(config) = notifications::from_extras(&self.extras) {
3214 for setting in notifications::NotificationSetting::ALL {
3215 out.insert(
3216 format!("notifications.{}", setting.key()),
3217 config.display(setting),
3218 );
3219 }
3220 } else {
3221 out.insert(k.clone(), "<invalid notification configuration>".into());
3222 }
3223 } else if notifications::in_namespace(k)
3224 && notifications::NotificationSetting::parse(k).is_some()
3225 {
3226 if let Ok(config) = notifications::from_extras(&self.extras) {
3227 let setting = notifications::NotificationSetting::parse(k)
3228 .expect("known notification key");
3229 out.insert(
3230 format!("notifications.{}", setting.key()),
3231 config.display(setting),
3232 );
3233 }
3234 } else {
3235 out.insert(k.clone(), redact_toml_value_for_display(k, v));
3236 }
3237 }
3238 out
3239 }
3240
3241 /// Resolve runtime options without touching platform credential stores.
3242 ///
3243 /// This method keeps library callers prompt-free: CLI flag → config file
3244 /// → environment. Call `resolve_runtime_options_with_secrets` when a
3245 /// user-facing dispatcher should recover credentials from the configured
3246 /// secret store.
3247 #[must_use]
3248 pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
3249 let no_keyring = Secrets::new(std::sync::Arc::new(
3250 codewhale_secrets::InMemoryKeyringStore::new(),
3251 ));
3252 self.resolve_runtime_options_with_secrets(cli, &no_keyring)
3253 }
3254
3255 /// Resolve runtime options using an explicit secrets façade.
3256 ///
3257 /// API-key precedence is **CLI flag → config-file → secret store → environment**.
3258 #[must_use]
3259 pub fn resolve_runtime_options_with_secrets(
3260 &self,
3261 cli: &CliRuntimeOverrides,
3262 secrets: &Secrets,
3263 ) -> ResolvedRuntimeOptions {
3264 let env = EnvRuntimeOverrides::load();
3265 let (provider, provider_source) = if let Some(provider) = cli.provider {
3266 (provider, ProviderSource::Cli)
3267 } else if let Some(provider) = env.provider {
3268 (
3269 provider,
3270 ProviderSource::Env(env.provider_source.unwrap_or("CODEWHALE_PROVIDER")),
3271 )
3272 } else {
3273 (self.provider, ProviderSource::Config)
3274 };
3275
3276 let named_custom_provider = (provider == ProviderKind::Custom
3277 && matches!(provider_source, ProviderSource::Config))
3278 .then(|| self.named_custom_provider_id())
3279 .flatten();
3280 let mut provider_cfg = if let Some(provider_id) = named_custom_provider {
3281 // A named route whose table became invalid after binding resolves
3282 // to the fail-closed loopback placeholder, never to the legacy
3283 // `[providers.custom]` endpoint, model and key.
3284 self.named_custom_provider_config_for(provider_id)
3285 .unwrap_or_else(|err| {
3286 tracing::warn!("{err:#}");
3287 ProviderConfigToml::default()
3288 })
3289 } else {
3290 self.providers.for_provider(provider).clone()
3291 };
3292 if provider == ProviderKind::SiliconflowCN {
3293 let fb = &self.providers.siliconflow;
3294 if provider_cfg.api_key.is_none() {
3295 provider_cfg.api_key = fb.api_key.clone();
3296 }
3297 if provider_cfg.base_url.is_none() {
3298 provider_cfg.base_url = fb.base_url.clone();
3299 }
3300 if provider_cfg.model.is_none() {
3301 provider_cfg.model = fb.model.clone();
3302 }
3303 }
3304 let auth_mode = cli
3305 .auth_mode
3306 .clone()
3307 .or_else(|| env.auth_mode.clone())
3308 .or_else(|| provider_cfg.auth_mode.clone())
3309 .or_else(|| self.auth_mode.clone());
3310 // The legacy top-level key and endpoint were moved into their owning
3311 // `[providers.<name>]` table when the file was parsed (#6394).
3312 let from_file = provider_cfg.api_key.clone();
3313 let cli_base_url = cli.base_url.clone();
3314 let env_base_url = env.base_url_for(provider);
3315 let file_base_url = provider_cfg.base_url.clone();
3316 let base_url_from_file =
3317 cli_base_url.is_none() && env_base_url.is_none() && file_base_url.is_some();
3318 let configured_base_url = cli_base_url.or(env_base_url).or(file_base_url);
3319 let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo {
3320 env.xiaomi_mimo_mode
3321 .clone()
3322 .or_else(|| provider_cfg.mode.clone())
3323 } else {
3324 None
3325 };
3326 let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo {
3327 xiaomi_mimo_env_api_key_for_runtime(
3328 xiaomi_mimo_mode.as_deref(),
3329 configured_base_url.as_deref(),
3330 )
3331 } else {
3332 None
3333 };
3334 let explicit_api_key_for_endpoint = cli
3335 .api_key
3336 .as_deref()
3337 .or(from_file.as_deref().filter(|value| {
3338 classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal
3339 }))
3340 .or(xiaomi_mimo_env_api_key.as_deref());
3341 let provider_wire = provider_cfg.wire.as_deref();
3342 let base_url = if provider == ProviderKind::XiaomiMimo {
3343 resolve_xiaomi_mimo_base_url(
3344 configured_base_url,
3345 explicit_api_key_for_endpoint,
3346 xiaomi_mimo_mode.as_deref(),
3347 )
3348 } else if is_modelstudio_family(provider) {
3349 resolve_modelstudio_base_url(
3350 configured_base_url,
3351 provider,
3352 provider_cfg.mode.as_deref(),
3353 provider_wire,
3354 )
3355 } else if matches!(
3356 provider,
3357 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic
3358 ) {
3359 resolve_minimax_base_url(configured_base_url, provider, provider_wire)
3360 } else if matches!(
3361 provider,
3362 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
3363 ) {
3364 resolve_deepseek_base_url(configured_base_url, provider, provider_wire)
3365 } else {
3366 configured_base_url
3367 .unwrap_or_else(|| descriptor_fallback_base_url(provider, auth_mode.as_deref()))
3368 };
3369 // Released builds represented Ollama Cloud as the local `ollama`
3370 // identity plus one exact hosted base URL. Upgrade only that tuple in
3371 // memory: the parsed config and secret store are never rewritten, and
3372 // neighboring/custom routes retain the local/custom identity.
3373 let legacy_ollama_cloud = provider::migrates_legacy_ollama_cloud_route(provider, &base_url);
3374 let provider = if legacy_ollama_cloud {
3375 ProviderKind::OllamaCloud
3376 } else {
3377 provider
3378 };
3379 // `auth_mode = "none"` is an endpoint contract, so it suppresses every
3380 // credential source (including explicit CLI/config values). Otherwise
3381 // CLI and route-local config win outright. Ambient provider credentials
3382 // are allowed only on the provider's official endpoint family: a saved
3383 // OpenRouter key must never follow `provider = "openrouter"` to an
3384 // unrelated custom gateway merely because the provider id stayed the
3385 // same.
3386 let uses_kimi_imported_token = provider == ProviderKind::Moonshot
3387 && auth_mode
3388 .as_deref()
3389 .is_some_and(auth_mode_uses_kimi_imported_token);
3390 let auth_disabled = auth_mode_disables_api_key(auth_mode.as_deref());
3391 let custom_endpoint = provider_preserves_custom_base_url_model(provider, &base_url);
3392 let (api_key, api_key_source) = if auth_disabled {
3393 (None, None)
3394 } else if let Some(value) = cli.api_key.clone() {
3395 (Some(value), Some(RuntimeApiKeySource::Cli))
3396 } else if uses_kimi_imported_token && !custom_endpoint {
3397 (None, None)
3398 } else if (!custom_endpoint || base_url_from_file)
3399 && let Some(value) = from_file.clone().filter(|value| {
3400 classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal
3401 })
3402 {
3403 (Some(value), Some(RuntimeApiKeySource::ConfigFile))
3404 } else if !custom_endpoint
3405 && let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty())
3406 {
3407 (Some(value), Some(RuntimeApiKeySource::Env))
3408 } else if custom_endpoint {
3409 (None, None)
3410 } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
3411 match env_api_key_for_provider(provider) {
3412 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
3413 None => (None, None),
3414 }
3415 } else {
3416 match stored_api_key_for_provider(secrets, provider, legacy_ollama_cloud) {
3417 Some((value, source)) => {
3418 let source = match source {
3419 SecretSource::Keyring => RuntimeApiKeySource::Keyring,
3420 SecretSource::Env => RuntimeApiKeySource::Env,
3421 };
3422 (Some(value), Some(source))
3423 }
3424 None => match env_api_key_for_provider(provider) {
3425 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
3426 None => (None, None),
3427 },
3428 }
3429 };
3430
3431 let env_provider_model = env.model_for(provider, &base_url);
3432 // Root `default_text_model` is the key `codewhale model set` writes and
3433 // the setup wizard writes, for every provider. It used to enter this
3434 // chain only when `provider == Deepseek`, which made this resolver
3435 // disagree with `Config::default_model()` in the TUI — the chain that
3436 // actually builds the request — for every non-DeepSeek provider
3437 // (#4832, #4838). The user's model still shipped; only this resolver,
3438 // and therefore `codewhale model resolve`, reported a provider default.
3439 //
3440 // It is honoured for any provider now, minus the one case the DeepSeek
3441 // gate was accidentally covering: a stale DeepSeek id left behind by a
3442 // provider switch must not be forwarded to an endpoint that cannot
3443 // serve it.
3444 let root_default_model = self
3445 .default_text_model
3446 .clone()
3447 .filter(|model| !root_default_model_is_foreign_to_provider(provider, model, &base_url));
3448 // Derived from the same chain as `model` below so the reported
3449 // provenance cannot drift from the id that is actually used.
3450 let model_source = if cli.model.is_some() {
3451 ModelSource::Cli
3452 } else if env.model.is_some() || env_provider_model.is_some() {
3453 ModelSource::Env
3454 } else if provider_cfg.model.is_some() {
3455 ModelSource::ProviderConfig
3456 } else if root_default_model.is_some() {
3457 ModelSource::RootDefaultTextModel
3458 } else if self.model.is_some() {
3459 ModelSource::RootModel
3460 } else {
3461 ModelSource::ProviderDefault
3462 };
3463 let explicit_model = model_source.is_explicit();
3464 let model = cli
3465 .model
3466 .clone()
3467 .or_else(|| env.model.clone())
3468 .or(env_provider_model)
3469 .or_else(|| provider_cfg.model.clone())
3470 .or(root_default_model)
3471 .or_else(|| self.model.clone())
3472 .unwrap_or_else(|| {
3473 if provider == ProviderKind::Moonshot
3474 && (auth_mode
3475 .as_deref()
3476 .is_some_and(auth_mode_uses_kimi_imported_token)
3477 || moonshot_base_url_uses_kimi_code(&base_url))
3478 {
3479 DEFAULT_KIMI_CODE_MODEL.to_string()
3480 } else {
3481 cloud_facts::cloud_default_model_for_route(provider, &base_url)
3482 .map(|(model, _)| model)
3483 .unwrap_or_else(|| default_model_for_provider(provider).to_string())
3484 }
3485 });
3486 let model = if provider == ProviderKind::OpencodeGo {
3487 // OpenCode Go's `/models` response also contains models that only
3488 // speak Anthropic Messages. This provider is deliberately bound to
3489 // Chat Completions, so even custom endpoint/env overrides cannot
3490 // promote an incompatible id onto `/chat/completions`.
3491 normalize_model_for_provider(provider, &model)
3492 } else if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
3493 model.trim().to_string()
3494 } else {
3495 normalize_model_for_provider(provider, &model)
3496 };
3497
3498 // RouteResolver is the runtime path: the executable wire model,
3499 // protocol, and endpoint come from a ReadyRouteCandidate. Auth/key
3500 // resolution above is unchanged. A resolver error keeps the existing
3501 // model string so this method stays total, and keeps the error itself
3502 // so no caller can present the rejected model as a resolved route.
3503 let route = crate::route::RouteResolver::new().resolve(&crate::route::RouteRequest {
3504 explicit_provider: Some(provider),
3505 model_selector: Some(crate::route::LogicalModelRef::from(model.as_str())),
3506 saved_provider_model: None,
3507 base_url_override: Some(base_url.clone()),
3508 limit_overrides: Vec::new(),
3509 });
3510
3511 let mut http_headers = self.http_headers.clone();
3512 http_headers.extend(provider_cfg.http_headers.clone());
3513 if let Some(env_headers) = env.http_headers {
3514 http_headers.extend(env_headers);
3515 }
3516 http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
3517 if auth_disabled {
3518 http_headers.retain(|name, _| !is_upstream_auth_header(name));
3519 }
3520
3521 let log_level = cli
3522 .log_level
3523 .clone()
3524 .or_else(|| env.log_level.clone())
3525 .or_else(|| self.log_level.clone());
3526 // The telemetry preference resolves once in the shared core behind
3527 // [`resolved_telemetry_consent`]. The telemetry owner also checks old
3528 // durable declines and unreadable privacy state. The
3529 // comments that matter live there: the
3530 // environment/file/default chain, and why every kill switch is a
3531 // floor (`telemetry = false` persisted in the file is the *persistent*
3532 // off switch; an explicit env "off", an unreadable env value, or a
3533 // dispatcher-declared floor forces off regardless of CLI flag or
3534 // config file).
3535 let (telemetry_env_file, telemetry_source_env_file) = telemetry_consent_from_env(
3536 env.telemetry,
3537 env.telemetry_env_invalid,
3538 env.telemetry_floor,
3539 self.telemetry,
3540 );
3541 // The CLI flag is a run-scoped term on top: `--telemetry false` stops
3542 // this run; `--telemetry true` can never climb over a kill switch.
3543 // The source names the CLI only when the CLI term actually decided
3544 // the outcome — a flag that lost to a kill switch is not the provenance.
3545 let telemetry = telemetry_env_file && cli.telemetry != Some(false);
3546 let telemetry_source = if cli.telemetry == Some(false)
3547 || (cli.telemetry == Some(true) && telemetry_env_file)
3548 {
3549 TelemetrySource::Cli
3550 } else {
3551 telemetry_source_env_file
3552 };
3553 let telemetry_persisted_off = self.telemetry == Some(false);
3554 // Only a *persisted* off is an answer. `--telemetry false` and
3555 // `CODEWHALE_TELEMETRY=0` are run-scoped kill switches: they must stop
3556 // this run without deleting the identity and buffered events of a user
3557 // who never revoked consent — the dispatcher forwards a resolved
3558 // `false` on every ordinary run, so treating an environment "off" as an
3559 // answer would also make the default state indistinguishable from a
3560 // revocation.
3561 let telemetry_explicit_off = telemetry_persisted_off;
3562 // The shipped default is [`DEFAULT_TELEMETRY_ENDPOINT`], and it is a
3563 // default rather than a floor: an explicit value in the environment or
3564 // the config file wins outright. An explicit *empty* value is not a
3565 // missing value — it is the local dry-run sink, and it stays reachable
3566 // by resolving to `None` instead of falling through to the default.
3567 //
3568 // None of this changes the user's opt-out. A session only reaches an
3569 // endpoint after `telemetry` above resolved true; every persistent and
3570 // run-scoped kill switch is upstream of this line.
3571 let telemetry_endpoint = match env
3572 .telemetry_endpoint
3573 .clone()
3574 .or_else(|| self.telemetry_endpoint.clone())
3575 {
3576 Some(configured) if configured.trim().is_empty() => None,
3577 Some(configured) => Some(configured),
3578 None => Some(DEFAULT_TELEMETRY_ENDPOINT.to_string()),
3579 };
3580 let approval_policy = cli
3581 .approval_policy
3582 .clone()
3583 .or_else(|| env.approval_policy.clone())
3584 .or_else(|| self.approval_policy.clone());
3585 let sandbox_mode = cli
3586 .sandbox_mode
3587 .clone()
3588 .or_else(|| env.sandbox_mode.clone())
3589 .or_else(|| self.sandbox_mode.clone());
3590 let yolo = cli.yolo.or(env.yolo);
3591 let verbosity = cli
3592 .verbosity
3593 .clone()
3594 .or_else(|| env.verbosity.clone())
3595 .or_else(|| self.verbosity.clone());
3596
3597 ResolvedRuntimeOptions {
3598 provider,
3599 provider_source,
3600 model,
3601 model_source,
3602 api_key,
3603 api_key_source,
3604 base_url,
3605 auth_mode,
3606 insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false),
3607 log_level,
3608 telemetry,
3609 telemetry_source,
3610 telemetry_explicit_off,
3611 telemetry_endpoint,
3612 approval_policy,
3613 sandbox_mode,
3614 yolo,
3615 verbosity,
3616 http_headers,
3617 route,
3618 }
3619 }
3620 }
3621
3622 /// Default base URL from the route descriptor, plus the one Moonshot/Kimi
3623 /// imported-token exception that is an auth-mode fact rather than a kind.
3624 fn descriptor_fallback_base_url(provider: ProviderKind, auth_mode: Option<&str>) -> String {
3625 if provider == ProviderKind::Moonshot
3626 && auth_mode.is_some_and(auth_mode_uses_kimi_imported_token)
3627 {
3628 return DEFAULT_KIMI_CODE_BASE_URL.to_string();
3629 }
3630 crate::route::ProviderDescriptor::for_kind(provider)
3631 .default_base_url()
3632 .to_string()
3633 }
3634
3635 /// Where an enabled session's batches go when nobody has said otherwise.
3636 ///
3637 /// The first-party ingest service — a Cloudflare Worker that appends to Workers
3638 /// Analytics Engine, with an optional disclosed PostHog sink. See
3639 /// `docs/TELEMETRY.md` for what a batch contains and `telemetry-ingest/` for the handler.
3640 ///
3641 /// This is a *default*, not a floor, and it changes nothing about permission:
3642 /// `CODEWHALE_TELEMETRY=0`, `telemetry = false`, and a recorded decline all
3643 /// stop the session long before an endpoint is read.
3644 ///
3645 /// An explicit value — `CODEWHALE_TELEMETRY_ENDPOINT` or `telemetry_endpoint` in
3646 /// the config file — wins outright, and an explicit *empty* value resolves to no
3647 /// endpoint at all, which is the local dry-run sink: batches are serialized
3648 /// exactly as a server would see them and appended to
3649 /// `$CODEWHALE_HOME/telemetry/dryrun.jsonl`, and no HTTP client is constructed.
3650 pub const DEFAULT_TELEMETRY_ENDPOINT: &str = "https://telemetry.codewhale.net/v1/telemetry";
3651
3652 /// Provider-neutral credential value forwarded from the CLI dispatcher to the
3653 /// in-process TUI when `--api-key` must survive profile-late route selection.
3654 pub const CLI_API_KEY_ENV: &str = "CODEWHALE_CLI_API_KEY";
3655
3656 /// Source marker paired with [`CLI_API_KEY_ENV`] on the CLI-to-TUI boundary.
3657 pub const CLI_API_KEY_SOURCE_ENV: &str = "CODEWHALE_CLI_API_KEY_SOURCE";
3658
3659 /// Read-only compatibility alias used by dispatchers before v0.9.12.
3660 pub const LEGACY_CLI_API_KEY_SOURCE_ENV: &str = "DEEPSEEK_API_KEY_SOURCE";
3661
3662 /// The dispatcher's statement to the TUI child about *why* telemetry is off.
3663 ///
3664 /// Private to the `codewhale` → `codewhale-tui` hop, in the same spirit as
3665 /// [`CLI_API_KEY_SOURCE_ENV`]. Set to `1`/`0` on every delegated run.
3666 pub const TELEMETRY_FLOOR_ENV: &str = "CODEWHALE_TELEMETRY_FLOOR";
3667
3668 /// Whether an environment-level kill switch forces telemetry off here.
3669 ///
3670 /// A floor is *not* the same as "telemetry resolved to false": off is the
3671 /// default, and the dispatcher forwards a resolved `CODEWHALE_TELEMETRY=false`
3672 /// on every ordinary run, so a child reading only that value cannot tell an
3673 /// operator's declared kill switch from the shipped default. That distinction
3674 /// matters exactly once — the first-run notice must not ask a question whose
3675 /// answer this environment overrides — so the dispatcher states it outright in
3676 /// [`TELEMETRY_FLOOR_ENV`] and the child believes the statement.
3677 ///
3678 /// With no statement (a directly launched `codewhale-tui`) the raw environment
3679 /// is read instead, where an explicit "off" or an unreadable value is a floor.
3680 #[must_use]
3681 pub fn telemetry_floor_in_force() -> bool {
3682 if let Ok(raw) = std::env::var(TELEMETRY_FLOOR_ENV)
3683 && let Ok(declared) = parse_bool(&raw)
3684 {
3685 return declared;
3686 }
3687 let Ok(raw) =
3688 std::env::var("CODEWHALE_TELEMETRY").or_else(|_| std::env::var("DEEPSEEK_TELEMETRY"))
3689 else {
3690 return false;
3691 };
3692 !matches!(parse_bool(&raw), Ok(true))
3693 }
3694
3695 /// Where resolved telemetry consent came from (#5441).
3696 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3697 pub enum TelemetrySource {
3698 /// `--telemetry` on this run's command line.
3699 Cli,
3700 /// `CODEWHALE_TELEMETRY`/`DEEPSEEK_TELEMETRY`, including the dispatcher's
3701 /// floor statement and every environment kill switch.
3702 Env,
3703 /// `telemetry = …` written to the config file.
3704 Config,
3705 /// Nobody said anything; the shipped preference is on.
3706 Default,
3707 }
3708
3709 impl TelemetrySource {
3710 /// Stable label for the doctor row and config display.
3711 #[must_use]
3712 pub const fn as_str(self) -> &'static str {
3713 match self {
3714 Self::Cli => "cli",
3715 Self::Env => "env",
3716 Self::Config => "config",
3717 Self::Default => "default",
3718 }
3719 }
3720 }
3721
3722 /// Read the telemetry environment override, reporting an unreadable value
3723 /// instead of swallowing it.
3724 ///
3725 /// Returns `(value, invalid)`. `invalid` is `true` only when the variable
3726 /// was set to something [`parse_bool`] rejected; an unset variable is
3727 /// simply `(None, false)`. Shared by the runtime resolver and the
3728 /// provenance surfaces so they cannot drift.
3729 fn read_telemetry_env() -> (Option<bool>, bool) {
3730 let Some(raw) = std::env::var("CODEWHALE_TELEMETRY")
3731 .or_else(|_| std::env::var("DEEPSEEK_TELEMETRY"))
3732 .ok()
3733 else {
3734 return (None, false);
3735 };
3736 match parse_bool(&raw) {
3737 Ok(value) => (Some(value), false),
3738 Err(_) => {
3739 tracing::warn!(
3740 "Invalid CODEWHALE_TELEMETRY/DEEPSEEK_TELEMETRY value; expected one of \
3741 1/0, true/false, yes/no, on/off, enabled/disabled. Telemetry is forced off."
3742 );
3743 (None, true)
3744 }
3745 }
3746 }
3747
3748 /// Resolved telemetry consent with its source, for surfaces that hold the
3749 /// config-file value but not the full CLI/env resolution chain — the doctor
3750 /// runtime-posture row and the `config get telemetry` display (#5441).
3751 ///
3752 /// This is the same resolution [`ConfigToml::resolve_runtime_options`]
3753 /// applies without its CLI term: environment first (an explicit value, an
3754 /// unreadable one, or a dispatcher floor), then the file, then the shipped
3755 /// default of `on`; a persisted `telemetry = false` is a floor no later
3756 /// term can climb over. The runtime resolver calls this directly, so the
3757 /// preference surfaces agree. The telemetry owner also checks historical
3758 /// durable declines and fails closed on unreadable privacy state.
3759 #[must_use]
3760 pub fn resolved_telemetry_consent(file_telemetry: Option<bool>) -> (bool, TelemetrySource) {
3761 let (env_telemetry, env_invalid) = read_telemetry_env();
3762 telemetry_consent_from_env(
3763 env_telemetry,
3764 env_invalid,
3765 telemetry_floor_in_force(),
3766 file_telemetry,
3767 )
3768 }
3769
3770 /// The decision core shared by [`resolved_telemetry_consent`] and the runtime
3771 /// resolver, which already holds a snapshot of the same environment facts.
3772 #[must_use]
3773 fn telemetry_consent_from_env(
3774 env_telemetry: Option<bool>,
3775 env_invalid: bool,
3776 floor: bool,
3777 file_telemetry: Option<bool>,
3778 ) -> (bool, TelemetrySource) {
3779 let persisted_off = file_telemetry == Some(false);
3780 let allowed = env_telemetry.or(file_telemetry).unwrap_or(true);
3781 let on = allowed && env_telemetry != Some(false) && !env_invalid && !floor && !persisted_off;
3782 let source = if !on && (env_telemetry == Some(false) || env_invalid || floor) {
3783 // An environment kill switch decided the outcome.
3784 TelemetrySource::Env
3785 } else if !on && persisted_off {
3786 // The persistent opt-out outranked everything else in play.
3787 TelemetrySource::Config
3788 } else if env_telemetry.is_some() {
3789 TelemetrySource::Env
3790 } else if file_telemetry.is_some() {
3791 TelemetrySource::Config
3792 } else {
3793 TelemetrySource::Default
3794 };
3795 (on, source)
3796 }
3797
3798 /// Values config.toml's root `approval_policy` accepts, compared trimmed and
3799 /// case-insensitively. settings.toml's `approval_policy` is a different
3800 /// vocabulary (`use-tui-default`, `ask`, `auto-review`, `full-access`).
3801 pub const CONFIG_TOML_APPROVAL_POLICIES: &[&str] =
3802 &["on-request", "untrusted", "never", "auto", "suggest"];
3803 /// Values config.toml's root `sandbox_mode` accepts.
3804 pub const CONFIG_TOML_SANDBOX_MODES: &[&str] = &[
3805 "read-only",
3806 "workspace-write",
3807 "danger-full-access",
3808 "external-sandbox",
3809 ];
3810 /// Values config.toml's root `verbosity` accepts.
3811 pub const CONFIG_TOML_VERBOSITIES: &[&str] = &["normal", "concise"];
3812
3813 /// The closed vocabulary of a config.toml root key, if it has one.
3814 #[must_use]
3815 pub fn config_toml_choices(key: &str) -> Option<&'static [&'static str]> {
3816 match key {
3817 "approval_policy" => Some(CONFIG_TOML_APPROVAL_POLICIES),
3818 "sandbox_mode" => Some(CONFIG_TOML_SANDBOX_MODES),
3819 "verbosity" => Some(CONFIG_TOML_VERBOSITIES),
3820 _ => None,
3821 }
3822 }
3823
3824 /// Refuse a value the config.toml loader would reject for a closed-vocabulary
3825 /// root key, so shared setters cannot write a file the TUI then fails to load.
3826 pub fn check_config_toml_choice(key: &str, value: &str) -> Result<()> {
3827 let Some(choices) = config_toml_choices(key) else {
3828 return Ok(());
3829 };
3830 if choices.contains(&value.trim().to_ascii_lowercase().as_str()) {
3831 return Ok(());
3832 }
3833 let settings_note = if key == "approval_policy" {
3834 " (`use-tui-default`, `ask`, `auto-review` and `full-access` are settings.toml \
3835 values for the /settings editor; config.toml does not read them.)"
3836 } else {
3837 ""
3838 };
3839 let value = codewhale_secrets::redact::redact_secrets(value);
3840 bail!(
3841 "invalid value '{value}' for '{key}': config.toml accepts {}.{settings_note} \
3842 No value was changed.\nfix: codewhale config set {key} {}",
3843 choices.join(", "),
3844 choices[0]
3845 )
3846 }
3847
3848 #[must_use]
3849 pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
3850 let Some(project_rank) = approval_policy_rank(project) else {
3851 return false;
3852 };
3853 match current.and_then(approval_policy_rank) {
3854 Some(current_rank) => project_rank >= current_rank,
3855 None => project_rank >= 2,
3856 }
3857 }
3858
3859 #[must_use]
3860 pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
3861 let normalized_project = project.trim().to_ascii_lowercase();
3862 if normalized_project == "external-sandbox" {
3863 return current
3864 .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
3865 .unwrap_or(false);
3866 }
3867
3868 let Some(project_rank) = sandbox_mode_rank(project) else {
3869 return false;
3870 };
3871 match current.and_then(sandbox_mode_rank) {
3872 Some(current_rank) => project_rank >= current_rank,
3873 None => project_rank >= 2,
3874 }
3875 }
3876
3877 fn approval_policy_rank(value: &str) -> Option<u8> {
3878 match value.trim().to_ascii_lowercase().as_str() {
3879 "auto" => Some(0),
3880 "suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
3881 "never" | "deny" | "denied" => Some(2),
3882 _ => None,
3883 }
3884 }
3885
3886 fn sandbox_mode_rank(value: &str) -> Option<u8> {
3887 match value.trim().to_ascii_lowercase().as_str() {
3888 "danger-full-access" => Some(0),
3889 "external-sandbox" => Some(0),
3890 "workspace-write" => Some(1),
3891 "read-only" => Some(2),
3892 _ => None,
3893 }
3894 }
3895
3896 /// What [`load_project_config_outcome`] found in the workspace.
3897 ///
3898 /// The distinction between "no project config" and "a project config that is
3899 /// broken" is security-relevant, so it is in the type rather than in a log
3900 /// line. A project config can only *tighten* `approval_policy` /
3901 /// `sandbox_mode` beyond the user's baseline; if a typo makes it unparseable
3902 /// and that is reported as absence, the project silently loses its
3903 /// restrictions and falls back to the user's more permissive baseline.
3904 #[derive(Debug, Clone)]
3905 pub enum ProjectConfigOutcome {
3906 /// No project config file exists in this workspace.
3907 Missing,
3908 /// A project config was found and parsed.
3909 Loaded(Box<ConfigToml>),
3910 /// A project config file exists but could not be used. Its contents are
3911 /// deliberately not included — a config file holds credentials.
3912 Invalid {
3913 /// The offending file.
3914 path: PathBuf,
3915 /// Why it could not be used, safe to display.
3916 reason: String,
3917 },
3918 }
3919
3920 impl ProjectConfigOutcome {
3921 /// The parsed config, discarding the reason a broken one was rejected.
3922 #[must_use]
3923 pub fn into_config(self) -> Option<ConfigToml> {
3924 match self {
3925 Self::Loaded(config) => Some(*config),
3926 Self::Missing | Self::Invalid { .. } => None,
3927 }
3928 }
3929
3930 /// The path and reason when a project config exists but is unusable.
3931 #[must_use]
3932 pub fn invalid(&self) -> Option<(&Path, &str)> {
3933 match self {
3934 Self::Invalid { path, reason } => Some((path.as_path(), reason.as_str())),
3935 Self::Missing | Self::Loaded(_) => None,
3936 }
3937 }
3938 }
3939
3940 /// Load a project-level config from the workspace, reporting why a file that
3941 /// exists could not be used.
3942 ///
3943 /// Checks `$WORKSPACE/.codewhale/config.toml` first, falling back to
3944 /// `$WORKSPACE/.deepseek/config.toml` for backward compatibility.
3945 pub fn load_project_config_outcome(workspace: &Path) -> ProjectConfigOutcome {
3946 for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
3947 let path = workspace.join(dir).join(CONFIG_FILE_NAME);
3948 if !project_config_candidate_exists(&path) {
3949 continue;
3950 }
3951 let raw = match read_checked_config_file(&path) {
3952 Ok(raw) => raw,
3953 Err(e) => {
3954 tracing::warn!("Failed to read project config {}: {e:#}", path.display());
3955 return ProjectConfigOutcome::Invalid {
3956 path,
3957 reason: format!("could not be read: {e}"),
3958 };
3959 }
3960 };
3961 match parse_config_toml_str(&raw) {
3962 Ok(config) => {
3963 let raw_provider = toml::from_str::<toml::Value>(&raw)
3964 .ok()
3965 .and_then(|document| document.get("provider").cloned())
3966 .and_then(|provider| provider.as_str().map(str::to_string));
3967 if config.provider == ProviderKind::Custom
3968 && raw_provider.as_deref() != Some(ProviderKind::Custom.as_str())
3969 {
3970 // An unrecognized provider name deserializes to `Custom`
3971 // rather than failing, so a typo would otherwise be
3972 // accepted as a deliberate custom-provider selection.
3973 tracing::warn!(
3974 "Failed to parse project config {}; file contents were omitted",
3975 quote_os_path(&path)
3976 );
3977 return ProjectConfigOutcome::Invalid {
3978 path,
3979 reason: match raw_provider {
3980 // A key pasted into `provider =` must not be
3981 // echoed; an ordinary typo stays readable.
3982 Some(name) => format!(
3983 "unknown provider '{}'",
3984 codewhale_secrets::redact::redact_secrets(&name)
3985 ),
3986 None => "unknown provider".to_string(),
3987 },
3988 };
3989 }
3990 return ProjectConfigOutcome::Loaded(Box::new(config));
3991 }
3992 Err(err) => {
3993 tracing::warn!(
3994 "Failed to parse project config {}; file contents were omitted",
3995 quote_os_path(&path)
3996 );
3997 return ProjectConfigOutcome::Invalid {
3998 path,
3999 // Position only: `toml`'s message and snippet can quote
4000 // the offending value, which may be a credential.
4001 reason: format!("invalid TOML at {}", config_toml_error_location(&raw, &err)),
4002 };
4003 }
4004 }
4005 }
4006 ProjectConfigOutcome::Missing
4007 }
4008
4009 /// Load a project-level config from the workspace.
4010 ///
4011 /// Returns `None` both when no project config exists and when one exists but
4012 /// is unusable. Callers that act on the *absence* of project restrictions —
4013 /// anything deciding whether a project tightens `approval_policy` or
4014 /// `sandbox_mode` — should use [`load_project_config_outcome`] instead, so a
4015 /// broken file is not read as "this project asked for nothing."
4016 pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
4017 load_project_config_outcome(workspace).into_config()
4018 }
4019
4020 fn project_config_candidate_exists(path: &Path) -> bool {
4021 fs::symlink_metadata(path).is_ok_and(|metadata| {
4022 let file_type = metadata.file_type();
4023 file_type.is_file() || file_type.is_symlink()
4024 })
4025 }
4026
4027 /// Canonical id for a DeepSeek-family model name, or `None` for anything else.
4028 ///
4029 /// Kept behaviourally identical to `normalize_model_name` in
4030 /// `crates/tui/src/config.rs`, which is the definition the TUI's own model
4031 /// chain uses. It exists here only so this crate can answer "is this root
4032 /// default a DeepSeek id?" without depending on the TUI.
4033 fn deepseek_family_model_id(model: &str) -> Option<String> {
4034 let trimmed = model.trim();
4035 if trimmed.is_empty() {
4036 return None;
4037 }
4038 match trimmed.to_ascii_lowercase().as_str() {
4039 "pro" | "deepseek-v4pro" => return Some("deepseek-v4-pro".to_string()),
4040 "flash" | "deepseek-v4flash" => return Some("deepseek-v4-flash".to_string()),
4041 "flash-vision" | "deepseek-v4flashvisionexp" => {
4042 return Some("deepseek-v4-flash-vision-exp".to_string());
4043 }
4044 _ => {}
4045 }
4046
4047 let normalized = trimmed.to_ascii_lowercase();
4048 if !normalized.starts_with("deepseek") && !normalized.contains("/deepseek") {
4049 return None;
4050 }
4051 if trimmed
4052 .chars()
4053 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))
4054 {
4055 return Some(trimmed.to_string());
4056 }
4057 None
4058 }
4059
4060 /// Providers whose model id is forwarded verbatim, because the upstream
4061 /// service — not this crate — is the authority on what ids it serves.
4062 ///
4063 /// Mirrors `provider_passes_model_through` in `crates/tui/src/config.rs`.
4064 fn provider_passes_model_through(provider: ProviderKind) -> bool {
4065 matches!(
4066 provider,
4067 ProviderKind::Openai
4068 | ProviderKind::Atlascloud
4069 | ProviderKind::WanjieArk
4070 | ProviderKind::Volcengine
4071 | ProviderKind::XiaomiMimo
4072 | ProviderKind::Moonshot
4073 | ProviderKind::Qianfan
4074 | ProviderKind::Openmodel
4075 | ProviderKind::Ollama
4076 | ProviderKind::OllamaCloud
4077 | ProviderKind::Huggingface
4078 | ProviderKind::Modelscope
4079 | ProviderKind::Meta
4080 | ProviderKind::Xai
4081 | ProviderKind::Telecomjs
4082 | ProviderKind::Edenai
4083 | ProviderKind::Zenmux
4084 | ProviderKind::Csdn
4085 | ProviderKind::Concentrate
4086 | ProviderKind::ModelstudioTokenPlan
4087 | ProviderKind::ModelstudioTokenPlanAnthropic
4088 | ProviderKind::ModelstudioCodingPlan
4089 | ProviderKind::ModelstudioCodingPlanAnthropic
4090 | ProviderKind::Custom
4091 )
4092 }
4093
4094 /// Whether a root `default_text_model` would be foreign to the active
4095 /// provider's endpoint, i.e. honouring it would send an id the endpoint cannot
4096 /// serve.
4097 ///
4098 /// This is the narrow case the old `provider == Deepseek` gate was covering by
4099 /// accident: a user switches `provider` and leaves a DeepSeek id behind in
4100 /// `default_text_model`. Forwarding `deepseek-chat` to Z.ai fails every
4101 /// request, so the root default is dropped and the provider default used
4102 /// instead — matching the decision `Config::default_model()` makes via
4103 /// `root_deepseek_model_is_foreign_to_direct_provider`
4104 /// (`crates/tui/src/config.rs`), whose provider lists this mirrors.
4105 fn root_default_model_is_foreign_to_provider(
4106 provider: ProviderKind,
4107 model: &str,
4108 base_url: &str,
4109 ) -> bool {
4110 // Not a DeepSeek id at all: nothing to protect against here. A model the
4111 // provider does not serve for some other reason is the provider's error to
4112 // report, not ours to silently rewrite.
4113 if deepseek_family_model_id(model).is_none() {
4114 return false;
4115 }
4116 // DeepSeek's own endpoints serve DeepSeek ids.
4117 if matches!(
4118 provider,
4119 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
4120 ) {
4121 return false;
4122 }
4123 // A custom base URL may be any OpenAI-compatible proxy, and a proxy may
4124 // legitimately serve DeepSeek ids (#1519). Full pass-through.
4125 if provider_preserves_custom_base_url_model(provider, base_url) {
4126 return false;
4127 }
4128 // Vendor-locked official endpoints. These pass model ids through, but
4129 // api.x.ai will never answer to `deepseek-v4-pro`, so pass-through does not
4130 // make the id servable — this is the #3227 contamination case.
4131 if matches!(
4132 provider,
4133 ProviderKind::Xai | ProviderKind::Openai | ProviderKind::Moonshot
4134 ) {
4135 return true;
4136 }
4137 // Remaining pass-through providers forward the id verbatim to a service
4138 // that is the authority on its own catalog.
4139 if provider_passes_model_through(provider) {
4140 return false;
4141 }
4142 // Aggregators, local runtimes, and multi-vendor clouds host DeepSeek
4143 // models under their own catalogs, so a DeepSeek id is valid there.
4144 if matches!(
4145 provider,
4146 ProviderKind::NvidiaNim
4147 | ProviderKind::Openrouter
4148 | ProviderKind::Orcarouter
4149 | ProviderKind::Novita
4150 | ProviderKind::Fireworks
4151 | ProviderKind::Siliconflow
4152 | ProviderKind::SiliconflowCN
4153 | ProviderKind::Deepinfra
4154 | ProviderKind::Together
4155 | ProviderKind::Sglang
4156 | ProviderKind::Vllm
4157 | ProviderKind::Volcengine
4158 | ProviderKind::Atlascloud
4159 | ProviderKind::OpencodeGo
4160 | ProviderKind::WanjieArk
4161 ) {
4162 return false;
4163 }
4164 // Everything else is a vendor serving only its own family (Z.ai, Stepfun,
4165 // MiniMax, Anthropic, …): a DeepSeek id there is the stale-config case.
4166 true
4167 }
4168
4169 /// A provider owner that Codewhale can identify with high confidence when an
4170 /// official route is handed a foreign model id.
4171 ///
4172 /// This intentionally reuses the conservative stale-root-model guard instead
4173 /// of treating the partial provider catalog as a closed-world allowlist.
4174 /// Unknown ids, custom endpoints, local runtimes, and multi-model gateways
4175 /// therefore remain provider-authoritative.
4176 #[must_use]
4177 pub fn known_foreign_model_owner(
4178 provider: ProviderKind,
4179 model: &str,
4180 base_url: &str,
4181 ) -> Option<ProviderKind> {
4182 root_default_model_is_foreign_to_provider(provider, model, base_url)
4183 .then_some(ProviderKind::Deepseek)
4184 }
4185
4186 fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
4187 if matches!(provider, ProviderKind::OpencodeGo) {
4188 // Canonicalize documented model ids. Unknown ids
4189 // must never be rewritten to the provider default — substituting a
4190 // different model is worse than letting the route layer reject the
4191 // request by the name the user actually configured.
4192 return opencode_go_model_id(model)
4193 .map(str::to_string)
4194 .unwrap_or_else(|| model.trim().to_string());
4195 }
4196 if matches!(provider, ProviderKind::XiaomiMimo)
4197 && let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
4198 {
4199 return canonical.to_string();
4200 }
4201 if matches!(
4202 provider,
4203 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic
4204 ) && let Some(canonical) = canonical_minimax_model_id(model)
4205 {
4206 return canonical.to_string();
4207 }
4208 if matches!(provider, ProviderKind::Zai)
4209 && let Some(canonical) = canonical_zai_model_id(model)
4210 {
4211 return canonical.to_string();
4212 }
4213
4214 if matches!(
4215 provider,
4216 ProviderKind::Atlascloud
4217 | ProviderKind::WanjieArk
4218 | ProviderKind::Volcengine
4219 | ProviderKind::XiaomiMimo
4220 | ProviderKind::Zai
4221 | ProviderKind::Stepfun
4222 | ProviderKind::Minimax
4223 | ProviderKind::MinimaxAnthropic
4224 | ProviderKind::Qianfan
4225 | ProviderKind::Ollama
4226 | ProviderKind::OllamaCloud
4227 | ProviderKind::Meta
4228 | ProviderKind::Xai
4229 ) {
4230 return model.to_string();
4231 }
4232
4233 let normalized = model.trim().to_ascii_lowercase();
4234 if provider == ProviderKind::Openrouter
4235 && let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
4236 {
4237 return canonical.to_string();
4238 }
4239 if provider == ProviderKind::Orcarouter
4240 && let Some(canonical) = canonical_orcarouter_recent_model_id(&normalized)
4241 {
4242 return canonical.to_string();
4243 }
4244 match (provider, normalized.as_str()) {
4245 (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
4246 DEFAULT_NVIDIA_NIM_MODEL.to_string()
4247 }
4248 (
4249 ProviderKind::NvidiaNim,
4250 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4251 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4252 ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
4253 (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
4254 DEFAULT_OPENROUTER_MODEL.to_string()
4255 }
4256 (
4257 ProviderKind::Openrouter,
4258 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4259 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4260 ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
4261 (ProviderKind::Orcarouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
4262 DEFAULT_ORCAROUTER_MODEL.to_string()
4263 }
4264 (
4265 ProviderKind::Orcarouter,
4266 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4267 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4268 ) => DEFAULT_ORCAROUTER_FLASH_MODEL.to_string(),
4269 (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
4270 DEFAULT_NOVITA_MODEL.to_string()
4271 }
4272 (
4273 ProviderKind::Novita,
4274 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4275 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4276 ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
4277 (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
4278 DEFAULT_FIREWORKS_MODEL.to_string()
4279 }
4280 (
4281 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
4282 "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
4283 ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
4284 (
4285 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
4286 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
4287 ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
4288 (
4289 ProviderKind::Arcee,
4290 "trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking",
4291 ) => DEFAULT_ARCEE_MODEL.to_string(),
4292 (ProviderKind::Arcee, "trinity-mini" | "arcee-trinity-mini") => {
4293 ARCEE_TRINITY_MINI_MODEL.to_string()
4294 }
4295 (ProviderKind::Arcee, "arcee-trinity-large-preview") => {
4296 ARCEE_TRINITY_LARGE_PREVIEW_MODEL.to_string()
4297 }
4298 (
4299 ProviderKind::Moonshot,
4300 "kimi"
4301 | "kimi-k2"
4302 | "kimi-k2.7"
4303 | "kimi-k2-7"
4304 | "kimi-k2.7-code"
4305 | "kimi-k2-7-code"
4306 | "kimi-code"
4307 | "moonshot-kimi-k2.7-code",
4308 ) => DEFAULT_MOONSHOT_MODEL.to_string(),
4309 (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
4310 MOONSHOT_KIMI_K2_6_MODEL.to_string()
4311 }
4312 (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
4313 DEFAULT_SGLANG_MODEL.to_string()
4314 }
4315 (
4316 ProviderKind::Sglang,
4317 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4318 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4319 ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
4320 (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
4321 DEFAULT_VLLM_MODEL.to_string()
4322 }
4323 (
4324 ProviderKind::Vllm,
4325 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4326 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4327 ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
4328 (ProviderKind::Huggingface, "deepseek-v4-pro" | "deepseek-v4pro") => {
4329 DEFAULT_HUGGINGFACE_MODEL.to_string()
4330 }
4331 (
4332 ProviderKind::Huggingface,
4333 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4334 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4335 ) => DEFAULT_HUGGINGFACE_FLASH_MODEL.to_string(),
4336 (ProviderKind::Together, "deepseek-v4-pro" | "deepseek-v4pro") => {
4337 DEFAULT_TOGETHER_MODEL.to_string()
4338 }
4339 (
4340 ProviderKind::Together,
4341 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4342 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4343 ) => DEFAULT_TOGETHER_FLASH_MODEL.to_string(),
4344 (ProviderKind::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
4345 DEFAULT_DEEPINFRA_MODEL.to_string()
4346 }
4347 (
4348 ProviderKind::Deepinfra,
4349 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4350 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4351 ) => DEFAULT_DEEPINFRA_FLASH_MODEL.to_string(),
4352 _ => model.to_string(),
4353 }
4354 }
4355
4356 fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> {
4357 let normalized = model.trim().to_ascii_lowercase();
4358 let normalized = normalized.replace(['_', ' '], "-");
4359 match normalized.as_str() {
4360 "mimo"
4361 | DEFAULT_XIAOMI_MIMO_MODEL
4362 | "mimo-v2-5-pro"
4363 | "xiaomi-mimo-v2.5-pro"
4364 | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL),
4365 XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL
4366 | "mimo-v2-5-pro-ultraspeed"
4367 | "xiaomi-mimo-v2.5-pro-ultraspeed"
4368 | "xiaomi-mimo-v2-5-pro-ultraspeed"
4369 | "ultraspeed"
4370 | "pro-ultraspeed" => Some(XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL),
4371 "omni"
4372 | "mimo-omni"
4373 | "v2.5-omni"
4374 | "v25-omni"
4375 | "mimo-v2.5"
4376 | "mimo-v25"
4377 | "mimo-v2-5"
4378 | "mimo-v2.5-omni"
4379 | "mimo-v25-omni"
4380 | "mimo-v2-5-omni"
4381 | "xiaomi-mimo-v2.5"
4382 | "xiaomi-mimo-v2-5"
4383 | "xiaomi-mimo-v2.5-omni"
4384 | "xiaomi-mimo-v2-5-omni" => Some(XIAOMI_MIMO_V2_5_OMNI_MODEL),
4385 "asr" | "mimo-asr" | "mimo-v2.5-asr" | "speech-to-text" | "transcribe" => {
4386 Some(XIAOMI_MIMO_ASR_MODEL)
4387 }
4388 "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => {
4389 Some(XIAOMI_MIMO_TTS_MODEL)
4390 }
4391 "mimo-tts-voicedesign"
4392 | "mimo-voice-design"
4393 | "mimo-v25-tts-voicedesign"
4394 | "mimo-v2.5-tts-voicedesign"
4395 | "voicedesign"
4396 | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL),
4397 "mimo-tts-voiceclone"
4398 | "mimo-voice-clone"
4399 | "mimo-v25-tts-voiceclone"
4400 | "mimo-v2.5-tts-voiceclone"
4401 | "voiceclone"
4402 | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL),
4403 "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL),
4404 _ => None,
4405 }
4406 }
4407
4408 fn canonical_minimax_model_id(model: &str) -> Option<&'static str> {
4409 let normalized = model.trim().to_ascii_lowercase();
4410 let normalized = normalized.replace(['_', ' '], "-");
4411 match normalized.as_str() {
4412 "minimax" | "minimax-m3" | "minimax-m-3" | "minimax-m-3-thinking" => {
4413 Some(DEFAULT_MINIMAX_MODEL)
4414 }
4415 "minimax-m2.7" | "minimax-m2-7" | "minimax-m-2.7" | "minimax-m-2-7" => {
4416 Some(MINIMAX_M2_7_MODEL)
4417 }
4418 "minimax-m2.7-highspeed"
4419 | "minimax-m2-7-highspeed"
4420 | "minimax-m-2.7-highspeed"
4421 | "minimax-m-2-7-highspeed" => Some(MINIMAX_M2_7_HIGHSPEED_MODEL),
4422 "minimax-m2.5" | "minimax-m2-5" | "minimax-m-2.5" | "minimax-m-2-5" => {
4423 Some(MINIMAX_M2_5_MODEL)
4424 }
4425 "minimax-m2.5-highspeed"
4426 | "minimax-m2-5-highspeed"
4427 | "minimax-m-2.5-highspeed"
4428 | "minimax-m-2-5-highspeed" => Some(MINIMAX_M2_5_HIGHSPEED_MODEL),
4429 "minimax-m2.1" | "minimax-m2-1" | "minimax-m-2.1" | "minimax-m-2-1" => {
4430 Some(MINIMAX_M2_1_MODEL)
4431 }
4432 "minimax-m2.1-highspeed"
4433 | "minimax-m2-1-highspeed"
4434 | "minimax-m-2.1-highspeed"
4435 | "minimax-m-2-1-highspeed" => Some(MINIMAX_M2_1_HIGHSPEED_MODEL),
4436 "minimax-m2" | "minimax-m-2" => Some(MINIMAX_M2_MODEL),
4437 _ => None,
4438 }
4439 }
4440
4441 fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
4442 let normalized = model.trim().to_ascii_lowercase();
4443 let normalized = normalized.replace(['_', ' '], "-");
4444 match normalized.as_str() {
4445 "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
4446 // Every alias resolves to its own id, never through DEFAULT_ZAI_MODEL:
4447 // moving the default (now GLM-5.3) must not silently re-point an
4448 // explicit GLM-5.2 route.
4449 "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(ZAI_GLM_5_2_MODEL),
4450 "glm-5.3-flash" | "glm-5-3-flash" | "zai-glm-5.3-flash" | "zai-glm-5-3-flash" => {
4451 Some(ZAI_GLM_5_3_FLASH_MODEL)
4452 }
4453 "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => Some(ZAI_GLM_5_3_MODEL),
4454 "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
4455 _ => None,
4456 }
4457 }
4458
4459 fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
4460 let normalized = model.trim().to_ascii_lowercase();
4461 let normalized = normalized.replace(['_', ' '], "-");
4462 match normalized.as_str() {
4463 OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
4464 | "trinity"
4465 | "trinity-large-thinking"
4466 | "arcee-trinity"
4467 | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
4468 OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
4469 Some(OPENROUTER_GEMMA_4_31B_MODEL)
4470 }
4471 OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
4472 Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
4473 }
4474 OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
4475 Some(OPENROUTER_GLM_5_1_MODEL)
4476 }
4477 OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
4478 Some(OPENROUTER_GLM_5_2_MODEL)
4479 }
4480 OPENROUTER_GLM_5_3_FLASH_MODEL
4481 | "glm-5.3-flash"
4482 | "glm-5-3-flash"
4483 | "zai-glm-5.3-flash"
4484 | "zai-glm-5-3-flash" => Some(OPENROUTER_GLM_5_3_FLASH_MODEL),
4485 OPENROUTER_GLM_5_3_MODEL | "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => {
4486 Some(OPENROUTER_GLM_5_3_MODEL)
4487 }
4488 OPENROUTER_KIMI_K2_7_CODE_MODEL
4489 | "kimi"
4490 | "kimi-k2"
4491 | "kimi-k2.7"
4492 | "kimi-k2-7"
4493 | "kimi-k2.7-code"
4494 | "kimi-k2-7-code"
4495 | "kimi-code"
4496 | "moonshot-kimi-k2.7-code"
4497 | "openrouter-kimi-k2.7-code" => Some(OPENROUTER_KIMI_K2_7_CODE_MODEL),
4498 OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
4499 Some(OPENROUTER_KIMI_K2_6_MODEL)
4500 }
4501 OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => {
4502 Some(OPENROUTER_MINIMAX_M3_MODEL)
4503 }
4504 OPENROUTER_MINIMAX_M2_7_MODEL
4505 | "minimax-2.7"
4506 | "minimax-2-7"
4507 | "minimax-m2.7"
4508 | "minimax-m2-7"
4509 | "minimax-m-2.7"
4510 | "minimax-m-2-7" => Some(OPENROUTER_MINIMAX_M2_7_MODEL),
4511 OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
4512 | "nemotron-3-nano-omni"
4513 | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
4514 OPENROUTER_QWEN_3_6_35B_A3B_MODEL
4515 | "qwen3.6-35b-a3b"
4516 | "qwen-3.6-35b-a3b"
4517 | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
4518 OPENROUTER_QWEN_3_6_FLASH_MODEL | "qwen3.6-flash" | "qwen-3.6-flash" => {
4519 Some(OPENROUTER_QWEN_3_6_FLASH_MODEL)
4520 }
4521 OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL
4522 | "qwen3.6-max-preview"
4523 | "qwen-3.6-max-preview"
4524 | "qwen-max-preview" => Some(OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
4525 OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
4526 Some(OPENROUTER_QWEN_3_6_27B_MODEL)
4527 }
4528 OPENROUTER_QWEN_3_6_PLUS_MODEL | "qwen3.6-plus" | "qwen-3.6-plus" => {
4529 Some(OPENROUTER_QWEN_3_6_PLUS_MODEL)
4530 }
4531 OPENROUTER_QWEN_3_7_PLUS_MODEL | "qwen3.7-plus" | "qwen-3.7-plus" => {
4532 Some(OPENROUTER_QWEN_3_7_PLUS_MODEL)
4533 }
4534 OPENROUTER_QWEN_3_7_MAX_MODEL | "qwen3.7-max" | "qwen-3.7-max" => {
4535 Some(OPENROUTER_QWEN_3_7_MAX_MODEL)
4536 }
4537 OPENROUTER_QWEN_3_8_FLASH_MODEL | "qwen3.8-flash" | "qwen-3.8-flash" => {
4538 Some(OPENROUTER_QWEN_3_8_FLASH_MODEL)
4539 }
4540 OPENROUTER_TENCENT_HY3_PREVIEW_MODEL
4541 | "hy3-preview"
4542 | "tencent-hy3-preview"
4543 | "hy3"
4544 | "hunyuan"
4545 | "tencent-hunyuan"
4546 | "hunyuan-hy3" => Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL),
4547 OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
4548 | "mimo-v2.5-pro"
4549 | "mimo-v2-5-pro"
4550 | "xiaomi-mimo-v2.5-pro"
4551 | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
4552 OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
4553 | "mimo-v2.5"
4554 | "mimo-v2-5"
4555 | "xiaomi-mimo-v2.5"
4556 | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
4557 _ => None,
4558 }
4559 }
4560
4561 /// Canonical id resolution for OrcaRouter's own auto-routing model.
4562 ///
4563 /// OrcaRouter is an aggregator whose upstream catalog uses the same
4564 /// namespaced ids as OpenRouter, so those ids pass through verbatim. The one
4565 /// OrcaRouter-specific alias worth normalizing is its `orcarouter/auto`
4566 /// router, which is not an upstream model and needs the bare `auto` spelling
4567 /// (as users naturally type it) to resolve to the namespaced wire id.
4568 fn canonical_orcarouter_recent_model_id(model: &str) -> Option<&'static str> {
4569 let normalized = model.trim().to_ascii_lowercase();
4570 let normalized = normalized.replace(['_', ' '], "-");
4571 match normalized.as_str() {
4572 ORCAROUTER_AUTO_MODEL | "auto" | "orcarouter-auto" | "orca-auto" => {
4573 Some(ORCAROUTER_AUTO_MODEL)
4574 }
4575 _ => None,
4576 }
4577 }
4578
4579 fn default_model_for_provider(provider: ProviderKind) -> &'static str {
4580 provider.provider().default_model()
4581 }
4582
4583 fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
4584 provider.provider().default_base_url()
4585 }
4586
4587 fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
4588 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
4589 normalized == DEFAULT_KIMI_CODE_BASE_URL
4590 || normalized == "https://api.kimi.com/coding"
4591 || normalized.starts_with("https://api.kimi.com/coding/")
4592 }
4593
4594 /// Dual-wire vendors: dialect is config (`wire`), not a separate ProviderKind.
4595 fn wire_prefers_anthropic(kind: ProviderKind, wire: Option<&str>) -> bool {
4596 if matches!(
4597 kind,
4598 ProviderKind::DeepseekAnthropic
4599 | ProviderKind::MinimaxAnthropic
4600 | ProviderKind::ModelstudioTokenPlanAnthropic
4601 | ProviderKind::ModelstudioCodingPlanAnthropic
4602 ) {
4603 return true;
4604 }
4605 let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else {
4606 return false;
4607 };
4608 let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-");
4609 matches!(
4610 normalized.as_str(),
4611 "anthropic"
4612 | "anthropic-messages"
4613 | "messages"
4614 | "claude"
4615 | "anthropic-compatible"
4616 | "anthropic-compat"
4617 )
4618 }
4619
4620 fn modelstudio_mode_is_coding_plan(kind: ProviderKind, mode: Option<&str>) -> bool {
4621 if matches!(
4622 kind,
4623 ProviderKind::ModelstudioCodingPlan | ProviderKind::ModelstudioCodingPlanAnthropic
4624 ) {
4625 return true;
4626 }
4627 let Some(raw) = mode.map(str::trim).filter(|value| !value.is_empty()) else {
4628 return false;
4629 };
4630 let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-");
4631 matches!(
4632 normalized.as_str(),
4633 "coding-plan" | "coding" | "codingplan" | "dashscope-coding" | "code"
4634 )
4635 }
4636
4637 fn is_modelstudio_family(kind: ProviderKind) -> bool {
4638 matches!(
4639 kind,
4640 ProviderKind::ModelstudioTokenPlan
4641 | ProviderKind::ModelstudioTokenPlanAnthropic
4642 | ProviderKind::ModelstudioCodingPlan
4643 | ProviderKind::ModelstudioCodingPlanAnthropic
4644 )
4645 }
4646
4647 fn resolve_modelstudio_base_url(
4648 configured: Option<String>,
4649 kind: ProviderKind,
4650 mode: Option<&str>,
4651 wire: Option<&str>,
4652 ) -> String {
4653 if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
4654 return url;
4655 }
4656 let coding = modelstudio_mode_is_coding_plan(kind, mode);
4657 let anthropic = wire_prefers_anthropic(kind, wire);
4658 match (coding, anthropic) {
4659 (true, true) => MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL.to_string(),
4660 (true, false) => DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL.to_string(),
4661 (false, true) => MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL.to_string(),
4662 (false, false) => DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL.to_string(),
4663 }
4664 }
4665
4666 fn resolve_minimax_base_url(
4667 configured: Option<String>,
4668 kind: ProviderKind,
4669 wire: Option<&str>,
4670 ) -> String {
4671 if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
4672 return url;
4673 }
4674 if wire_prefers_anthropic(kind, wire) {
4675 DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string()
4676 } else {
4677 DEFAULT_MINIMAX_BASE_URL.to_string()
4678 }
4679 }
4680
4681 fn resolve_deepseek_base_url(
4682 configured: Option<String>,
4683 kind: ProviderKind,
4684 wire: Option<&str>,
4685 ) -> String {
4686 if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
4687 return url;
4688 }
4689 if wire_prefers_anthropic(kind, wire) {
4690 DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string()
4691 } else {
4692 DEFAULT_DEEPSEEK_BASE_URL.to_string()
4693 }
4694 }
4695
4696 fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
4697 let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
4698 if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
4699 return None;
4700 }
4701 Some(match normalized.as_str() {
4702 "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => {
4703 DEFAULT_XIAOMI_MIMO_BASE_URL
4704 }
4705 "token-plan-cn"
4706 | "token-plan-china"
4707 | "token-plan-mainland"
4708 | "token-plan-mainland-china"
4709 | "cn"
4710 | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
4711 "token-plan-sgp"
4712 | "token-plan-sg"
4713 | "token-plan-singapore"
4714 | "sgp"
4715 | "sg"
4716 | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
4717 "token-plan-ams"
4718 | "token-plan-eu"
4719 | "token-plan-europe"
4720 | "token-plan-amsterdam"
4721 | "ams"
4722 | "eu"
4723 | "europe"
4724 | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
4725 _ => DEFAULT_XIAOMI_MIMO_BASE_URL,
4726 })
4727 }
4728
4729 fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
4730 matches!(
4731 normalized_mode,
4732 "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
4733 )
4734 }
4735
4736 fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
4737 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
4738 normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
4739 || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL
4740 || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
4741 }
4742
4743 const XIAOMI_MIMO_TOKEN_PLAN_ENV_VARS: &[&str] =
4744 &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"];
4745 const XIAOMI_MIMO_STANDARD_ENV_VARS: &[&str] =
4746 &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"];
4747
4748 fn xiaomi_mimo_env_api_key_for_runtime(
4749 mode: Option<&str>,
4750 base_url: Option<&str>,
4751 ) -> Option<String> {
4752 let env_value = |vars: &[&str]| codewhale_secrets::env_first(vars).map(|(_, value)| value);
4753
4754 let normalized_mode =
4755 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
4756 let standard_selected = normalized_mode
4757 .as_deref()
4758 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint)
4759 || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go);
4760 if standard_selected {
4761 return env_value(XIAOMI_MIMO_STANDARD_ENV_VARS);
4762 }
4763
4764 let token_plan_selected = normalized_mode
4765 .as_deref()
4766 .and_then(xiaomi_mimo_base_url_for_mode)
4767 .is_some()
4768 || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan);
4769 if token_plan_selected {
4770 return env_value(XIAOMI_MIMO_TOKEN_PLAN_ENV_VARS);
4771 }
4772
4773 env_value(XIAOMI_MIMO_TOKEN_PLAN_ENV_VARS).or_else(|| env_value(XIAOMI_MIMO_STANDARD_ENV_VARS))
4774 }
4775
4776 fn resolve_xiaomi_mimo_base_url(
4777 configured: Option<String>,
4778 api_key: Option<&str>,
4779 mode: Option<&str>,
4780 ) -> String {
4781 let normalized_mode =
4782 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
4783 let uses_standard_mode = normalized_mode
4784 .as_deref()
4785 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint);
4786 let mode_base_url = normalized_mode
4787 .as_deref()
4788 .and_then(xiaomi_mimo_base_url_for_mode);
4789 let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key);
4790 match configured {
4791 Some(base_url) if uses_standard_mode => base_url,
4792 Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => {
4793 mode_base_url
4794 .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL)
4795 .to_string()
4796 }
4797 Some(base_url) => base_url,
4798 None => {
4799 if let Some(base_url) = mode_base_url {
4800 base_url.to_string()
4801 } else if uses_standard_mode {
4802 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
4803 } else if uses_token_plan || api_key.is_none() {
4804 DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()
4805 } else {
4806 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
4807 }
4808 }
4809 }
4810 }
4811
4812 fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
4813 api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
4814 }
4815
4816 fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool {
4817 matches!(
4818 base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
4819 "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
4820 )
4821 }
4822
4823 /// Whether `base_url` belongs to the provider's official endpoint family.
4824 ///
4825 /// Some providers publish multiple stable paths for the same credential and
4826 /// model namespace. Keep that family definition centralized so route
4827 /// canonicalization and credential scoping cannot disagree.
4828 #[must_use]
4829 pub fn provider_base_url_is_official(provider: ProviderKind, base_url: &str) -> bool {
4830 let normalized = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
4831 match provider {
4832 ProviderKind::Deepseek => matches!(
4833 normalized.as_str(),
4834 "https://api.deepseek.com"
4835 | "https://api.deepseek.com/v1"
4836 | "https://api.deepseek.com/beta"
4837 ),
4838 ProviderKind::DeepseekAnthropic => matches!(
4839 normalized.as_str(),
4840 "https://api.deepseek.com/anthropic" | "https://api.deepseek.com/anthropic/v1"
4841 ),
4842 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => matches!(
4843 normalized.as_str(),
4844 "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
4845 ),
4846 ProviderKind::Moonshot => {
4847 matches!(
4848 normalized.as_str(),
4849 DEFAULT_MOONSHOT_BASE_URL | MOONSHOT_CN_BASE_URL
4850 ) || moonshot_base_url_uses_kimi_code(base_url)
4851 }
4852 ProviderKind::Zai => matches!(
4853 normalized.as_str(),
4854 "https://api.z.ai/api/coding/paas/v4"
4855 | "https://api.z.ai/api/paas/v4"
4856 | "https://open.bigmodel.cn/api/paas/v4"
4857 ),
4858 ProviderKind::XiaomiMimo => {
4859 xiaomi_mimo_base_url_uses_token_plan(base_url)
4860 || xiaomi_mimo_base_url_is_pay_as_you_go(base_url)
4861 }
4862 // StepFun publishes one vendor over four hosts: global (.ai) and
4863 // China (.com), each with a pay-as-you-go `/v1` and a Step Plan
4864 // `/step_plan/v1` surface. All four are StepFun's own, documented in
4865 // its console. Recognising only the global PAYG default made a Step
4866 // Plan subscriber's route read as a custom endpoint, and
4867 // `catalog_models_for_route` withholds the catalog from a custom
4868 // endpoint — so the picker reported `0 bundled`, offered no model
4869 // list, and fell back to a guessed context window on a route whose
4870 // console lists `step-5-preview` at 1M context.
4871 //
4872 // Note the plan hosts are listed from StepFun's console, not from
4873 // models.dev, whose `stepfun-ai-step-plan` entry is missing
4874 // `step-5-preview` that the vendor itself advertises. An aggregator
4875 // is a secondary source for what a host serves; the vendor is not.
4876 ProviderKind::Stepfun => matches!(
4877 normalized.as_str(),
4878 "https://api.stepfun.ai/v1"
4879 | "https://api.stepfun.ai/step_plan/v1"
4880 | "https://api.stepfun.com/v1"
4881 | "https://api.stepfun.com/step_plan/v1"
4882 ),
4883 ProviderKind::Ollama => {
4884 normalized == DEFAULT_OLLAMA_BASE_URL
4885 || provider::is_exact_ollama_cloud_route(provider, base_url)
4886 }
4887 ProviderKind::OllamaCloud => provider::is_exact_ollama_cloud_route(provider, base_url),
4888 ProviderKind::Edenai => matches!(
4889 normalized.as_str(),
4890 "https://api.edenai.run/v3" | "https://api.eu.edenai.run/v3"
4891 ),
4892 ProviderKind::Zenmux => normalized == DEFAULT_ZENMUX_BASE_URL,
4893 ProviderKind::Csdn => normalized == DEFAULT_CSDN_BASE_URL,
4894 ProviderKind::Concentrate => normalized == DEFAULT_CONCENTRATE_BASE_URL,
4895 // The Codewhale API's official endpoint family is its default base
4896 // plus whatever the operator declared in `CODEWHALE_API_BASE` — the
4897 // route's own documented override, already validated as HTTPS or
4898 // loopback, and the exact shape `CODEWHALE_CLOUD_API_BASE` has for the
4899 // account surface. Treating that declared origin as "custom" is what
4900 // silently stripped the account bearer and dispatched unauthenticated.
4901 // A base URL from anywhere else stays custom and keyless.
4902 ProviderKind::Codewhale => {
4903 normalized == DEFAULT_CODEWHALE_BASE_URL
4904 || provider::codewhale_api_base_from_env().is_some_and(|declared| {
4905 normalized == declared.trim_end_matches('/').to_ascii_lowercase()
4906 })
4907 }
4908 // Custom routes have no Codewhale-owned official endpoint. The
4909 // descriptor URL is a schema placeholder, never a credential scope.
4910 ProviderKind::Custom => false,
4911 _ => {
4912 normalized
4913 == default_base_url_for_provider(provider)
4914 .trim()
4915 .trim_end_matches('/')
4916 .to_ascii_lowercase()
4917 }
4918 }
4919 }
4920
4921 fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
4922 !provider_base_url_is_official(provider, base_url)
4923 }
4924
4925 /// Whether `base_url` is outside the provider's official endpoint family and
4926 /// therefore owns its model-id namespace.
4927 ///
4928 /// Custom OpenAI-compatible endpoints must receive the exact model selector
4929 /// the user supplied. Official endpoints may safely canonicalize known aliases
4930 /// to their provider wire ids.
4931 #[must_use]
4932 pub fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
4933 base_url_is_custom_for_provider(provider, base_url)
4934 }
4935
4936 fn should_skip_secret_store_for_provider(
4937 provider: ProviderKind,
4938 base_url: &str,
4939 auth_mode: Option<&str>,
4940 ) -> bool {
4941 if auth_mode_disables_api_key(auth_mode) {
4942 return true;
4943 }
4944 if base_url_is_custom_for_provider(provider, base_url) {
4945 return true;
4946 }
4947 if auth_mode_requires_api_key(auth_mode) {
4948 return false;
4949 }
4950 // The Codewhale API authenticates on every origin it is allowed to reach,
4951 // including the loopback test origin `CODEWHALE_API_BASE` may name. It is
4952 // never a keyless local runtime.
4953 if provider == ProviderKind::Codewhale {
4954 return false;
4955 }
4956
4957 matches!(provider, ProviderKind::Sglang | ProviderKind::Vllm)
4958 || (provider == ProviderKind::Ollama
4959 && !provider::is_exact_ollama_cloud_route(provider, base_url))
4960 || base_url_uses_local_host(base_url)
4961 }
4962
4963 /// Read the durable provider slot without allowing environment fallback to
4964 /// jump ahead of the bounded legacy slot. The old `ollama` slot is consulted
4965 /// only for the exact route tuple migrated above; selecting `ollama-cloud`
4966 /// directly never consumes a local provider credential.
4967 fn stored_api_key_for_provider(
4968 secrets: &Secrets,
4969 provider: ProviderKind,
4970 legacy_ollama_cloud: bool,
4971 ) -> Option<(String, SecretSource)> {
4972 let mut slots = vec![provider.secret_store_slot()];
4973 if provider == ProviderKind::OllamaCloud && legacy_ollama_cloud {
4974 slots.push(ProviderKind::Ollama.secret_store_slot());
4975 }
4976 slots.into_iter().find_map(|slot| {
4977 secrets
4978 .get(slot)
4979 .ok()
4980 .flatten()
4981 .filter(|value| !value.trim().is_empty())
4982 .map(|value| (value, SecretSource::Keyring))
4983 })
4984 }
4985
4986 /// The provider's API key from its own environment variables, the single
4987 /// list on its descriptor ([`provider::Provider::env_vars`]).
4988 ///
4989 /// Xiaomi MiMo is the exception: its token-plan variables belong to the
4990 /// token-plan endpoints, and `xiaomi_mimo_env_api_key_for_runtime` (which the
4991 /// resolver tries first) is the only reader that knows the selected mode. This
4992 /// fallback reads the standard variables only, so a token-plan key is never
4993 /// sent to the pay-as-you-go endpoint.
4994 fn env_api_key_for_provider(provider: ProviderKind) -> Option<String> {
4995 let env_vars = if provider == ProviderKind::XiaomiMimo {
4996 XIAOMI_MIMO_STANDARD_ENV_VARS
4997 } else {
4998 provider.provider().env_vars()
4999 };
5000 codewhale_secrets::env_first(env_vars).map(|(_, value)| value)
5001 }
5002
5003 /// Whether an authentication mode requires API-key material.
5004 #[must_use]
5005 pub fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
5006 matches!(
5007 auth_mode
5008 .map(str::trim)
5009 .filter(|value| !value.is_empty())
5010 .map(|value| value.to_ascii_lowercase()),
5011 Some(value)
5012 if matches!(
5013 value.as_str(),
5014 "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
5015 )
5016 )
5017 }
5018
5019 /// Whether an authentication mode explicitly disables upstream provider auth.
5020 #[must_use]
5021 pub fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
5022 matches!(
5023 auth_mode
5024 .map(str::trim)
5025 .filter(|value| !value.is_empty())
5026 .map(|value| value.to_ascii_lowercase()),
5027 Some(value)
5028 if matches!(
5029 value.as_str(),
5030 "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
5031 )
5032 )
5033 }
5034
5035 /// Whether an authentication mode selects Kimi's imported bearer token.
5036 #[must_use]
5037 pub fn auth_mode_uses_kimi_imported_token(auth_mode: &str) -> bool {
5038 matches!(
5039 auth_mode
5040 .trim()
5041 .to_ascii_lowercase()
5042 .replace('-', "_")
5043 .as_str(),
5044 "kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
5045 )
5046 }
5047
5048 fn base_url_uses_local_host(base_url: &str) -> bool {
5049 let Some(host) = base_url_host(base_url) else {
5050 return false;
5051 };
5052 let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
5053 if matches!(host.as_str(), "localhost" | "0.0.0.0") {
5054 return true;
5055 }
5056 host.parse::<std::net::IpAddr>()
5057 .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
5058 }
5059
5060 fn base_url_host(base_url: &str) -> Option<&str> {
5061 let without_scheme = base_url
5062 .split_once("://")
5063 .map_or(base_url, |(_, rest)| rest);
5064 let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
5065 if let Some(rest) = authority.strip_prefix('[') {
5066 return rest.split_once(']').map(|(host, _)| host);
5067 }
5068 authority.split(':').next().filter(|host| !host.is_empty())
5069 }
5070
5071 #[derive(Debug, Clone, Default)]
5072 pub struct CliRuntimeOverrides {
5073 pub provider: Option<ProviderKind>,
5074 pub model: Option<String>,
5075 pub api_key: Option<String>,
5076 pub base_url: Option<String>,
5077 pub auth_mode: Option<String>,
5078 pub log_level: Option<String>,
5079 pub telemetry: Option<bool>,
5080 pub approval_policy: Option<String>,
5081 pub sandbox_mode: Option<String>,
5082 pub yolo: Option<bool>,
5083 pub verbosity: Option<String>,
5084 }
5085
5086 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
5087 pub enum RuntimeApiKeySource {
5088 Cli,
5089 ConfigFile,
5090 Keyring,
5091 Env,
5092 }
5093
5094 impl RuntimeApiKeySource {
5095 #[must_use]
5096 pub fn as_env_value(self) -> &'static str {
5097 match self {
5098 Self::Cli => "cli",
5099 Self::ConfigFile => "config",
5100 Self::Keyring => "keyring",
5101 Self::Env => "env",
5102 }
5103 }
5104 }
5105
5106 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
5107 pub enum ProviderSource {
5108 Cli,
5109 Env(&'static str),
5110 Config,
5111 }
5112
5113 /// Where the resolved runtime model id came from.
5114 ///
5115 /// This mirrors the precedence chain in
5116 /// [`ConfigToml::resolve_runtime_options_with_secrets`] so diagnostics can say
5117 /// *why* a model was chosen instead of presenting a built-in default as if the
5118 /// user had asked for it. [`Self::ProviderDefault`] is the only variant that
5119 /// means "nothing was configured".
5120 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
5121 pub enum ModelSource {
5122 /// `--model` on the command line.
5123 Cli,
5124 /// A `CODEWHALE_*` environment variable.
5125 Env,
5126 /// `[providers.<name>].model`.
5127 ProviderConfig,
5128 /// The root `default_text_model` key, which is DeepSeek-scoped.
5129 RootDefaultTextModel,
5130 /// The provider-neutral root `model` key.
5131 RootModel,
5132 /// Nothing was configured; this is the built-in default for the provider.
5133 ProviderDefault,
5134 }
5135
5136 impl ModelSource {
5137 /// Whether the id was chosen by the user rather than substituted by us.
5138 #[must_use]
5139 pub fn is_explicit(self) -> bool {
5140 !matches!(self, Self::ProviderDefault)
5141 }
5142
5143 #[must_use]
5144 pub fn as_str(self) -> &'static str {
5145 match self {
5146 Self::Cli => "--model",
5147 Self::Env => "environment",
5148 Self::ProviderConfig => "config [providers.*].model",
5149 Self::RootDefaultTextModel => "config default_text_model",
5150 Self::RootModel => "config model",
5151 Self::ProviderDefault => "provider default",
5152 }
5153 }
5154 }
5155
5156 #[derive(Debug, Clone)]
5157 pub struct ResolvedRuntimeOptions {
5158 pub provider: ProviderKind,
5159 pub provider_source: ProviderSource,
5160 pub model: String,
5161 pub model_source: ModelSource,
5162 pub api_key: Option<String>,
5163 pub api_key_source: Option<RuntimeApiKeySource>,
5164 pub base_url: String,
5165 pub auth_mode: Option<String>,
5166 pub insecure_skip_tls_verify: bool,
5167 pub log_level: Option<String>,
5168 pub telemetry: bool,
5169 /// Where the resolved telemetry consent came from (cli | env | config |
5170 /// default), so doctor and config displays can state the truth about a
5171 /// machine that never opted in (#5441).
5172 pub telemetry_source: TelemetrySource,
5173 /// A human wrote `telemetry = false` into the config file.
5174 ///
5175 /// This is the *persistent* opt-out, and it is deliberately narrower than
5176 /// "telemetry resolved to false". A run-scoped kill switch also resolves
5177 /// false; treating that as a revocation would destroy the identity and
5178 /// buffered events of a user who merely set `CODEWHALE_TELEMETRY=0` for one
5179 /// command. Run-scoped kill switches
5180 /// (`--telemetry false`, the environment variable) stop the run and leave
5181 /// every byte on disk alone; only this flag authorizes the wipe.
5182 pub telemetry_explicit_off: bool,
5183 /// Where a telemetry batch would be sent, if telemetry were on.
5184 ///
5185 /// Already resolved: [`DEFAULT_TELEMETRY_ENDPOINT`] when nobody configured
5186 /// one, the configured value when somebody did, and `None` when somebody
5187 /// configured an empty one — which means the dry-run sink, not "unset".
5188 /// Which schemes are actually contactable is decided where a batch would be
5189 /// sent, not here — a user must be able to stage a value.
5190 pub telemetry_endpoint: Option<String>,
5191 pub approval_policy: Option<String>,
5192 pub sandbox_mode: Option<String>,
5193 pub yolo: Option<bool>,
5194 pub verbosity: Option<String>,
5195 pub http_headers: BTreeMap<String, String>,
5196 /// Executable route minted by [`crate::route::RouteResolver`].
5197 ///
5198 /// `Err` carries the resolver's rejection (foreign model on a strict
5199 /// direct provider, empty model, unsupported protocol). `model` and
5200 /// `base_url` above are still the requested values in that case, so a
5201 /// caller that reports them as a resolved route must check this first.
5202 /// Auth/key fields above are independent: the resolver never inspects
5203 /// credentials.
5204 pub route: Result<crate::route::ReadyRouteCandidate, crate::route::RouteError>,
5205 }
5206
5207 #[derive(Debug, Clone)]
5208 pub struct ConfigStore {
5209 path: PathBuf,
5210 pub config: ConfigToml,
5211 permissions: PermissionsToml,
5212 /// Original file text, retained so [`save`](Self::save) can merge
5213 /// comments back after serialisation.
5214 original_raw: Option<String>,
5215 /// What loading moved in memory from legacy top-level keys (#6394).
5216 legacy_root: legacy_root::LegacyRootMigration,
5217 }
5218
5219 /// Parse a [`ConfigToml`] on a dedicated thread with an explicit stack size.
5220 ///
5221 /// `ConfigToml` nests the per-provider tables, fleet trust policy, and every
5222 /// typed sub-table in one struct, and the monomorphized toml/serde
5223 /// deserializer frames for a struct this large overflow the 2 MiB default
5224 /// stack of libtest and tokio worker threads in debug builds (the same
5225 /// hazard the TUI fixed for its `ConfigFile`; reproduced as the #5585 stack
5226 /// overflow through the guided-setup save path). Every production
5227 /// `ConfigToml` parse goes through here so config-store loads stay safe
5228 /// regardless of the calling thread's stack budget.
5229 fn parse_config_toml_str(contents: &str) -> Result<ConfigToml, toml::de::Error> {
5230 parse_config_toml_with_receipt(contents).map(|(config, _)| config)
5231 }
5232
5233 /// [`parse_config_toml_str`] plus the receipt of the legacy top-level keys it
5234 /// moved in memory (#6394).
5235 fn parse_config_toml_with_receipt(
5236 contents: &str,
5237 ) -> Result<(ConfigToml, legacy_root::LegacyRootMigration), toml::de::Error> {
5238 std::thread::scope(|scope| {
5239 match std::thread::Builder::new()
5240 .name("config-toml-parse".to_string())
5241 .stack_size(16 * 1024 * 1024)
5242 .spawn_scoped(scope, || parse_config_toml_canonical(contents))
5243 {
5244 Ok(handle) => handle
5245 .join()
5246 .unwrap_or_else(|panic| std::panic::resume_unwind(panic)),
5247 // Spawning can only fail under resource exhaustion; parsing on
5248 // the caller's stack is still the best remaining option.
5249 Err(_) => parse_config_toml_canonical(contents),
5250 }
5251 })
5252 }
5253
5254 fn parse_config_toml_canonical(
5255 contents: &str,
5256 ) -> Result<(ConfigToml, legacy_root::LegacyRootMigration), toml::de::Error> {
5257 let (text, receipt) = legacy_root::canonicalize_text(contents)?;
5258 let config = toml::from_str::<ConfigToml>(&text)?;
5259 Ok((config, receipt))
5260 }
5261
5262 /// Where a TOML error sits: `line L, column C` in `source` (the exact text
5263 /// that was parsed), plus the key path when the deserializer recorded one.
5264 ///
5265 /// Never the error's message or source snippet: both can quote the offending
5266 /// value (`invalid type: string "sk-…", expected a boolean`), and config and
5267 /// permissions files hold credentials. Pass `None` for `source` when the
5268 /// span refers to text the user did not write.
5269 #[must_use]
5270 pub fn toml_error_location(source: Option<&str>, err: &toml::de::Error) -> String {
5271 let position = source.zip(err.span()).map(|(source, span)| {
5272 let mut start = span.start.min(source.len());
5273 while !source.is_char_boundary(start) {
5274 start -= 1;
5275 }
5276 let before = &source[..start];
5277 let line = before.matches('\n').count() + 1;
5278 let column = before.rsplit('\n').next().unwrap_or("").chars().count() + 1;
5279 format!("line {line}, column {column}")
5280 });
5281 // Without its input, `toml` renders exactly `{message}\n` followed by
5282 // ``in `{keys}`\n`` when it recorded a key path; take only the latter.
5283 let mut detached = err.clone();
5284 detached.set_input(None);
5285 let rendered = detached.to_string();
5286 let key_path = rendered
5287 .strip_prefix(err.message())
5288 .and_then(|rest| rest.strip_prefix("\nin `"))
5289 .and_then(|rest| rest.strip_suffix("`\n"))
5290 .filter(|keys| !keys.is_empty())
5291 .map(|keys| format!("in `{}`", codewhale_secrets::redact::redact_secrets(keys)));
5292 match (position, key_path) {
5293 (Some(position), Some(keys)) => format!("{position}, {keys}"),
5294 (Some(position), None) => position,
5295 (None, Some(keys)) => keys,
5296 (None, None) => "position unknown".to_string(),
5297 }
5298 }
5299
5300 /// [`toml_error_location`] for an error from [`parse_config_toml_with_receipt`].
5301 /// A file with legacy top-level keys is parsed from a rewritten copy whose
5302 /// offsets name nothing the user wrote, so only the key path is reported.
5303 fn config_toml_error_location(raw: &str, err: &toml::de::Error) -> String {
5304 let rewritten = matches!(
5305 legacy_root::canonicalize_text(raw),
5306 Ok((std::borrow::Cow::Owned(_), _))
5307 );
5308 toml_error_location((!rewritten).then_some(raw), err)
5309 }
5310
5311 /// Parse any `config.toml`-shaped document into [`ConfigToml`], moving legacy
5312 /// top-level `base_url` / `api_key` into their provider tables first. Every
5313 /// caller outside this crate (bundle import, tests) parses through here so a
5314 /// top-level key can never land in `extras`.
5315 pub fn parse_config_toml(contents: &str) -> Result<ConfigToml, toml::de::Error> {
5316 parse_config_toml_str(contents)
5317 }
5318
5319 impl ConfigStore {
5320 /// The validated file snapshot captured by the last load or successful
5321 /// save. This read-only view preserves literal provider identities that a
5322 /// typed serialization may normalize; it excludes unsaved in-memory edits.
5323 #[must_use]
5324 pub fn original_body(&self) -> Option<&str> {
5325 self.original_raw.as_deref()
5326 }
5327
5328 pub fn load(path: Option<PathBuf>) -> Result<Self> {
5329 let path = resolve_config_path(path)?;
5330 let (config, original_raw, legacy_root) = if checked_path_exists(&path)? {
5331 let raw = read_checked_config_file(&path)?;
5332 let (mut parsed, receipt) = parse_config_toml_with_receipt(&raw).map_err(|err| {
5333 anyhow::anyhow!(
5334 "failed to parse config at {} ({}); file contents were omitted",
5335 quote_os_path(&path),
5336 config_toml_error_location(&raw, &err)
5337 )
5338 })?;
5339 let raw_document: toml::Value = toml::from_str(&raw).map_err(|err| {
5340 anyhow::anyhow!(
5341 "failed to parse config at {} ({}); file contents were omitted",
5342 quote_os_path(&path),
5343 toml_error_location(Some(&raw), &err)
5344 )
5345 })?;
5346 if let Some(provider_id) = raw_document.get("provider").and_then(toml::Value::as_str) {
5347 parsed
5348 .bind_persisted_provider_id(provider_id)
5349 .with_context(|| {
5350 format!("failed to parse config at {}", quote_os_path(&path))
5351 })?;
5352 }
5353 (parsed, Some(raw), receipt)
5354 } else {
5355 (
5356 ConfigToml::default(),
5357 None,
5358 legacy_root::LegacyRootMigration::default(),
5359 )
5360 };
5361 let permissions = load_sibling_permissions(&path)?;
5362
5363 Ok(Self {
5364 path,
5365 config,
5366 permissions,
5367 original_raw,
5368 legacy_root,
5369 })
5370 }
5371
5372 /// What loading moved in memory from legacy top-level keys. Loading never
5373 /// rewrites the file; the next save does, keeping any conflict in place.
5374 #[must_use]
5375 pub fn legacy_root_migration(&self) -> &legacy_root::LegacyRootMigration {
5376 &self.legacy_root
5377 }
5378
5379 /// Render the exact body [`save`](Self::save) would write: the serialized
5380 /// config with comments and disabled keys from the originally-loaded file
5381 /// merged back in. Exposed so setup flows can stage this body into a
5382 /// [`persistence::SetupTransaction`] alongside sibling files and keep the
5383 /// comment-preserving write atomic with the rest of the transaction.
5384 pub fn rendered_body(&self) -> Result<String> {
5385 catalog::configured::validate_configured_models(
5386 self.config.custom_models.as_deref().unwrap_or_default(),
5387 )?;
5388 let mut serialized =
5389 toml::to_string_pretty(&self.config).context("failed to serialize config")?;
5390 let provider_id = self.config.provider_id();
5391 if provider_id != self.config.provider.as_str() {
5392 let mut document = serialized
5393 .parse::<toml_edit::DocumentMut>()
5394 .context("failed to edit serialized config")?;
5395 document["provider"] = toml_edit::value(provider_id);
5396 serialized = document.to_string();
5397 }
5398 if let Some(ref original_raw) = self.original_raw {
5399 // Comments come from the original with legacy top-level keys
5400 // already moved (#6394): a comment above a moved key stays in
5401 // place instead of vanishing with the key.
5402 let decor_source = legacy_root::migrated_document_text(original_raw);
5403 let merged = merge_and_preserve_comments(
5404 &serialized,
5405 decor_source.as_deref().unwrap_or(original_raw),
5406 )
5407 .with_context(|| {
5408 format!(
5409 "cannot safely preserve config at {}; reload it and retry instead of replacing an unmergeable snapshot",
5410 quote_os_path(&self.path)
5411 )
5412 })?;
5413 // The typed body has no top-level keys. A conflicting pair the
5414 // user never touched goes back exactly as it was on disk.
5415 let mut document = merged
5416 .parse::<toml_edit::DocumentMut>()
5417 .context("failed to edit serialized config")?;
5418 if legacy_root::restore_conflicts(&mut document, original_raw) {
5419 return Ok(document.to_string());
5420 }
5421 Ok(merged)
5422 } else {
5423 Ok(serialized)
5424 }
5425 }
5426
5427 pub fn save(&mut self) -> Result<()> {
5428 let path = normalize_config_file_path(self.path.clone())?;
5429 let body = self.rendered_body()?;
5430 if let Some(original_raw) = self.original_raw.as_deref() {
5431 note_legacy_root_file_migration(&path, original_raw)?;
5432 }
5433 replace_config_document_if_unchanged(&path, self.original_raw.as_deref(), &body)?;
5434 self.original_raw = Some(body);
5435 Ok(())
5436 }
5437
5438 /// Refresh the typed value and byte snapshot after a targeted writer used
5439 /// the shared config lock. This keeps a long-lived command process from
5440 /// treating its own successful mutation as an external stale conflict.
5441 pub fn reload(&mut self) -> Result<()> {
5442 *self = Self::load(Some(self.path.clone()))?;
5443 Ok(())
5444 }
5445
5446 #[must_use]
5447 pub fn path(&self) -> &Path {
5448 &self.path
5449 }
5450
5451 #[must_use]
5452 pub fn permissions(&self) -> &PermissionsToml {
5453 &self.permissions
5454 }
5455
5456 #[must_use]
5457 pub fn permissions_path(&self) -> PathBuf {
5458 checked_permissions_path_for_config_path(&self.path)
5459 .expect("ConfigStore path is validated before construction")
5460 }
5461
5462 #[must_use]
5463 pub fn exec_policy_engine(&self) -> ExecPolicyEngine {
5464 if self.permissions.is_empty() {
5465 ExecPolicyEngine::new(Vec::new(), Vec::new())
5466 } else {
5467 ExecPolicyEngine::with_rulesets(vec![self.permissions.ruleset()])
5468 }
5469 }
5470
5471 /// Atomically append ask-only permission rules to the sibling
5472 /// `permissions.toml` file.
5473 ///
5474 /// Existing comments and formatting are preserved. Exact duplicate rules
5475 /// are ignored, and the in-memory permissions snapshot is refreshed after
5476 /// a successful write.
5477 pub fn append_ask_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
5478 self.append_permission_rules(rules, PermissionAction::Ask)
5479 }
5480
5481 /// Atomically append exact, repo-scoped allow rules to the sibling
5482 /// `permissions.toml` file.
5483 ///
5484 /// The caller is responsible for deciding which tool calls are eligible;
5485 /// this boundary rejects broad or incorrectly typed records so a UI bug
5486 /// cannot persist an unscoped allow grant.
5487 pub fn append_allow_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
5488 for rule in rules {
5489 if rule.action != PermissionAction::Allow {
5490 bail!("append_allow_rules only accepts action = \"allow\"");
5491 }
5492 let Some(workspace) = rule
5493 .workspace
5494 .as_deref()
5495 .and_then(codewhale_execpolicy::normalize_workspace_scope)
5496 else {
5497 bail!("persistent allow rules must be scoped to a workspace");
5498 };
5499 if rule.command.is_some() && !rule.command_exact {
5500 bail!("persistent command allow rules must use exact matching");
5501 }
5502 if rule.command.is_none() && rule.path.is_none() {
5503 bail!("persistent allow rules must match an exact command or path");
5504 }
5505 if let Some(command) = rule.command.as_deref()
5506 && command.trim().is_empty()
5507 {
5508 bail!("persistent command allow rules must not be empty");
5509 }
5510 if let Some(path) = rule.path.as_deref()
5511 && codewhale_execpolicy::normalize_workspace_relative_path(path, &workspace)
5512 .is_none_or(|path| path.is_empty())
5513 {
5514 bail!("persistent path allow rules must stay within the workspace");
5515 }
5516 }
5517 self.append_permission_rules(rules, PermissionAction::Allow)
5518 }
5519
5520 fn append_permission_rules(
5521 &mut self,
5522 rules: &[ToolAskRule],
5523 expected_action: PermissionAction,
5524 ) -> Result<usize> {
5525 if rules.is_empty() {
5526 return Ok(0);
5527 }
5528 if rules.iter().any(|rule| rule.action != expected_action) {
5529 bail!(
5530 "permission rule action does not match requested {:?} persistence",
5531 expected_action
5532 );
5533 }
5534
5535 let path = checked_permissions_path_for_config_path(&self.path)?;
5536 let (added, persisted) = config_document::with_config_write_lock(&path, |path| {
5537 let (_, raw, mut permissions) = read_permissions_state(path)?;
5538 let mut document = parse_permissions_document(path, &raw)?;
5539
5540 if !document.contains_key("rules") {
5541 document["rules"] = toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
5542 }
5543 let rules_item = document
5544 .get_mut("rules")
5545 .expect("rules entry was inserted above");
5546
5547 let mut added = 0;
5548 for rule in rules {
5549 if permissions.rules.contains(rule) {
5550 continue;
5551 }
5552 append_permission_rule(rules_item, rule)?;
5553 permissions.rules.push(rule.clone());
5554 added += 1;
5555 }
5556 if added == 0 {
5557 return Ok((0, permissions));
5558 }
5559
5560 let body = document.to_string();
5561 let persisted = parse_generated_permissions(path, &body)?;
5562 write_permissions_atomic(path, body.as_bytes())?;
5563 Ok((added, persisted))
5564 })?;
5565 self.permissions = persisted;
5566 Ok(added)
5567 }
5568 }
5569
5570 fn config_backup_file_name(path: &Path) -> OsString {
5571 let mut file_name = path
5572 .file_name()
5573 .map(OsString::from)
5574 .unwrap_or_else(|| OsString::from(CONFIG_FILE_NAME));
5575 file_name.push(".bak");
5576 file_name
5577 }
5578
5579 fn config_sibling_path_unchecked(config_path: &Path, file_name: &OsStr) -> PathBuf {
5580 config_path
5581 .parent()
5582 .unwrap_or_else(|| Path::new("."))
5583 .join(file_name)
5584 }
5585
5586 fn checked_config_sibling_path(config_path: &Path, file_name: &OsStr) -> Result<PathBuf> {
5587 let config_path = normalize_config_file_path(config_path.to_path_buf())?;
5588 let parent = config_path
5589 .parent()
5590 .context("config path must include a parent directory")?;
5591 let path = parent.join(file_name);
5592 reject_path_symlink(&path)?;
5593 Ok(path)
5594 }
5595
5596 #[cfg(test)]
5597 fn config_backup_path(path: &Path) -> PathBuf {
5598 config_sibling_path_unchecked(path, &config_backup_file_name(path))
5599 }
5600
5601 fn checked_config_backup_path(path: &Path) -> Result<PathBuf> {
5602 checked_config_sibling_path(path, &config_backup_file_name(path))
5603 }
5604
5605 /// Remove plaintext `api_key` entries from the one-time config backup, if it
5606 /// exists.
5607 ///
5608 /// Credential migration deliberately preserves the rest of `config.toml.bak`
5609 /// while ensuring that moving a key into the durable secret store does not
5610 /// leave the same credential behind in an older backup.
5611 pub fn scrub_plaintext_api_keys_from_config_backup(path: &Path) -> Result<()> {
5612 scrub_credentials_from_backup_file(&checked_config_backup_path(path)?)
5613 }
5614
5615 /// Rewrite an existing backup without credential-named keys; see
5616 /// [`config_toml_without_plaintext_api_keys`].
5617 fn scrub_credentials_from_backup_file(backup: &Path) -> Result<()> {
5618 if !backup.exists() {
5619 return Ok(());
5620 }
5621
5622 let raw = read_checked_toml_file(backup, "config backup")?;
5623 let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
5624 format!(
5625 "failed to scrub plaintext API keys from config backup {}",
5626 backup.display()
5627 )
5628 })?;
5629 if scrubbed != raw {
5630 persistence::atomic_write(backup, scrubbed.as_bytes()).with_context(|| {
5631 format!(
5632 "failed to write credential-free config backup {}",
5633 backup.display()
5634 )
5635 })?;
5636 }
5637 Ok(())
5638 }
5639
5640 /// Remove only retired Antigravity state from Codewhale's one-time config
5641 /// backup. This never resolves, reads, writes, or revokes any external Google
5642 /// or Antigravity session; it edits only the checked sibling `.bak` file owned
5643 /// by Codewhale.
5644 pub fn scrub_legacy_antigravity_from_config_backup(path: &Path) -> Result<()> {
5645 let backup = checked_config_backup_path(path)?;
5646 if !backup.exists() {
5647 return Ok(());
5648 }
5649
5650 let raw = read_checked_toml_file(&backup, "config backup")?;
5651 let scrubbed = config_toml_without_legacy_antigravity(&raw).with_context(|| {
5652 format!(
5653 "failed to clear retired provider state from config backup {}",
5654 backup.display()
5655 )
5656 })?;
5657 if scrubbed != raw {
5658 persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
5659 format!(
5660 "failed to write retired-provider-free config backup {}",
5661 backup.display()
5662 )
5663 })?;
5664 }
5665 Ok(())
5666 }
5667
5668 fn config_toml_without_legacy_antigravity(raw: &str) -> Result<String> {
5669 let mut document = raw.parse::<toml_edit::DocumentMut>().map_err(|_| {
5670 anyhow::anyhow!(
5671 "failed to parse config TOML while clearing retired provider state; file contents were omitted"
5672 )
5673 })?;
5674 let root = document.as_table_mut();
5675
5676 if root
5677 .get("provider")
5678 .and_then(toml_edit::Item::as_str)
5679 .is_some_and(is_legacy_antigravity_name)
5680 {
5681 root.remove("provider");
5682 }
5683 if let Some(fallbacks) = root
5684 .get_mut("fallback_providers")
5685 .and_then(toml_edit::Item::as_array_mut)
5686 {
5687 fallbacks.retain(|value| !value.as_str().is_some_and(is_legacy_antigravity_name));
5688 if fallbacks.is_empty() {
5689 root.remove("fallback_providers");
5690 }
5691 }
5692 if let Some(providers) = root
5693 .get_mut("providers")
5694 .and_then(toml_edit::Item::as_table_like_mut)
5695 {
5696 providers.remove("antigravity");
5697 providers.remove("agy");
5698 }
5699
5700 Ok(document.to_string())
5701 }
5702
5703 fn is_legacy_antigravity_name(value: &str) -> bool {
5704 value.eq_ignore_ascii_case("antigravity") || value.eq_ignore_ascii_case("agy")
5705 }
5706
5707 /// Before a write moves legacy top-level keys (#6394), keep one credential-free
5708 /// copy of the file as it was, and queue a one-line notice for the caller's
5709 /// status channel. Does nothing when the original has nothing to move.
5710 pub(crate) fn note_legacy_root_file_migration(path: &Path, original_raw: &str) -> Result<()> {
5711 let Ok(document) = original_raw.parse::<toml_edit::DocumentMut>() else {
5712 return Ok(());
5713 };
5714 let receipt = legacy_root::preview_document(&document, None);
5715 if !receipt.changes_file() {
5716 return Ok(());
5717 }
5718 let backup = write_legacy_root_backup(path, original_raw)?;
5719 legacy_root::queue_notice(&receipt, &backup);
5720 Ok(())
5721 }
5722
5723 /// Keep one credential-free copy of the file as it was before legacy
5724 /// top-level keys first moved. Later migrations never overwrite it.
5725 pub(crate) fn write_legacy_root_backup(path: &Path, original_raw: &str) -> Result<PathBuf> {
5726 let backup = checked_config_sibling_path(path, &pre_migrate_backup_file_name(path))?;
5727 if backup.exists() {
5728 // Written once and kept; still repair one left by a release that
5729 // scrubbed only `api_key`. Best effort: an unreadable old backup must
5730 // not block the save that is moving keys now.
5731 if let Err(err) = scrub_credentials_from_backup_file(&backup) {
5732 tracing::warn!("{err:#}");
5733 }
5734 } else {
5735 let scrubbed = config_toml_without_plaintext_api_keys(original_raw)?;
5736 persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
5737 format!(
5738 "failed to create config backup {} before moving top-level keys",
5739 backup.display()
5740 )
5741 })?;
5742 }
5743 Ok(backup)
5744 }
5745
5746 fn pre_migrate_backup_file_name(path: &Path) -> OsString {
5747 let mut file_name = path
5748 .file_name()
5749 .map(OsString::from)
5750 .unwrap_or_else(|| OsString::from(CONFIG_FILE_NAME));
5751 file_name.push(".pre-migrate.bak");
5752 file_name
5753 }
5754
5755 /// Path of the one-time backup written before legacy top-level keys moved.
5756 pub fn legacy_root_backup_path(path: &Path) -> Result<PathBuf> {
5757 checked_config_sibling_path(path, &pre_migrate_backup_file_name(path))
5758 }
5759
5760 fn write_one_time_config_backup(path: &Path) -> Result<()> {
5761 let backup = checked_config_backup_path(path)?;
5762 if backup.exists() {
5763 return scrub_plaintext_api_keys_from_config_backup(path);
5764 }
5765
5766 let raw = read_checked_config_file(path)?;
5767 let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
5768 format!(
5769 "failed to scrub plaintext API keys while creating config backup {}",
5770 backup.display()
5771 )
5772 })?;
5773 persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
5774 format!(
5775 "failed to create credential-free config backup {} from {}",
5776 backup.display(),
5777 path.display()
5778 )
5779 })?;
5780 Ok(())
5781 }
5782
5783 fn config_toml_without_plaintext_api_keys(raw: &str) -> Result<String> {
5784 let mut document = raw
5785 .parse::<toml_edit::DocumentMut>()
5786 .map_err(|_| {
5787 anyhow::anyhow!(
5788 "failed to parse config TOML while removing plaintext API keys; file contents were omitted"
5789 )
5790 })?;
5791 remove_plaintext_api_keys_recursive(document.as_table_mut());
5792 scrub_backup_decor(document.as_table_mut().decor_mut());
5793 if let Some(trailing) = document.trailing().as_str().map(scrub_backup_comments) {
5794 document.set_trailing(trailing);
5795 }
5796 Ok(document.to_string())
5797 }
5798
5799 /// Drop every credential-named key ([`is_sensitive_config_key`]: `api_key`,
5800 /// `webhook_token`, `Authorization` under `http_headers`, ...) at any depth,
5801 /// including tables inside arrays. The backups are kept indefinitely and are
5802 /// described as credential-free, so a rotated secret must not live on there.
5803 fn remove_plaintext_api_keys_recursive(table: &mut dyn toml_edit::TableLike) {
5804 let sensitive: Vec<String> = table
5805 .iter()
5806 .filter(|(key, item)| {
5807 is_sensitive_config_key(key) || item.as_value().is_some_and(backup_value_carries_secret)
5808 })
5809 .map(|(key, _)| key.to_owned())
5810 .collect();
5811 for key in sensitive {
5812 // Keep a comment written above the key (often the file header).
5813 config_document::remove_key_preserving_leading_decor(table, &key);
5814 }
5815 for (mut key, item) in table.iter_mut() {
5816 scrub_backup_decor(key.leaf_decor_mut());
5817 if let toml_edit::Item::Table(nested) = item {
5818 scrub_backup_decor(nested.decor_mut());
5819 }
5820 if let toml_edit::Item::Value(value) = item {
5821 scrub_backup_decor(value.decor_mut());
5822 }
5823 match item {
5824 toml_edit::Item::ArrayOfTables(tables) => {
5825 for nested in tables.iter_mut() {
5826 scrub_backup_decor(nested.decor_mut());
5827 remove_plaintext_api_keys_recursive(nested);
5828 }
5829 }
5830 toml_edit::Item::Value(toml_edit::Value::Array(array)) => {
5831 remove_plaintext_api_keys_in_array(array);
5832 }
5833 _ => {
5834 if let Some(nested) = item.as_table_like_mut() {
5835 remove_plaintext_api_keys_recursive(nested);
5836 }
5837 }
5838 }
5839 }
5840 }
5841
5842 fn remove_plaintext_api_keys_in_array(array: &mut toml_edit::Array) {
5843 array.retain(|value| !backup_value_carries_secret(value));
5844 for value in array.iter_mut() {
5845 scrub_backup_decor(value.decor_mut());
5846 match value {
5847 toml_edit::Value::InlineTable(table) => remove_plaintext_api_keys_recursive(table),
5848 toml_edit::Value::Array(nested) => remove_plaintext_api_keys_in_array(nested),
5849 _ => {}
5850 }
5851 }
5852 }
5853
5854 fn backup_value_carries_secret(value: &toml_edit::Value) -> bool {
5855 value
5856 .as_str()
5857 .is_some_and(codewhale_secrets::sanitize::contains_secret)
5858 }
5859
5860 fn scrub_backup_decor(decor: &mut toml_edit::Decor) {
5861 let prefix = decor
5862 .prefix()
5863 .and_then(|text| text.as_str())
5864 .map(scrub_backup_comments);
5865 let suffix = decor
5866 .suffix()
5867 .and_then(|text| text.as_str())
5868 .map(scrub_backup_comments);
5869 if let Some(prefix) = prefix {
5870 decor.set_prefix(prefix);
5871 }
5872 if let Some(suffix) = suffix {
5873 decor.set_suffix(suffix);
5874 }
5875 }
5876
5877 fn scrub_backup_comments(text: &str) -> String {
5878 text.split_inclusive('\n')
5879 .filter(|line| {
5880 !line
5881 .trim_start()
5882 .strip_prefix('#')
5883 .map(str::trim)
5884 .is_some_and(|comment| {
5885 comment
5886 .split_once('=')
5887 .is_some_and(|(key, _)| is_sensitive_config_key(key))
5888 || codewhale_secrets::sanitize::contains_secret(comment)
5889 })
5890 })
5891 .collect()
5892 }
5893
5894 /// Merge comments and formatting from an original TOML file into a
5895 /// freshly serialized document so user annotations (comments, whitespace,
5896 /// disabled keys) survive config rewrites.
5897 ///
5898 /// `original_raw` is the raw text of the file before the change; the
5899 /// function parses it internally with [`toml_edit`] so callers stay free
5900 /// of that dependency.
5901 pub fn merge_and_preserve_comments(serialized: &str, original_raw: &str) -> Result<String> {
5902 let original = original_raw
5903 .parse::<toml_edit::DocumentMut>()
5904 .map_err(|_| {
5905 anyhow::anyhow!(
5906 "failed to parse original config for comment merge; file contents were omitted"
5907 )
5908 })?;
5909
5910 let mut new_doc = serialized.parse::<toml_edit::DocumentMut>().map_err(|_| {
5911 anyhow::anyhow!(
5912 "failed to parse serialized config for comment merge; file contents were omitted"
5913 )
5914 })?;
5915
5916 // Reuse the original document’s trailing text (file-footer comments /
5917 // disabled keys) so they survive the rewrite.
5918 new_doc.set_trailing(original.trailing().clone());
5919
5920 // Copy the top-level table's decor (document-header comments, whitespace
5921 // before the first key) which `toml_edit` stores on the root `Table` itself.
5922 *new_doc.as_table_mut().decor_mut() = original.as_table().decor().clone();
5923
5924 merge_decor_table(new_doc.as_table_mut(), original.as_table());
5925
5926 Ok(new_doc.to_string())
5927 }
5928
5929 /// Recursively copy `decor` (prefix/suffix comments and whitespace) from
5930 /// every key in `source` that also exists in `target`.
5931 fn merge_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
5932 // Collect keys first — the borrow checker won't let us hold
5933 // `get_key_value_mut` while iterating.
5934 let keys: Vec<String> = source.iter().map(|(k, _)| k.to_owned()).collect();
5935 for key in &keys {
5936 let Some((source_key, source_item)) = source.get_key_value(key) else {
5937 continue;
5938 };
5939 let Some((mut target_key_mut, target_item)) = target.get_key_value_mut(key) else {
5940 continue;
5941 };
5942
5943 // Copy the key-level decor (comments before the key itself)
5944 *target_key_mut.leaf_decor_mut() = source_key.leaf_decor().clone();
5945
5946 copy_item_decor(target_item, source_item);
5947
5948 if let (Some(tt), Some(st)) = (target_item.as_table_mut(), source_item.as_table()) {
5949 merge_decor_table(tt, st);
5950 }
5951
5952 if let (Some(ta), Some(sa)) = (
5953 target_item.as_array_of_tables_mut(),
5954 source_item.as_array_of_tables(),
5955 ) {
5956 for (i, source_table) in sa.iter().enumerate() {
5957 if let Some(target_table) = ta.get_mut(i) {
5958 copy_item_decor_table(target_table, source_table);
5959 merge_decor_table(target_table, source_table);
5960 }
5961 }
5962 }
5963 }
5964 }
5965
5966 /// Copy the decor (comments and surrounding whitespace) from `source` to `target`,
5967 /// respecting the concrete item type since [`toml_edit::Item`] has no uniform
5968 /// `decor` accessor.
5969 fn copy_item_decor(target: &mut toml_edit::Item, source: &toml_edit::Item) {
5970 match (target, source) {
5971 (toml_edit::Item::Table(tt), toml_edit::Item::Table(st)) => {
5972 *tt.decor_mut() = st.decor().clone();
5973 }
5974 (toml_edit::Item::Value(tv), toml_edit::Item::Value(sv)) => {
5975 *tv.decor_mut() = sv.decor().clone();
5976 }
5977 _ => {}
5978 }
5979 }
5980
5981 fn copy_item_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
5982 *target.decor_mut() = source.decor().clone();
5983 }
5984
5985 // ── CodeWhale state root (v0.8.44) ──────────────────────────────────
5986 //
5987 // v0.8.44 migrates product-owned app state from ~/.deepseek/ to
5988 // ~/.codewhale/ while keeping ~/.deepseek/ as a compatibility fallback.
5989 // New installs write to ~/.codewhale/. Existing installs with only
5990 // ~/.deepseek/ continue working without data loss.
5991
5992 pub use codewhale_paths::{CODEWHALE_APP_DIR, LEGACY_APP_DIR};
5993
5994 /// Resolve the primary CodeWhale home directory.
5995 ///
5996 /// `$CODEWHALE_HOME` takes precedence when set. Otherwise defaults to
5997 /// `$HOME/.codewhale`. This is the write target for new product state.
5998 pub fn codewhale_home() -> Result<PathBuf> {
5999 codewhale_paths::codewhale_home()
6000 .map_err(anyhow::Error::new)?
6001 .context("failed to resolve home directory")
6002 }
6003
6004 /// Whether `$CODEWHALE_HOME` is set to a non-empty value.
6005 ///
6006 /// An explicit CodeWhale home is an isolation boundary: state/config resolvers
6007 /// must not fall back to ambient legacy `~/.deepseek` data outside that root.
6008 pub fn codewhale_home_is_explicit() -> bool {
6009 codewhale_paths::codewhale_home_is_explicit()
6010 }
6011
6012 /// Resolve the legacy DeepSeek home directory (`$HOME/.deepseek`).
6013 ///
6014 /// Always returns the legacy path regardless of whether it exists.
6015 pub fn legacy_deepseek_home() -> Result<PathBuf> {
6016 codewhale_paths::legacy_deepseek_home().context("failed to resolve home directory")
6017 }
6018
6019 /// Reject state subdirs that could escape the state root via path injection.
6020 ///
6021 /// `ensure_state_dir` / `resolve_state_dir` are public APIs taking an arbitrary
6022 /// subdir string; every in-tree caller passes a hardcoded single component
6023 /// (e.g. `"sessions"`, `"."`). This validates defensively so a future caller
6024 /// can never traverse out of the state root via `..` components or an absolute
6025 /// path. Nested relative paths such as `"a/b"` are permitted.
6026 fn ensure_safe_state_subdir(subdir: &str) -> Result<()> {
6027 if subdir.is_empty() {
6028 bail!("state subdir must not be empty");
6029 }
6030 let path = std::path::Path::new(subdir);
6031 if path.is_absolute() {
6032 bail!("state subdir must not be an absolute path: {subdir}");
6033 }
6034 if path.components().any(|c| {
6035 matches!(
6036 c,
6037 std::path::Component::RootDir | std::path::Component::Prefix(_)
6038 )
6039 }) {
6040 bail!("state subdir must not contain a root or prefix: {subdir}");
6041 }
6042 if path
6043 .components()
6044 .any(|c| matches!(c, std::path::Component::ParentDir))
6045 {
6046 bail!("state subdir must not contain parent-dir (..) components: {subdir}");
6047 }
6048 Ok(())
6049 }
6050
6051 /// Resolve a state subdirectory, preferring the CodeWhale root if
6052 /// it already exists, otherwise falling back to the legacy root.
6053 ///
6054 /// This is the read-path resolver: it returns the primary path when
6055 /// migration has occurred or on a fresh install, but keeps reading
6056 /// from the legacy path for users who haven't migrated yet.
6057 pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
6058 ensure_safe_state_subdir(subdir)?;
6059 let explicit_codewhale_home = codewhale_home_is_explicit();
6060 let primary = codewhale_home()?.join(subdir);
6061 if explicit_codewhale_home || primary.exists() {
6062 return Ok(primary);
6063 }
6064 let legacy = legacy_deepseek_home()?.join(subdir);
6065 if legacy.exists() {
6066 return Ok(legacy);
6067 }
6068 // Neither exists — return primary for first-write creation.
6069 Ok(primary)
6070 }
6071
6072 /// Ensure a state subdirectory exists under the primary CodeWhale root,
6073 /// creating it if necessary. This is the write-path resolver.
6074 ///
6075 /// On the first creation of a real subdirectory (not the root sentinel `"."`),
6076 /// if a legacy `~/.deepseek/<subdir>` exists but the primary
6077 /// `~/.codewhale/<subdir>` does not, the legacy directory is relocated into
6078 /// the primary location so the user keeps their data and the legacy tree
6079 /// stops growing (#3240). After migration, [`resolve_state_dir`] finds the
6080 /// data in the primary location; the read resolver itself is unchanged.
6081 pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
6082 let (dir, migration) = ensure_state_dir_with_migration(subdir)?;
6083 if let Some(migration) = migration {
6084 eprintln!("{}", migration.user_notice());
6085 }
6086 Ok(dir)
6087 }
6088
6089 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
6090 pub enum StateMigrationKind {
6091 Relocated,
6092 Copied,
6093 }
6094
6095 #[derive(Debug, Clone, PartialEq, Eq)]
6096 pub struct StateMigration {
6097 pub subdir: String,
6098 pub legacy_path: PathBuf,
6099 pub primary_path: PathBuf,
6100 pub kind: StateMigrationKind,
6101 }
6102
6103 impl StateMigration {
6104 pub fn user_notice(&self) -> String {
6105 let action = match self.kind {
6106 StateMigrationKind::Relocated => "relocated",
6107 StateMigrationKind::Copied => "copied",
6108 };
6109 let legacy_detail = match self.kind {
6110 StateMigrationKind::Relocated => {
6111 "The legacy .deepseek copy for this state path was removed by the move."
6112 }
6113 StateMigrationKind::Copied => {
6114 "The legacy .deepseek copy was left in place because a direct move failed."
6115 }
6116 };
6117
6118 format!(
6119 "Codewhale migrated legacy state ({action}):\n {} -> {}\nYour data was preserved. Use .codewhale as the canonical state location from now on.\n{legacy_detail}\nIf no other apps use it, you can remove the legacy .deepseek tree after confirming everything looks right.",
6120 self.legacy_path.display(),
6121 self.primary_path.display(),
6122 )
6123 }
6124 }
6125
6126 /// Variant of [`ensure_state_dir`] that exposes whether a legacy state path was
6127 /// migrated. Most callers should use [`ensure_state_dir`]; this is kept for
6128 /// tests and future UI surfaces that want to render the notice themselves.
6129 pub fn ensure_state_dir_with_migration(subdir: &str) -> Result<(PathBuf, Option<StateMigration>)> {
6130 ensure_safe_state_subdir(subdir)?;
6131 let explicit_codewhale_home = codewhale_home_is_explicit();
6132 let dir = codewhale_home()?.join(subdir);
6133 let migration = if explicit_codewhale_home {
6134 None
6135 } else {
6136 match migrate_legacy_state_dir(&dir, subdir)? {
6137 LegacyStateMigration::NotNeeded => None,
6138 LegacyStateMigration::Migrated(migration) => Some(migration),
6139 // Creating an empty primary here would make it authoritative and
6140 // hide the legacy data for good. Keep using the legacy directory
6141 // (the read resolver does the same) and retry on the next call.
6142 LegacyStateMigration::Failed { legacy } => return Ok((legacy, None)),
6143 }
6144 };
6145 std::fs::create_dir_all(&dir)
6146 .with_context(|| format!("failed to create {}/", dir.display()))?;
6147 Ok((dir, migration))
6148 }
6149
6150 enum LegacyStateMigration {
6151 NotNeeded,
6152 Migrated(StateMigration),
6153 /// Neither the move nor the copy completed. The primary was left absent,
6154 /// so the legacy directory stays authoritative and migration retries.
6155 Failed {
6156 legacy: PathBuf,
6157 },
6158 }
6159
6160 /// One-time relocation of a legacy `~/.deepseek/<subdir>` state directory into
6161 /// the primary `~/.codewhale/<subdir>` location (#3240). No-op once the primary
6162 /// exists, for the root sentinel `"."` (a whole-tree move is owned by the
6163 /// config-file migration), or when no legacy directory is present.
6164 fn migrate_legacy_state_dir(primary: &Path, subdir: &str) -> Result<LegacyStateMigration> {
6165 if primary.exists() || subdir == "." || subdir.is_empty() {
6166 return Ok(LegacyStateMigration::NotNeeded);
6167 }
6168 let legacy = match legacy_deepseek_home() {
6169 Ok(home) => home.join(subdir),
6170 Err(_) => return Ok(LegacyStateMigration::NotNeeded),
6171 };
6172 if !legacy.exists() {
6173 return Ok(LegacyStateMigration::NotNeeded);
6174 }
6175 // The primary's parent (the ~/.codewhale root) must exist for the rename.
6176 if let Some(parent) = primary.parent()
6177 && let Err(err) = std::fs::create_dir_all(parent)
6178 {
6179 tracing::warn!(
6180 target: "config::migration",
6181 "Could not create {} for state migration ({}); writing to primary anyway",
6182 parent.display(),
6183 err
6184 );
6185 }
6186 match std::fs::rename(&legacy, primary) {
6187 Ok(()) => {
6188 tracing::info!(
6189 target: "config::migration",
6190 "Migrated legacy state directory {} -> {} (relocated). The .deepseek copy was removed.",
6191 legacy.display(),
6192 primary.display()
6193 );
6194 return Ok(LegacyStateMigration::Migrated(StateMigration {
6195 subdir: subdir.to_string(),
6196 legacy_path: legacy,
6197 primary_path: primary.to_path_buf(),
6198 kind: StateMigrationKind::Relocated,
6199 }));
6200 }
6201 Err(err) => {
6202 // Cross-device rename or permission issue: fall back to a
6203 // recursive copy so the user keeps their data. The legacy tree is
6204 // left in place; it stops growing because writes now target the
6205 // primary path.
6206 match copy_dir_into_place(&legacy, primary) {
6207 Ok(()) => {
6208 tracing::info!(
6209 target: "config::migration",
6210 "Migrated legacy state directory {} -> {} (copied; rename failed: {err}). \
6211 The legacy .deepseek copy was left in place.",
6212 legacy.display(),
6213 primary.display()
6214 );
6215 return Ok(LegacyStateMigration::Migrated(StateMigration {
6216 subdir: subdir.to_string(),
6217 legacy_path: legacy,
6218 primary_path: primary.to_path_buf(),
6219 kind: StateMigrationKind::Copied,
6220 }));
6221 }
6222 Err(copy_err) => {
6223 // A concurrent process may have completed the move/copy
6224 // after our initial check. Use that completed primary,
6225 // rather than resuming writes to the legacy tree.
6226 if primary.is_dir() {
6227 return Ok(LegacyStateMigration::NotNeeded);
6228 }
6229 tracing::warn!(
6230 target: "config::migration",
6231 "Could not migrate legacy state {} -> {} (rename: {err}; copy: {copy_err:#}). \
6232 The legacy path stays in use and migration retries next time.",
6233 legacy.display(),
6234 primary.display()
6235 );
6236 }
6237 }
6238 }
6239 }
6240 Ok(LegacyStateMigration::Failed { legacy })
6241 }
6242
6243 /// Copy `src` to a staging sibling of `dst` and rename it into place, so a
6244 /// copy that fails midway never leaves a partial `dst` that later runs would
6245 /// treat as a completed migration.
6246 fn copy_dir_into_place(src: &Path, dst: &Path) -> Result<()> {
6247 let mut staging_name = OsString::from(".");
6248 staging_name.push(dst.file_name().unwrap_or_default());
6249 staging_name.push(".migrating-");
6250 // Each attempt owns only its unique private directory. A fixed sibling
6251 // could be another process's active copy and must never be cleared.
6252 let staging = tempfile::Builder::new().prefix(&staging_name).tempdir_in(
6253 dst.parent()
6254 .context("migration destination has no parent")?,
6255 )?;
6256 copy_dir_recursive(src, staging.path())?;
6257 std::fs::rename(staging.path(), dst).with_context(|| {
6258 format!(
6259 "failed to move {} into place at {}",
6260 staging.path().display(),
6261 dst.display()
6262 )
6263 })
6264 }
6265
6266 /// Recursively copy a directory tree from `src` to `dst`, creating `dst`.
6267 /// Symlinks and other non-file/non-dir entries are skipped (rare in state dirs).
6268 fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
6269 std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
6270 for entry in
6271 std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
6272 {
6273 let entry = entry.with_context(|| format!("failed to read entry in {}", src.display()))?;
6274 let path = entry.path();
6275 let target = dst.join(entry.file_name());
6276 let file_type = entry
6277 .file_type()
6278 .with_context(|| format!("failed to read file type for {}", path.display()))?;
6279 if file_type.is_dir() {
6280 copy_dir_recursive(&path, &target)?;
6281 } else if file_type.is_file() {
6282 std::fs::copy(&path, &target).with_context(|| {
6283 format!("failed to copy {} -> {}", path.display(), target.display())
6284 })?;
6285 }
6286 }
6287 Ok(())
6288 }
6289
6290 /// Resolve a project-local state subdirectory, preferring `.codewhale/`
6291 /// when it exists, falling back to `.deepseek/` for legacy projects.
6292 ///
6293 /// Returns `(true, path)` when the primary `.codewhale/` path is used,
6294 /// `(false, path)` for the legacy fallback. The boolean helps callers
6295 /// emit a deprecation notice on legacy paths.
6296 pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> Result<(bool, PathBuf)> {
6297 ensure_safe_state_subdir(subdir)?;
6298 let workspace = normalize_project_workspace(workspace)?;
6299 let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
6300 if primary.exists() {
6301 return Ok((true, primary));
6302 }
6303 let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
6304 Ok((false, legacy))
6305 }
6306
6307 /// Ensure a project-local state subdirectory exists under `.codewhale/`,
6308 /// creating it if necessary. Returns the directory path.
6309 pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
6310 ensure_safe_state_subdir(subdir)?;
6311 let workspace = normalize_project_workspace(workspace)?;
6312 let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
6313 std::fs::create_dir_all(&dir)
6314 .with_context(|| format!("failed to create {}/", dir.display()))?;
6315 Ok(dir)
6316 }
6317
6318 pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
6319 if let Some(path) = explicit {
6320 return normalize_config_file_path(path);
6321 }
6322 if let Some(path) = codewhale_paths::config_path_override().map_err(anyhow::Error::new)? {
6323 return normalize_config_file_path(path);
6324 }
6325 default_config_path()
6326 }
6327
6328 /// Whether `path` names a workspace-scoped config document —
6329 /// `<repo>/.codewhale/config.toml` (or the legacy `.deepseek` layout) inside a
6330 /// checkout — rather than a user-global config file.
6331 ///
6332 /// Credential writes (api_key values, `auth_mode` markers, oauth/external
6333 /// credential pointers) must never target such a document: a key saved while
6334 /// working in one repo would be invisible from every other repo, and the repo
6335 /// file stores it in plaintext where it is easy to commit by accident (#5045,
6336 /// #5193).
6337 ///
6338 /// A path is classified workspace-scoped only when its parent directory is a
6339 /// `.codewhale`/`.deepseek` app dir outside the user's home AND the document
6340 /// belongs to a workspace: it is relative (resolves against the process cwd),
6341 /// its base directory contains the process cwd, or its base directory is a
6342 /// checkout (has a `.git` entry). An explicit `$CODEWHALE_HOME` config is
6343 /// user-global wherever that home points, even when the directory itself
6344 /// happens to be named `.codewhale`; other custom locations (for example
6345 /// `CODEWHALE_CONFIG_PATH=~/team.toml` or an isolated test directory) stay
6346 /// honored as deliberate user-scoped choices.
6347 #[must_use]
6348 pub fn config_path_is_workspace_scoped(path: &Path) -> bool {
6349 config_path_is_workspace_scoped_with_context(
6350 path,
6351 codewhale_paths::codewhale_home_override()
6352 .ok()
6353 .flatten()
6354 .as_deref(),
6355 codewhale_paths::user_home().as_deref(),
6356 std::env::current_dir().ok().as_deref(),
6357 )
6358 }
6359
6360 /// Environment-free core of [`config_path_is_workspace_scoped`], split out so
6361 /// scope classification is testable without mutating process-global state.
6362 fn config_path_is_workspace_scoped_with_context(
6363 path: &Path,
6364 explicit_codewhale_home: Option<&Path>,
6365 user_home: Option<&Path>,
6366 current_dir: Option<&Path>,
6367 ) -> bool {
6368 if let Some(home) = explicit_codewhale_home
6369 && same_lexical_or_canonical_path(path, &home.join(CONFIG_FILE_NAME))
6370 {
6371 return false;
6372 }
6373 let Some(parent) = path.parent() else {
6374 return false;
6375 };
6376 let parent_is_app_dir = parent
6377 .file_name()
6378 .and_then(OsStr::to_str)
6379 .is_some_and(|name| name == CODEWHALE_APP_DIR || name == LEGACY_APP_DIR);
6380 if !parent_is_app_dir {
6381 return false;
6382 }
6383 let Some(base) = parent.parent() else {
6384 return true;
6385 };
6386 if let Some(home) = user_home
6387 && same_lexical_or_canonical_path(base, home)
6388 {
6389 return false;
6390 }
6391 if path.is_relative() {
6392 // Resolves against the process cwd: repo-scoped by construction.
6393 return true;
6394 }
6395 // The document belongs to the workspace the process is sitting in…
6396 if let Some(cwd) = current_dir
6397 && canonicalize_or_keep(cwd).starts_with(canonicalize_or_keep(base))
6398 {
6399 return true;
6400 }
6401 // …or to some other checkout (a `.git` entry beside the app dir).
6402 base.join(".git").exists()
6403 }
6404
6405 /// Lexical equality first, canonical equality as a fallback so an existing
6406 /// path still matches through symlinked parents (e.g. `/tmp` on macOS).
6407 fn same_lexical_or_canonical_path(a: &Path, b: &Path) -> bool {
6408 a == b || canonicalize_or_keep(a) == canonicalize_or_keep(b)
6409 }
6410
6411 fn canonicalize_or_keep(path: &Path) -> PathBuf {
6412 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
6413 }
6414
6415 #[cfg(test)]
6416 mod credential_scope_tests {
6417 use super::config_path_is_workspace_scoped_with_context;
6418 use std::path::Path;
6419
6420 #[test]
6421 fn config_inside_current_workspace_is_workspace_scoped() {
6422 let temp = tempfile::tempdir().expect("tempdir");
6423 let repo = temp.path().join("repo");
6424 let cwd = repo.join("nested/dir");
6425 for app_dir in [".codewhale", ".deepseek"] {
6426 let config = repo.join(app_dir).join("config.toml");
6427 assert!(
6428 config_path_is_workspace_scoped_with_context(
6429 &config,
6430 None,
6431 Some(Path::new("/home/user")),
6432 Some(&cwd),
6433 ),
6434 "{} should be workspace-scoped when cwd sits inside the repo",
6435 config.display()
6436 );
6437 }
6438 }
6439
6440 #[test]
6441 fn relative_app_dir_config_is_workspace_scoped() {
6442 assert!(config_path_is_workspace_scoped_with_context(
6443 Path::new(".codewhale/config.toml"),
6444 None,
6445 Some(Path::new("/home/user")),
6446 Some(Path::new("/somewhere/else")),
6447 ));
6448 }
6449
6450 #[test]
6451 fn checkout_config_outside_cwd_is_workspace_scoped_via_git_marker() {
6452 let temp = tempfile::tempdir().expect("tempdir");
6453 let repo = temp.path().join("repo");
6454 std::fs::create_dir_all(repo.join(".git")).expect("git marker");
6455 std::fs::create_dir_all(repo.join(".codewhale")).expect("app dir");
6456 assert!(config_path_is_workspace_scoped_with_context(
6457 &repo.join(".codewhale/config.toml"),
6458 None,
6459 Some(Path::new("/home/user")),
6460 Some(Path::new("/somewhere/else")),
6461 ));
6462 }
6463
6464 #[test]
6465 fn user_global_and_custom_locations_are_not_workspace_scoped() {
6466 let home = Path::new("/home/user");
6467 let elsewhere = Some(Path::new("/somewhere/else"));
6468 for global_config in [
6469 "/home/user/.codewhale/config.toml",
6470 "/home/user/.deepseek/config.toml",
6471 "/home/user/team-config.toml",
6472 "/etc/codewhale/config.toml",
6473 ] {
6474 assert!(
6475 !config_path_is_workspace_scoped_with_context(
6476 Path::new(global_config),
6477 None,
6478 Some(home),
6479 elsewhere,
6480 ),
6481 "{global_config} should stay user-global"
6482 );
6483 }
6484 // An isolated app-dir-shaped location with no workspace relationship
6485 // (no cwd ancestry, no checkout marker) stays honored: test harnesses
6486 // and deliberate overrides point there.
6487 let temp = tempfile::tempdir().expect("tempdir");
6488 assert!(!config_path_is_workspace_scoped_with_context(
6489 &temp.path().join(".codewhale/config.toml"),
6490 None,
6491 Some(home),
6492 elsewhere,
6493 ));
6494 }
6495
6496 #[test]
6497 fn explicit_codewhale_home_config_is_user_global_even_when_dir_is_app_named() {
6498 let temp = tempfile::tempdir().expect("tempdir");
6499 let repo = temp.path().join("repo");
6500 let explicit = repo.join(".codewhale");
6501 // Even with cwd inside the repo, the explicit CODEWHALE_HOME config is
6502 // the user-global scope by definition.
6503 assert!(!config_path_is_workspace_scoped_with_context(
6504 &explicit.join("config.toml"),
6505 Some(&explicit),
6506 Some(Path::new("/home/user")),
6507 Some(&repo),
6508 ));
6509 // A different repo-scoped document is still workspace-scoped.
6510 assert!(config_path_is_workspace_scoped_with_context(
6511 &repo.join("other/.codewhale/config.toml"),
6512 Some(&explicit),
6513 Some(Path::new("/home/user")),
6514 Some(&repo.join("other")),
6515 ));
6516 }
6517 }
6518
6519 #[must_use]
6520 pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
6521 config_sibling_path_unchecked(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
6522 }
6523
6524 fn checked_permissions_path_for_config_path(config_path: &Path) -> Result<PathBuf> {
6525 checked_config_sibling_path(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
6526 }
6527
6528 pub fn resolve_permissions_path(config_path: Option<PathBuf>) -> Result<PathBuf> {
6529 checked_permissions_path_for_config_path(&resolve_config_path(config_path)?)
6530 }
6531
6532 /// Load the active sibling permission rules with confirmation tokens suitable
6533 /// for a later compare-and-remove operation.
6534 pub fn load_permissions_snapshot(config_path: Option<PathBuf>) -> Result<PermissionsSnapshot> {
6535 let path = resolve_permissions_path(config_path)?;
6536 let (file_exists, raw, permissions) = read_permissions_state(&path)?;
6537 let file_state = if !file_exists {
6538 PermissionsFileState::Missing
6539 } else if raw.is_empty() {
6540 PermissionsFileState::Empty
6541 } else {
6542 PermissionsFileState::Present
6543 };
6544 let removal_tokens = (0..permissions.rules.len())
6545 .map(|index| permission_removal_token(&path, &raw, index))
6546 .collect();
6547 Ok(PermissionsSnapshot {
6548 path,
6549 file_state,
6550 permissions,
6551 removal_tokens,
6552 })
6553 }
6554
6555 /// Remove one zero-based permission rule if `expected_token` still describes
6556 /// that exact index in the current file.
6557 ///
6558 /// The file is re-read only after acquiring the same adjacent lock used by
6559 /// append operations. This makes the token check and atomic replacement one
6560 /// transaction, preventing stale list views from deleting a different rule.
6561 pub fn remove_permission_rule(
6562 config_path: Option<PathBuf>,
6563 index: usize,
6564 expected_token: &str,
6565 ) -> Result<ToolAskRule> {
6566 let path = resolve_permissions_path(config_path)?;
6567 config_document::with_config_write_lock(&path, |path| {
6568 let (file_exists, raw, permissions) = read_permissions_state(path)?;
6569 if !file_exists {
6570 bail!(
6571 "permissions changed after they were listed; reload {} and retry",
6572 quote_os_path(path)
6573 );
6574 }
6575 let rule = permissions.rules.get(index).cloned().with_context(|| {
6576 format!(
6577 "permission rule {} no longer exists in {}; list rules again",
6578 index + 1,
6579 quote_os_path(path)
6580 )
6581 })?;
6582 let current_token = permission_removal_token(path, &raw, index);
6583 if current_token != expected_token {
6584 bail!(
6585 "permissions changed after they were listed; reload {} and retry",
6586 quote_os_path(path)
6587 );
6588 }
6589
6590 let mut document = parse_permissions_document(path, &raw)?;
6591 let rules_item = document.get_mut("rules").with_context(|| {
6592 format!(
6593 "permissions at {} no longer contain a rules array",
6594 quote_os_path(path)
6595 )
6596 })?;
6597 let orphaned_header = remove_permission_rule_item(rules_item, index)?;
6598 if let Some(header) = orphaned_header {
6599 let trailing = format!(
6600 "{header}{}",
6601 document.trailing().as_str().unwrap_or_default()
6602 );
6603 document.set_trailing(trailing);
6604 }
6605 let body = document.to_string();
6606 let persisted = parse_generated_permissions(path, &body)?;
6607 if persisted.rules.len() + 1 != permissions.rules.len() {
6608 bail!(
6609 "refusing inconsistent permission removal at {}",
6610 quote_os_path(path)
6611 );
6612 }
6613 write_permissions_atomic(path, body.as_bytes())?;
6614 Ok(rule)
6615 })
6616 }
6617
6618 fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
6619 let permissions_path = checked_permissions_path_for_config_path(config_path)?;
6620 let (_, _, permissions) = read_permissions_state(&permissions_path)?;
6621 Ok(permissions)
6622 }
6623
6624 fn read_permissions_state(path: &Path) -> Result<(bool, String, PermissionsToml)> {
6625 let file_exists = checked_path_exists(path)?;
6626 let raw = if file_exists {
6627 read_checked_permissions_file(path)?
6628 } else {
6629 String::new()
6630 };
6631 let permissions = if raw.trim().is_empty() {
6632 PermissionsToml::default()
6633 } else {
6634 toml::from_str(&raw).map_err(|err| {
6635 anyhow::anyhow!(
6636 "failed to parse permissions at {} ({}); file contents were omitted",
6637 quote_os_path(path),
6638 toml_error_location(Some(&raw), &err)
6639 )
6640 })?
6641 };
6642 Ok((file_exists, raw, permissions))
6643 }
6644
6645 fn parse_permissions_document(path: &Path, raw: &str) -> Result<toml_edit::DocumentMut> {
6646 if raw.trim().is_empty() {
6647 Ok(toml_edit::DocumentMut::new())
6648 } else {
6649 raw.parse::<toml_edit::DocumentMut>().map_err(|_| {
6650 anyhow::anyhow!(
6651 "failed to edit permissions at {}; file contents were omitted",
6652 quote_os_path(path)
6653 )
6654 })
6655 }
6656 }
6657
6658 fn parse_generated_permissions(path: &Path, body: &str) -> Result<PermissionsToml> {
6659 toml::from_str(body).map_err(|_| {
6660 anyhow::anyhow!(
6661 "generated invalid permissions document for {}; file contents were omitted",
6662 quote_os_path(path)
6663 )
6664 })
6665 }
6666
6667 fn permission_removal_token(path: &Path, raw: &str, index: usize) -> String {
6668 let mut hasher = Sha256::new();
6669 hasher.update(b"codewhale-permission-removal-v1\0");
6670 hasher.update(quote_os_path(path).as_bytes());
6671 hasher.update(b"\0");
6672 hasher.update(index.to_le_bytes());
6673 hasher.update(b"\0");
6674 hasher.update(raw.as_bytes());
6675 let digest = hasher.finalize();
6676 let mut token = String::with_capacity(24);
6677 for byte in &digest[..12] {
6678 use std::fmt::Write as _;
6679 let _ = write!(&mut token, "{byte:02x}");
6680 }
6681 token
6682 }
6683
6684 fn append_permission_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
6685 match item {
6686 toml_edit::Item::ArrayOfTables(rules) => {
6687 rules.push(permission_rule_table(rule));
6688 Ok(())
6689 }
6690 toml_edit::Item::Value(value) => {
6691 let Some(rules) = value.as_array_mut() else {
6692 bail!("`rules` in permissions.toml must be an array");
6693 };
6694 rules.push(toml_edit::Value::InlineTable(permission_rule_inline_table(
6695 rule,
6696 )));
6697 Ok(())
6698 }
6699 _ => bail!("`rules` in permissions.toml must be an array"),
6700 }
6701 }
6702
6703 fn remove_permission_rule_item(item: &mut toml_edit::Item, index: usize) -> Result<Option<String>> {
6704 match item {
6705 toml_edit::Item::ArrayOfTables(rules) => {
6706 if index >= rules.len() {
6707 bail!("permission rule index changed before removal");
6708 }
6709 let file_header = if index == 0 {
6710 rules
6711 .get(index)
6712 .and_then(|rule| rule.decor().prefix())
6713 .and_then(toml_edit::RawString::as_str)
6714 .map(str::to_owned)
6715 } else {
6716 None
6717 };
6718 rules.remove(index);
6719 if let Some(header) = file_header.as_deref()
6720 && let Some(next_rule) = rules.get_mut(0)
6721 {
6722 let next_prefix = next_rule
6723 .decor()
6724 .prefix()
6725 .and_then(toml_edit::RawString::as_str)
6726 .unwrap_or_default()
6727 .to_owned();
6728 next_rule
6729 .decor_mut()
6730 .set_prefix(format!("{header}{next_prefix}"));
6731 return Ok(None);
6732 }
6733 Ok(file_header)
6734 }
6735 toml_edit::Item::Value(value) => {
6736 let Some(rules) = value.as_array_mut() else {
6737 bail!("`rules` in permissions.toml must be an array");
6738 };
6739 if index >= rules.len() {
6740 bail!("permission rule index changed before removal");
6741 }
6742 rules.remove(index);
6743 Ok(None)
6744 }
6745 _ => bail!("`rules` in permissions.toml must be an array"),
6746 }
6747 }
6748
6749 fn permission_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
6750 let mut table = toml_edit::Table::new();
6751 table["tool"] = toml_edit::value(rule.tool.clone());
6752 if let Some(command) = rule.command.as_deref() {
6753 table["command"] = toml_edit::value(command);
6754 }
6755 if rule.command_exact {
6756 table["command_exact"] = toml_edit::value(true);
6757 }
6758 if let Some(path) = rule.path.as_deref() {
6759 table["path"] = toml_edit::value(path);
6760 }
6761 if let Some(workspace) = rule.workspace.as_deref() {
6762 table["workspace"] = toml_edit::value(workspace);
6763 }
6764 if rule.action != PermissionAction::Ask {
6765 table["action"] = toml_edit::value(match rule.action {
6766 PermissionAction::Allow => "allow",
6767 PermissionAction::Ask => "ask",
6768 PermissionAction::Deny => "deny",
6769 });
6770 }
6771 table
6772 }
6773
6774 fn permission_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
6775 let mut table = toml_edit::InlineTable::new();
6776 table.insert("tool", toml_edit::Value::from(rule.tool.clone()));
6777 if let Some(command) = rule.command.as_deref() {
6778 table.insert("command", toml_edit::Value::from(command));
6779 }
6780 if rule.command_exact {
6781 table.insert("command_exact", toml_edit::Value::from(true));
6782 }
6783 if let Some(path) = rule.path.as_deref() {
6784 table.insert("path", toml_edit::Value::from(path));
6785 }
6786 if let Some(workspace) = rule.workspace.as_deref() {
6787 table.insert("workspace", toml_edit::Value::from(workspace));
6788 }
6789 if rule.action != PermissionAction::Ask {
6790 table.insert(
6791 "action",
6792 toml_edit::Value::from(match rule.action {
6793 PermissionAction::Allow => "allow",
6794 PermissionAction::Ask => "ask",
6795 PermissionAction::Deny => "deny",
6796 }),
6797 );
6798 }
6799 table
6800 }
6801
6802 fn write_permissions_atomic(path: &Path, body: &[u8]) -> Result<()> {
6803 let parent = path.parent().with_context(|| {
6804 format!(
6805 "permissions path has no parent directory: {}",
6806 path.display()
6807 )
6808 })?;
6809 fs::create_dir_all(parent).with_context(|| {
6810 format!(
6811 "failed to create permissions directory {}",
6812 parent.display()
6813 )
6814 })?;
6815
6816 let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
6817 format!(
6818 "failed to create temporary permissions file in {}",
6819 parent.display()
6820 )
6821 })?;
6822 #[cfg(unix)]
6823 temporary
6824 .as_file()
6825 .set_permissions(fs::Permissions::from_mode(0o600))
6826 .with_context(|| {
6827 format!(
6828 "failed to secure temporary permissions file for {}",
6829 path.display()
6830 )
6831 })?;
6832 temporary
6833 .write_all(body)
6834 .with_context(|| format!("failed to write permissions at {}", path.display()))?;
6835 temporary
6836 .as_file()
6837 .sync_all()
6838 .with_context(|| format!("failed to sync permissions at {}", path.display()))?;
6839 temporary
6840 .persist(path)
6841 .map_err(|error| error.error)
6842 .with_context(|| format!("failed to replace permissions at {}", path.display()))?;
6843 Ok(())
6844 }
6845
6846 pub fn default_config_path() -> Result<PathBuf> {
6847 // Prefer ~/.codewhale/config.toml when it exists (fresh install or
6848 // migrated), otherwise fall back to ~/.deepseek/config.toml.
6849 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
6850 if codewhale_home_is_explicit() || primary.exists() {
6851 return Ok(primary);
6852 }
6853 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
6854 if legacy.exists() {
6855 return Ok(legacy);
6856 }
6857 // Neither exists — return primary so first write creates it there.
6858 Ok(primary)
6859 }
6860
6861 #[derive(Debug, Clone, PartialEq, Eq)]
6862 pub struct ConfigMigration {
6863 pub legacy_path: PathBuf,
6864 pub primary_path: PathBuf,
6865 }
6866
6867 impl ConfigMigration {
6868 pub fn user_notice(&self) -> String {
6869 format!(
6870 "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.",
6871 self.legacy_path.display(),
6872 self.primary_path.display()
6873 )
6874 }
6875 }
6876
6877 /// v0.8.44: one-time migration from `~/.deepseek/config.toml` to
6878 /// `~/.codewhale/config.toml`. Called on first launch after the config
6879 /// is loaded; copies the legacy file if the primary doesn't exist yet.
6880 /// Never overwrites an existing primary config.
6881 pub fn migrate_config_if_needed() -> Result<Option<ConfigMigration>> {
6882 if codewhale_home_is_explicit() {
6883 return Ok(None);
6884 }
6885 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
6886 // `exists()` follows links, so a dangling link would read as "absent" and
6887 // the copy below would create whatever it points at. A link at the primary
6888 // name is refused instead, dangling or not.
6889 reject_path_symlink(&primary)?;
6890 if primary.exists() {
6891 return Ok(None);
6892 }
6893 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
6894 if !legacy.exists() {
6895 return Ok(None);
6896 }
6897 // Copy the config to the new home, owner-only, through a temporary file
6898 // renamed into place (the primary name is replaced, never written through).
6899 let contents = std::fs::read(&legacy).context("failed to read legacy deepseek config")?;
6900 persistence::atomic_write(&primary, &contents)
6901 .context("failed to migrate config from deepseek to codewhale home")?;
6902 tracing::info!(
6903 "Migrated config from {} to {}",
6904 legacy.display(),
6905 primary.display()
6906 );
6907 Ok(Some(ConfigMigration {
6908 legacy_path: legacy,
6909 primary_path: primary,
6910 }))
6911 }
6912
6913 /// `value` as the TOML value a [`SettingDef`] declares, or an error naming
6914 /// the key and what it accepts. No partial write happens on error.
6915 fn schema_toml_value(key: &str, def: &SettingDef, value: &str) -> Result<toml::Value> {
6916 let trimmed = value.trim();
6917 Ok(match def.kind {
6918 SettingKind::Bool(_) => toml::Value::Boolean(
6919 parse_bool(value).with_context(|| format!("invalid value for '{key}'"))?,
6920 ),
6921 SettingKind::Int => toml::Value::Integer(trimmed.parse().map_err(|_| {
6922 anyhow::anyhow!("invalid value '{value}' for '{key}': expected an integer")
6923 })?),
6924 SettingKind::Float => toml::Value::Float(
6925 trimmed
6926 .parse::<f64>()
6927 .ok()
6928 .filter(|number| number.is_finite())
6929 .ok_or_else(|| {
6930 anyhow::anyhow!("invalid value '{value}' for '{key}': expected a number")
6931 })?,
6932 ),
6933 SettingKind::Enum(options) => {
6934 let Some(option) = options
6935 .iter()
6936 .find(|option| option.value.eq_ignore_ascii_case(trimmed))
6937 else {
6938 let expected: Vec<&str> = options.iter().map(|option| option.value).collect();
6939 bail!(
6940 "invalid value '{value}' for '{key}': expected one of {}",
6941 expected.join(", ")
6942 );
6943 };
6944 toml::Value::String(option.value.to_string())
6945 }
6946 SettingKind::String => toml::Value::String(value.to_string()),
6947 })
6948 }
6949
6950 fn parse_bool(raw: &str) -> Result<bool> {
6951 match raw.trim().to_ascii_lowercase().as_str() {
6952 "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
6953 "0" | "false" | "no" | "off" | "disabled" => Ok(false),
6954 _ => bail!("invalid boolean '{raw}'"),
6955 }
6956 }
6957
6958 fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
6959 let mut headers = BTreeMap::new();
6960 for pair in raw.trim().split(',') {
6961 let pair = pair.trim();
6962 if pair.is_empty() {
6963 continue;
6964 }
6965 let Some((name, value)) = pair.split_once('=') else {
6966 bail!("invalid header pair '{pair}', expected name=value");
6967 };
6968 let name = name.trim();
6969 let value = value.trim();
6970 if name.is_empty() {
6971 bail!("header name cannot be empty");
6972 }
6973 if value.is_empty() {
6974 continue;
6975 }
6976 headers.insert(name.to_string(), value.to_string());
6977 }
6978 Ok(headers)
6979 }
6980
6981 fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
6982 if headers.is_empty() {
6983 return None;
6984 }
6985 Some(
6986 headers
6987 .iter()
6988 .map(|(name, value)| format!("{name}={value}"))
6989 .collect::<Vec<_>>()
6990 .join(","),
6991 )
6992 }
6993
6994 fn serialize_http_headers_for_display(headers: &BTreeMap<String, String>) -> Option<String> {
6995 if headers.is_empty() {
6996 return None;
6997 }
6998 Some(
6999 headers
7000 .iter()
7001 .map(|(name, value)| {
7002 let display_value = if is_sensitive_config_key(name) {
7003 redact_secret(value)
7004 } else {
7005 value.clone()
7006 };
7007 format!("{name}={display_value}")
7008 })
7009 .collect::<Vec<_>>()
7010 .join(","),
7011 )
7012 }
7013
7014 fn redact_secret(secret: &str) -> String {
7015 let chars: Vec<char> = secret.chars().collect();
7016 if chars.len() <= 16 {
7017 return "********".to_string();
7018 }
7019 let prefix: String = chars.iter().take(4).collect();
7020 let suffix: String = chars
7021 .iter()
7022 .rev()
7023 .take(4)
7024 .collect::<Vec<_>>()
7025 .into_iter()
7026 .rev()
7027 .collect();
7028 format!("{prefix}***{suffix}")
7029 }
7030
7031 #[must_use]
7032 pub fn is_sensitive_config_key(key: &str) -> bool {
7033 key.rsplit('.')
7034 .next()
7035 .is_some_and(codewhale_secrets::sanitize::is_sensitive_key_name)
7036 }
7037
7038 /// Resolve dotted paths without treating a dotted key as a top-level literal.
7039 fn config_value_at_path<'a>(value: &'a toml::Value, key: &str) -> Option<&'a toml::Value> {
7040 key.split('.')
7041 .try_fold(value, |value, part| value.get(part))
7042 }
7043
7044 fn redact_toml_value_for_display(key: &str, value: &toml::Value) -> String {
7045 redact_toml_value_for_display_inner(key, false, value).to_string()
7046 }
7047
7048 impl ConfigToml {
7049 /// Redacted TOML rendering of the effective config for `config dump`.
7050 /// Structure is preserved; strings under sensitive key names (api_key,
7051 /// token, secret, … — see `is_sensitive_config_key`, nested tables
7052 /// inherit sensitivity from their ancestors) are redacted. Redaction is
7053 /// name-based: an unrecognized key holding a secret-looking value would
7054 /// pass through, so pasting dump output still deserves a glance.
7055 #[must_use]
7056 pub fn redacted_toml_value(&self) -> toml::Value {
7057 let value =
7058 toml::Value::try_from(self).expect("ConfigToml derives Serialize, so this holds");
7059 match value {
7060 toml::Value::Table(table) => toml::Value::Table(
7061 table
7062 .into_iter()
7063 .map(|(key, value)| {
7064 let redacted = redact_toml_value_for_display_inner(&key, false, &value);
7065 (key, redacted)
7066 })
7067 .collect(),
7068 ),
7069 other => other,
7070 }
7071 }
7072 }
7073
7074 fn toml_value_as_u64(value: &toml::Value) -> Option<u64> {
7075 match value {
7076 toml::Value::Integer(value) => u64::try_from(*value).ok(),
7077 toml::Value::String(value) => value.trim().parse().ok(),
7078 _ => None,
7079 }
7080 }
7081
7082 fn redact_toml_value_for_display_inner(
7083 key: &str,
7084 sensitive_ancestor: bool,
7085 value: &toml::Value,
7086 ) -> toml::Value {
7087 let sensitive = sensitive_ancestor || is_sensitive_config_key(key);
7088 match value {
7089 toml::Value::String(value) if sensitive => toml::Value::String(redact_secret(value)),
7090 toml::Value::Array(values) => toml::Value::Array(
7091 values
7092 .iter()
7093 .map(|value| redact_toml_value_for_display_inner(key, sensitive, value))
7094 .collect(),
7095 ),
7096 toml::Value::Table(table) => {
7097 let mut redacted = toml::map::Map::new();
7098 for (child_key, child_value) in table {
7099 let path = if key.is_empty() {
7100 child_key.clone()
7101 } else {
7102 format!("{key}.{child_key}")
7103 };
7104 redacted.insert(
7105 child_key.clone(),
7106 redact_toml_value_for_display_inner(&path, sensitive, child_value),
7107 );
7108 }
7109 toml::Value::Table(redacted)
7110 }
7111 _ if sensitive => toml::Value::String("********".to_string()),
7112 _ => value.clone(),
7113 }
7114 }
7115
7116 fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
7117 if path.as_os_str().is_empty() {
7118 bail!("config path cannot be empty");
7119 }
7120 if path
7121 .components()
7122 .any(|component| matches!(component, Component::ParentDir))
7123 {
7124 bail!("config path cannot contain '..' components");
7125 }
7126 if path.file_name().is_none() {
7127 bail!("config path must include a file name");
7128 }
7129 let absolute = if path.is_absolute() {
7130 path
7131 } else {
7132 std::env::current_dir()
7133 .context("failed to resolve current directory for config path")?
7134 .join(path)
7135 };
7136 let file_name = absolute
7137 .file_name()
7138 .map(OsString::from)
7139 .context("config path must include a file name")?;
7140 let parent = absolute
7141 .parent()
7142 .context("config path must include a parent directory")?;
7143 let parent = match parent.canonicalize() {
7144 Ok(parent) => parent,
7145 Err(err) if err.kind() == std::io::ErrorKind::NotFound => parent.to_path_buf(),
7146 Err(err) => {
7147 return Err(err).with_context(|| {
7148 format!("failed to resolve config directory {}", parent.display())
7149 });
7150 }
7151 };
7152 let normalized = parent.join(file_name);
7153 reject_path_symlink(&normalized)?;
7154 Ok(normalized)
7155 }
7156
7157 fn normalize_project_workspace(workspace: &Path) -> Result<PathBuf> {
7158 if workspace.as_os_str().is_empty() {
7159 bail!("project workspace path cannot be empty");
7160 }
7161 if workspace
7162 .components()
7163 .any(|component| matches!(component, Component::ParentDir))
7164 {
7165 bail!("project workspace path cannot contain '..' components");
7166 }
7167 let absolute = if workspace.is_absolute() {
7168 workspace.to_path_buf()
7169 } else {
7170 std::env::current_dir()
7171 .context("failed to resolve current directory for project workspace")?
7172 .join(workspace)
7173 };
7174 match absolute.canonicalize() {
7175 Ok(path) => Ok(path),
7176 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
7177 Ok(normalize_path_components(&absolute))
7178 }
7179 Err(err) => Err(err).with_context(|| {
7180 format!(
7181 "failed to resolve project workspace {}",
7182 workspace.display()
7183 )
7184 }),
7185 }
7186 }
7187
7188 fn normalize_path_components(path: &Path) -> PathBuf {
7189 let mut normalized = PathBuf::new();
7190 for component in path.components() {
7191 match component {
7192 Component::Prefix(_) | Component::RootDir => normalized.push(component.as_os_str()),
7193 Component::CurDir => {}
7194 Component::ParentDir => {
7195 normalized.pop();
7196 }
7197 Component::Normal(part) => normalized.push(part),
7198 }
7199 }
7200 if normalized.as_os_str().is_empty() {
7201 PathBuf::from(".")
7202 } else {
7203 normalized
7204 }
7205 }
7206
7207 fn checked_path_exists(path: &Path) -> Result<bool> {
7208 let path = normalize_config_file_path(path.to_path_buf())?;
7209 path.try_exists()
7210 .with_context(|| format!("failed to inspect config path {}", path.display()))
7211 }
7212
7213 fn read_checked_config_file(path: &Path) -> Result<String> {
7214 read_checked_toml_file(path, "config")
7215 }
7216
7217 fn read_checked_permissions_file(path: &Path) -> Result<String> {
7218 read_checked_toml_file(path, "permissions")
7219 }
7220
7221 fn read_checked_toml_file(path: &Path, label: &str) -> Result<String> {
7222 let path = normalize_config_file_path(path.to_path_buf())?;
7223 read_string_no_follow(&path)
7224 .with_context(|| format!("failed to read {label} at {}", path.display()))
7225 }
7226
7227 /// Maximum bytes read from a config file. Configs are kilobytes; anything
7228 /// larger is not a config file.
7229 const MAX_CONFIG_FILE_BYTES: u64 = 1024 * 1024;
7230
7231 #[cfg(unix)]
7232 fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
7233 let file = fs::OpenOptions::new()
7234 .read(true)
7235 .custom_flags(libc::O_NOFOLLOW)
7236 .open(path)?;
7237 let mut raw = String::new();
7238 file.take(MAX_CONFIG_FILE_BYTES + 1)
7239 .read_to_string(&mut raw)?;
7240 if raw.len() as u64 > MAX_CONFIG_FILE_BYTES {
7241 return Err(std::io::Error::new(
7242 std::io::ErrorKind::InvalidData,
7243 format!("config file {} exceeds the 1 MiB limit", path.display()),
7244 ));
7245 }
7246 Ok(raw)
7247 }
7248
7249 #[cfg(not(unix))]
7250 fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
7251 let file = fs::File::open(path)?;
7252 let mut raw = String::new();
7253 file.take(MAX_CONFIG_FILE_BYTES + 1)
7254 .read_to_string(&mut raw)?;
7255 if raw.len() as u64 > MAX_CONFIG_FILE_BYTES {
7256 return Err(std::io::Error::new(
7257 std::io::ErrorKind::InvalidData,
7258 format!("config file {} exceeds the 1 MiB limit", path.display()),
7259 ));
7260 }
7261 Ok(raw)
7262 }
7263
7264 fn reject_path_symlink(path: &Path) -> Result<()> {
7265 let Ok(metadata) = fs::symlink_metadata(path) else {
7266 return Ok(());
7267 };
7268 if metadata.file_type().is_symlink() {
7269 bail!("config path must not be a symlink: {}", path.display());
7270 }
7271 Ok(())
7272 }
7273
7274 #[derive(Debug, Clone, Default)]
7275 struct EnvRuntimeOverrides {
7276 provider: Option<ProviderKind>,
7277 provider_source: Option<&'static str>,
7278 model: Option<String>,
7279 volcengine_model: Option<String>,
7280 wanjie_ark_model: Option<String>,
7281 openrouter_model: Option<String>,
7282 orcarouter_model: Option<String>,
7283 moonshot_model: Option<String>,
7284 xiaomi_mimo_model: Option<String>,
7285 xiaomi_mimo_mode: Option<String>,
7286 novita_model: Option<String>,
7287 fireworks_model: Option<String>,
7288 arcee_model: Option<String>,
7289 auth_mode: Option<String>,
7290 log_level: Option<String>,
7291 telemetry: Option<bool>,
7292 /// `CODEWHALE_TELEMETRY`/`DEEPSEEK_TELEMETRY` was set to something
7293 /// [`parse_bool`] could not read. A typo in a kill switch must never
7294 /// resolve to "on", so this forces telemetry off the same way an explicit
7295 /// `false` does.
7296 telemetry_env_invalid: bool,
7297 /// An environment-level kill switch is in force for this process.
7298 ///
7299 /// See [`telemetry_floor_in_force`] for what sets it and why the dispatcher
7300 /// has to state it rather than let the child infer it.
7301 telemetry_floor: bool,
7302 /// `CODEWHALE_TELEMETRY_ENDPOINT`/`DEEPSEEK_TELEMETRY_ENDPOINT`. Overrides
7303 /// the config file. A workspace `.env` cannot reach this — the dotenv
7304 /// allowlist admits only built-in provider credential names.
7305 telemetry_endpoint: Option<String>,
7306 approval_policy: Option<String>,
7307 sandbox_mode: Option<String>,
7308 yolo: Option<bool>,
7309 verbosity: Option<String>,
7310 http_headers: Option<BTreeMap<String, String>>,
7311 active_route_base_url: Option<String>,
7312 deepseek_anthropic_base_url: Option<String>,
7313 nvidia_base_url: Option<String>,
7314 openai_base_url: Option<String>,
7315 atlascloud_base_url: Option<String>,
7316 volcengine_base_url: Option<String>,
7317 wanjie_ark_base_url: Option<String>,
7318 openrouter_base_url: Option<String>,
7319 orcarouter_base_url: Option<String>,
7320 xiaomi_mimo_base_url: Option<String>,
7321 novita_base_url: Option<String>,
7322 fireworks_base_url: Option<String>,
7323 siliconflow_base_url: Option<String>,
7324 siliconflow_model: Option<String>,
7325 arcee_base_url: Option<String>,
7326 moonshot_base_url: Option<String>,
7327 sglang_base_url: Option<String>,
7328 vllm_base_url: Option<String>,
7329 ollama_base_url: Option<String>,
7330 ollama_cloud_base_url: Option<String>,
7331 ollama_cloud_model: Option<String>,
7332 huggingface_base_url: Option<String>,
7333 huggingface_model: Option<String>,
7334 modelscope_base_url: Option<String>,
7335 modelscope_model: Option<String>,
7336 together_base_url: Option<String>,
7337 together_model: Option<String>,
7338 qianfan_base_url: Option<String>,
7339 qianfan_model: Option<String>,
7340 openai_codex_base_url: Option<String>,
7341 openai_codex_model: Option<String>,
7342 anthropic_base_url: Option<String>,
7343 anthropic_model: Option<String>,
7344 openmodel_base_url: Option<String>,
7345 openmodel_model: Option<String>,
7346 zai_base_url: Option<String>,
7347 zai_model: Option<String>,
7348 stepfun_base_url: Option<String>,
7349 stepfun_model: Option<String>,
7350 minimax_base_url: Option<String>,
7351 minimax_anthropic_base_url: Option<String>,
7352 minimax_model: Option<String>,
7353 deepinfra_base_url: Option<String>,
7354 deepinfra_model: Option<String>,
7355 sakana_base_url: Option<String>,
7356 sakana_model: Option<String>,
7357 longcat_base_url: Option<String>,
7358 longcat_model: Option<String>,
7359 opencode_go_base_url: Option<String>,
7360 opencode_go_model: Option<String>,
7361 opencode_zen_base_url: Option<String>,
7362 opencode_zen_model: Option<String>,
7363 meta_base_url: Option<String>,
7364 meta_model: Option<String>,
7365 xai_base_url: Option<String>,
7366 xai_model: Option<String>,
7367 mistral_base_url: Option<String>,
7368 mistral_model: Option<String>,
7369 google_base_url: Option<String>,
7370 google_model: Option<String>,
7371 telecomjs_base_url: Option<String>,
7372 telecomjs_model: Option<String>,
7373 edenai_base_url: Option<String>,
7374 edenai_model: Option<String>,
7375 zenmux_base_url: Option<String>,
7376 zenmux_model: Option<String>,
7377 csdn_base_url: Option<String>,
7378 csdn_model: Option<String>,
7379 concentrate_base_url: Option<String>,
7380 concentrate_model: Option<String>,
7381 codewhale_base_url: Option<String>,
7382 codewhale_model: Option<String>,
7383 modelstudio_token_plan_base_url: Option<String>,
7384 modelstudio_token_plan_model: Option<String>,
7385 modelstudio_coding_plan_base_url: Option<String>,
7386 modelstudio_coding_plan_model: Option<String>,
7387 }
7388
7389 /// The first of `names` that is set to a non-blank value. A variable exported
7390 /// empty (compose `${VAR:-}`, CI templates) must not shadow the legacy
7391 /// variable or the config file.
7392 fn env_non_blank(names: &[&str]) -> Option<String> {
7393 names.iter().find_map(|name| {
7394 std::env::var(name)
7395 .ok()
7396 .filter(|value| !value.trim().is_empty())
7397 })
7398 }
7399
7400 impl EnvRuntimeOverrides {
7401 fn load() -> Self {
7402 let (provider, provider_source) = Self::load_provider();
7403 let (telemetry, telemetry_env_invalid) = Self::load_telemetry();
7404 let telemetry_floor = telemetry_floor_in_force();
7405 Self {
7406 provider,
7407 provider_source,
7408 model: std::env::var("CODEWHALE_MODEL")
7409 .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
7410 .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
7411 .ok()
7412 .filter(|v| !v.trim().is_empty()),
7413 volcengine_model: std::env::var("VOLCENGINE_MODEL")
7414 .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
7415 .ok()
7416 .filter(|v| !v.trim().is_empty()),
7417 wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
7418 .or_else(|_| std::env::var("WANJIE_MODEL"))
7419 .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
7420 .ok()
7421 .filter(|v| !v.trim().is_empty()),
7422 openrouter_model: std::env::var("OPENROUTER_MODEL")
7423 .ok()
7424 .filter(|v| !v.trim().is_empty()),
7425 orcarouter_model: std::env::var("ORCAROUTER_MODEL")
7426 .ok()
7427 .filter(|v| !v.trim().is_empty()),
7428 moonshot_model: std::env::var("MOONSHOT_MODEL")
7429 .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
7430 .or_else(|_| std::env::var("KIMI_MODEL"))
7431 .ok()
7432 .filter(|v| !v.trim().is_empty()),
7433 xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
7434 .or_else(|_| std::env::var("MIMO_MODEL"))
7435 .ok()
7436 .filter(|v| !v.trim().is_empty()),
7437 xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
7438 .or_else(|_| std::env::var("MIMO_MODE"))
7439 .ok()
7440 .filter(|v| !v.trim().is_empty()),
7441 novita_model: std::env::var("NOVITA_MODEL")
7442 .ok()
7443 .filter(|v| !v.trim().is_empty()),
7444 fireworks_model: std::env::var("FIREWORKS_MODEL")
7445 .ok()
7446 .filter(|v| !v.trim().is_empty()),
7447 arcee_model: std::env::var("ARCEE_MODEL")
7448 .ok()
7449 .filter(|v| !v.trim().is_empty()),
7450 verbosity: env_non_blank(&["CODEWHALE_VERBOSITY", "DEEPSEEK_VERBOSITY"]),
7451 auth_mode: env_non_blank(&["CODEWHALE_AUTH_MODE", "DEEPSEEK_AUTH_MODE"]),
7452 log_level: env_non_blank(&["CODEWHALE_LOG_LEVEL", "DEEPSEEK_LOG_LEVEL"]),
7453 telemetry,
7454 telemetry_env_invalid,
7455 telemetry_floor,
7456 // Empty is kept, not discarded. Since the config file's *absent*
7457 // endpoint now resolves to `DEFAULT_TELEMETRY_ENDPOINT`, dropping
7458 // an explicitly emptied variable here would make
7459 // `CODEWHALE_TELEMETRY_ENDPOINT=` select the shipped endpoint —
7460 // the opposite of what anyone typing it means. Resolution reads an
7461 // empty override as "contact nobody, write the dry-run file".
7462 telemetry_endpoint: std::env::var("CODEWHALE_TELEMETRY_ENDPOINT")
7463 .or_else(|_| std::env::var("DEEPSEEK_TELEMETRY_ENDPOINT"))
7464 .ok(),
7465 approval_policy: env_non_blank(&[
7466 "CODEWHALE_APPROVAL_POLICY",
7467 "DEEPSEEK_APPROVAL_POLICY",
7468 ]),
7469 sandbox_mode: env_non_blank(&["CODEWHALE_SANDBOX_MODE", "DEEPSEEK_SANDBOX_MODE"]),
7470 // `DEEPSEEK_YOLO` is a read-only deprecated alias of
7471 // `CODEWHALE_YOLO` so existing scripts keep working; when both are
7472 // set `CODEWHALE_YOLO` wins. The alias is removed in 0.10 per
7473 // issue #5443 — do not write it anywhere.
7474 yolo: std::env::var("CODEWHALE_YOLO")
7475 .or_else(|_| std::env::var("DEEPSEEK_YOLO"))
7476 .ok()
7477 .and_then(|v| match parse_bool(&v) {
7478 Ok(b) => Some(b),
7479 Err(_) => {
7480 tracing::warn!("Invalid CODEWHALE_YOLO value '{v}', expected true/false");
7481 None
7482 }
7483 }),
7484 http_headers: std::env::var("CODEWHALE_HTTP_HEADERS")
7485 .or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
7486 .ok()
7487 .and_then(|value| match parse_http_headers(&value) {
7488 Ok(h) => Some(h),
7489 Err(_) => {
7490 tracing::warn!("Invalid CODEWHALE_HTTP_HEADERS/DEEPSEEK_HTTP_HEADERS value, expected format: header1=val1,header2=val2");
7491 None
7492 }
7493 })
7494 .filter(|headers| !headers.is_empty()),
7495 active_route_base_url: std::env::var("CODEWHALE_BASE_URL")
7496 .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
7497 .ok()
7498 .filter(|v| !v.trim().is_empty()),
7499 deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
7500 .or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
7501 .ok()
7502 .filter(|v| !v.trim().is_empty()),
7503 nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
7504 .or_else(|_| std::env::var("NIM_BASE_URL"))
7505 .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
7506 .ok()
7507 .filter(|v| !v.trim().is_empty()),
7508 openai_base_url: std::env::var("OPENAI_BASE_URL")
7509 .ok()
7510 .filter(|v| !v.trim().is_empty()),
7511 atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
7512 .ok()
7513 .filter(|v| !v.trim().is_empty()),
7514 volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
7515 .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
7516 .or_else(|_| std::env::var("ARK_BASE_URL"))
7517 .ok()
7518 .filter(|v| !v.trim().is_empty()),
7519 wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
7520 .or_else(|_| std::env::var("WANJIE_BASE_URL"))
7521 .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
7522 .ok()
7523 .filter(|v| !v.trim().is_empty()),
7524 openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
7525 .ok()
7526 .filter(|v| !v.trim().is_empty()),
7527 orcarouter_base_url: std::env::var("ORCAROUTER_BASE_URL")
7528 .ok()
7529 .filter(|v| !v.trim().is_empty()),
7530 xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
7531 .or_else(|_| std::env::var("MIMO_BASE_URL"))
7532 .ok()
7533 .filter(|v| !v.trim().is_empty()),
7534 novita_base_url: std::env::var("NOVITA_BASE_URL")
7535 .ok()
7536 .filter(|v| !v.trim().is_empty()),
7537 fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
7538 .ok()
7539 .filter(|v| !v.trim().is_empty()),
7540 siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
7541 .ok()
7542 .filter(|v| !v.trim().is_empty()),
7543 siliconflow_model: std::env::var("SILICONFLOW_MODEL")
7544 .ok()
7545 .filter(|v| !v.trim().is_empty()),
7546 arcee_base_url: std::env::var("ARCEE_BASE_URL")
7547 .ok()
7548 .filter(|v| !v.trim().is_empty()),
7549 moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
7550 .or_else(|_| std::env::var("KIMI_BASE_URL"))
7551 .ok()
7552 .filter(|v| !v.trim().is_empty()),
7553 sglang_base_url: std::env::var("SGLANG_BASE_URL")
7554 .ok()
7555 .filter(|v| !v.trim().is_empty()),
7556 vllm_base_url: std::env::var("VLLM_BASE_URL")
7557 .ok()
7558 .filter(|v| !v.trim().is_empty()),
7559 ollama_base_url: std::env::var("OLLAMA_BASE_URL")
7560 .ok()
7561 .filter(|v| !v.trim().is_empty()),
7562 ollama_cloud_base_url: std::env::var("OLLAMA_CLOUD_BASE_URL")
7563 .ok()
7564 .filter(|v| !v.trim().is_empty()),
7565 ollama_cloud_model: std::env::var("OLLAMA_CLOUD_MODEL")
7566 .ok()
7567 .filter(|v| !v.trim().is_empty()),
7568 huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
7569 .or_else(|_| std::env::var("HF_BASE_URL"))
7570 .ok()
7571 .filter(|v| !v.trim().is_empty()),
7572 huggingface_model: std::env::var("HUGGINGFACE_MODEL")
7573 .or_else(|_| std::env::var("HF_MODEL"))
7574 .ok()
7575 .filter(|v| !v.trim().is_empty()),
7576 modelscope_base_url: std::env::var("MODELSCOPE_BASE_URL")
7577 .ok()
7578 .filter(|v| !v.trim().is_empty()),
7579 modelscope_model: std::env::var("MODELSCOPE_MODEL")
7580 .ok()
7581 .filter(|v| !v.trim().is_empty()),
7582 together_base_url: std::env::var("TOGETHER_BASE_URL")
7583 .ok()
7584 .filter(|v| !v.trim().is_empty()),
7585 together_model: std::env::var("TOGETHER_MODEL")
7586 .ok()
7587 .filter(|v| !v.trim().is_empty()),
7588 qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
7589 .ok()
7590 .filter(|v| !v.trim().is_empty())
7591 .or_else(|| {
7592 std::env::var("BAIDU_QIANFAN_BASE_URL")
7593 .ok()
7594 .filter(|v| !v.trim().is_empty())
7595 }),
7596 qianfan_model: std::env::var("QIANFAN_MODEL")
7597 .ok()
7598 .filter(|v| !v.trim().is_empty())
7599 .or_else(|| {
7600 std::env::var("BAIDU_QIANFAN_MODEL")
7601 .ok()
7602 .filter(|v| !v.trim().is_empty())
7603 }),
7604 openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
7605 .or_else(|_| std::env::var("CODEX_BASE_URL"))
7606 .ok()
7607 .filter(|v| !v.trim().is_empty()),
7608 openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
7609 .or_else(|_| std::env::var("CODEX_MODEL"))
7610 .ok()
7611 .filter(|v| !v.trim().is_empty()),
7612 anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
7613 .ok()
7614 .filter(|v| !v.trim().is_empty()),
7615 anthropic_model: std::env::var("ANTHROPIC_MODEL")
7616 .ok()
7617 .filter(|v| !v.trim().is_empty()),
7618 openmodel_base_url: std::env::var("OPENMODEL_BASE_URL")
7619 .ok()
7620 .filter(|v| !v.trim().is_empty()),
7621 openmodel_model: std::env::var("OPENMODEL_MODEL")
7622 .ok()
7623 .filter(|v| !v.trim().is_empty()),
7624 zai_base_url: std::env::var("ZAI_BASE_URL")
7625 .or_else(|_| std::env::var("Z_AI_BASE_URL"))
7626 .or_else(|_| std::env::var("ZHIPU_BASE_URL"))
7627 .or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
7628 .or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
7629 .ok()
7630 .filter(|v| !v.trim().is_empty()),
7631 zai_model: std::env::var("ZAI_MODEL")
7632 .or_else(|_| std::env::var("Z_AI_MODEL"))
7633 .or_else(|_| std::env::var("ZHIPU_MODEL"))
7634 .or_else(|_| std::env::var("ZHIPUAI_MODEL"))
7635 .or_else(|_| std::env::var("BIGMODEL_MODEL"))
7636 .or_else(|_| std::env::var("GLM_MODEL"))
7637 .ok()
7638 .filter(|v| !v.trim().is_empty()),
7639 stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
7640 .or_else(|_| std::env::var("STEP_BASE_URL"))
7641 .ok()
7642 .filter(|v| !v.trim().is_empty()),
7643 stepfun_model: std::env::var("STEPFUN_MODEL")
7644 .or_else(|_| std::env::var("STEP_MODEL"))
7645 .ok()
7646 .filter(|v| !v.trim().is_empty()),
7647 minimax_base_url: std::env::var("MINIMAX_BASE_URL")
7648 .ok()
7649 .filter(|v| !v.trim().is_empty()),
7650 minimax_anthropic_base_url: std::env::var("MINIMAX_ANTHROPIC_BASE_URL")
7651 .ok()
7652 .filter(|v| !v.trim().is_empty()),
7653 minimax_model: std::env::var("MINIMAX_MODEL")
7654 .ok()
7655 .filter(|v| !v.trim().is_empty()),
7656 deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
7657 .ok()
7658 .filter(|v| !v.trim().is_empty()),
7659 deepinfra_model: std::env::var("DEEPINFRA_MODEL")
7660 .ok()
7661 .filter(|v| !v.trim().is_empty()),
7662 sakana_base_url: std::env::var("SAKANA_BASE_URL")
7663 .ok()
7664 .filter(|v| !v.trim().is_empty()),
7665 sakana_model: std::env::var("SAKANA_MODEL")
7666 .ok()
7667 .filter(|v| !v.trim().is_empty()),
7668 longcat_base_url: std::env::var("LONGCAT_BASE_URL")
7669 .ok()
7670 .filter(|v| !v.trim().is_empty()),
7671 longcat_model: std::env::var("LONGCAT_MODEL")
7672 .ok()
7673 .filter(|v| !v.trim().is_empty()),
7674 opencode_go_base_url: std::env::var("OPENCODE_GO_BASE_URL")
7675 .ok()
7676 .filter(|v| !v.trim().is_empty()),
7677 opencode_go_model: std::env::var("OPENCODE_GO_MODEL")
7678 .ok()
7679 .filter(|v| !v.trim().is_empty()),
7680 opencode_zen_base_url: std::env::var("OPENCODE_ZEN_BASE_URL")
7681 .ok()
7682 .filter(|v| !v.trim().is_empty()),
7683 opencode_zen_model: std::env::var("OPENCODE_ZEN_MODEL")
7684 .ok()
7685 .filter(|v| !v.trim().is_empty()),
7686 meta_base_url: std::env::var("META_MODEL_API_BASE_URL")
7687 .ok()
7688 .filter(|v| !v.trim().is_empty())
7689 .or_else(|| {
7690 std::env::var("MODEL_API_BASE_URL")
7691 .ok()
7692 .filter(|v| !v.trim().is_empty())
7693 }),
7694 meta_model: std::env::var("META_MODEL_API_MODEL")
7695 .ok()
7696 .filter(|v| !v.trim().is_empty())
7697 .or_else(|| {
7698 std::env::var("MODEL_API_MODEL")
7699 .ok()
7700 .filter(|v| !v.trim().is_empty())
7701 }),
7702 xai_base_url: std::env::var("XAI_BASE_URL")
7703 .ok()
7704 .filter(|v| !v.trim().is_empty()),
7705 xai_model: std::env::var("XAI_MODEL")
7706 .ok()
7707 .filter(|v| !v.trim().is_empty()),
7708 google_base_url: std::env::var("GOOGLE_BASE_URL")
7709 .ok()
7710 .filter(|v| !v.trim().is_empty())
7711 .or_else(|| {
7712 std::env::var("GEMINI_BASE_URL")
7713 .ok()
7714 .filter(|v| !v.trim().is_empty())
7715 }),
7716 google_model: std::env::var("GOOGLE_MODEL")
7717 .ok()
7718 .filter(|v| !v.trim().is_empty())
7719 .or_else(|| {
7720 std::env::var("GEMINI_MODEL")
7721 .ok()
7722 .filter(|v| !v.trim().is_empty())
7723 }),
7724 mistral_base_url: std::env::var("MISTRAL_BASE_URL")
7725 .ok()
7726 .filter(|v| !v.trim().is_empty()),
7727 mistral_model: std::env::var("MISTRAL_MODEL")
7728 .ok()
7729 .filter(|v| !v.trim().is_empty()),
7730 telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL")
7731 .ok()
7732 .filter(|v| !v.trim().is_empty()),
7733 telecomjs_model: std::env::var("TELECOMJS_MODEL")
7734 .ok()
7735 .filter(|v| !v.trim().is_empty()),
7736 edenai_base_url: std::env::var("EDENAI_BASE_URL")
7737 .ok()
7738 .filter(|v| !v.trim().is_empty()),
7739 edenai_model: std::env::var("EDENAI_MODEL")
7740 .ok()
7741 .filter(|v| !v.trim().is_empty()),
7742 zenmux_base_url: std::env::var("ZENMUX_BASE_URL")
7743 .ok()
7744 .filter(|v| !v.trim().is_empty()),
7745 zenmux_model: std::env::var("ZENMUX_MODEL")
7746 .ok()
7747 .filter(|v| !v.trim().is_empty()),
7748 csdn_base_url: std::env::var("CSDN_BASE_URL")
7749 .ok()
7750 .filter(|v| !v.trim().is_empty()),
7751 csdn_model: std::env::var("CSDN_MODEL")
7752 .ok()
7753 .filter(|v| !v.trim().is_empty()),
7754 concentrate_base_url: std::env::var("CONCENTRATE_BASE_URL")
7755 .ok()
7756 .filter(|v| !v.trim().is_empty()),
7757 concentrate_model: std::env::var("CONCENTRATE_MODEL")
7758 .ok()
7759 .filter(|v| !v.trim().is_empty()),
7760 // `CODEWHALE_API_BASE` is a trust boundary, not a plain string:
7761 // an origin this route would refuse to send a bearer to is
7762 // dropped here rather than resolved into a route.
7763 codewhale_base_url: provider::codewhale_api_base_from_env(),
7764 codewhale_model: std::env::var("CODEWHALE_MODEL")
7765 .ok()
7766 .filter(|v| !v.trim().is_empty()),
7767 modelstudio_token_plan_base_url: std::env::var("MODELSTUDIO_TOKEN_PLAN_BASE_URL")
7768 .ok()
7769 .filter(|v| !v.trim().is_empty()),
7770 modelstudio_token_plan_model: std::env::var("MODELSTUDIO_TOKEN_PLAN_MODEL")
7771 .ok()
7772 .filter(|v| !v.trim().is_empty()),
7773 modelstudio_coding_plan_base_url: std::env::var("MODELSTUDIO_CODING_PLAN_BASE_URL")
7774 .ok()
7775 .filter(|v| !v.trim().is_empty()),
7776 modelstudio_coding_plan_model: std::env::var("MODELSTUDIO_CODING_PLAN_MODEL")
7777 .ok()
7778 .filter(|v| !v.trim().is_empty()),
7779 }
7780 }
7781
7782 fn load_provider() -> (Option<ProviderKind>, Option<&'static str>) {
7783 for name in ["CODEWHALE_PROVIDER", "DEEPSEEK_PROVIDER"] {
7784 let Some(value) = env_non_blank(&[name]) else {
7785 continue;
7786 };
7787 if let Some(parsed) = ProviderKind::parse_config_identity(&value) {
7788 return (Some(parsed), Some(name));
7789 }
7790 // An unrecognized value used to be dropped silently, leaving the
7791 // config provider in charge while the user believed the env var
7792 // had switched it. Say so, then let the legacy variable apply.
7793 tracing::warn!(
7794 "{name} does not name a built-in provider and is ignored; \
7795 select a named custom provider with `provider` in config.toml"
7796 );
7797 }
7798 (None, None)
7799 }
7800
7801 /// Read the telemetry kill switch, reporting an unreadable value instead of
7802 /// swallowing it. See [`read_telemetry_env`].
7803 fn load_telemetry() -> (Option<bool>, bool) {
7804 read_telemetry_env()
7805 }
7806
7807 fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
7808 // Defaults belong in the resolver's final fallback so config-file
7809 // values (`providers.<name>.base_url`) still win when env is unset.
7810 match provider {
7811 ProviderKind::Deepseek => self.active_route_base_url.clone(),
7812 ProviderKind::DeepseekAnthropic => self.deepseek_anthropic_base_url.clone(),
7813 ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
7814 ProviderKind::Openai => self.openai_base_url.clone(),
7815 ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
7816 ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
7817 ProviderKind::Volcengine => self.volcengine_base_url.clone(),
7818 ProviderKind::Openrouter => self.openrouter_base_url.clone(),
7819 ProviderKind::Orcarouter => self.orcarouter_base_url.clone(),
7820 ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
7821 ProviderKind::Novita => self.novita_base_url.clone(),
7822 ProviderKind::Fireworks => self.fireworks_base_url.clone(),
7823 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
7824 self.siliconflow_base_url.clone()
7825 }
7826 ProviderKind::Arcee => self.arcee_base_url.clone(),
7827 ProviderKind::Moonshot => self.moonshot_base_url.clone(),
7828 ProviderKind::Sglang => self.sglang_base_url.clone(),
7829 ProviderKind::Vllm => self.vllm_base_url.clone(),
7830 ProviderKind::Ollama => self.ollama_base_url.clone(),
7831 ProviderKind::OllamaCloud => self.ollama_cloud_base_url.clone(),
7832 ProviderKind::Huggingface => self.huggingface_base_url.clone(),
7833 ProviderKind::Modelscope => self.modelscope_base_url.clone(),
7834 ProviderKind::Together => self.together_base_url.clone(),
7835 ProviderKind::Qianfan => self.qianfan_base_url.clone(),
7836 ProviderKind::OpenaiCodex => self.openai_codex_base_url.clone(),
7837 ProviderKind::Anthropic => self.anthropic_base_url.clone(),
7838 ProviderKind::Openmodel => self.openmodel_base_url.clone(),
7839 ProviderKind::Zai => self.zai_base_url.clone(),
7840 ProviderKind::Stepfun => self.stepfun_base_url.clone(),
7841 ProviderKind::Minimax => self.minimax_base_url.clone(),
7842 ProviderKind::MinimaxAnthropic => self.minimax_anthropic_base_url.clone(),
7843 ProviderKind::Deepinfra => self.deepinfra_base_url.clone(),
7844 ProviderKind::Sakana => self.sakana_base_url.clone(),
7845 ProviderKind::LongCat => self.longcat_base_url.clone(),
7846 ProviderKind::OpencodeGo => self.opencode_go_base_url.clone(),
7847 ProviderKind::OpencodeZen => self.opencode_zen_base_url.clone(),
7848 ProviderKind::Meta => self.meta_base_url.clone(),
7849 ProviderKind::Xai => self.xai_base_url.clone(),
7850 ProviderKind::Mistral => self.mistral_base_url.clone(),
7851 ProviderKind::Google => self.google_base_url.clone(),
7852 ProviderKind::Antigravity => None,
7853 ProviderKind::Telecomjs => self.telecomjs_base_url.clone(),
7854 ProviderKind::Edenai => self.edenai_base_url.clone(),
7855 ProviderKind::Zenmux => self.zenmux_base_url.clone(),
7856 ProviderKind::Csdn => self.csdn_base_url.clone(),
7857 ProviderKind::Concentrate => self.concentrate_base_url.clone(),
7858 ProviderKind::Codewhale => self.codewhale_base_url.clone(),
7859 ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => {
7860 self.modelstudio_token_plan_base_url.clone()
7861 }
7862 ProviderKind::ModelstudioCodingPlan | ProviderKind::ModelstudioCodingPlanAnthropic => {
7863 self.modelstudio_coding_plan_base_url.clone()
7864 }
7865 // No dedicated CODEWHALE_CUSTOM_BASE_URL env override: a custom
7866 // provider's base URL comes from its `[providers.<name>]` table.
7867 ProviderKind::Custom => None,
7868 }
7869 }
7870
7871 fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
7872 let model = match provider {
7873 ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
7874 ProviderKind::Volcengine => self.volcengine_model.clone(),
7875 ProviderKind::Openrouter => self.openrouter_model.clone(),
7876 ProviderKind::Orcarouter => self.orcarouter_model.clone(),
7877 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
7878 self.siliconflow_model.clone()
7879 }
7880 ProviderKind::Arcee => self.arcee_model.clone(),
7881 ProviderKind::Moonshot => self.moonshot_model.clone(),
7882 ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
7883 ProviderKind::Novita => self.novita_model.clone(),
7884 ProviderKind::Fireworks => self.fireworks_model.clone(),
7885 ProviderKind::Huggingface => self.huggingface_model.clone(),
7886 ProviderKind::Modelscope => self.modelscope_model.clone(),
7887 ProviderKind::Together => self.together_model.clone(),
7888 ProviderKind::Qianfan => self.qianfan_model.clone(),
7889 ProviderKind::OpenaiCodex => self.openai_codex_model.clone(),
7890 ProviderKind::Anthropic => self.anthropic_model.clone(),
7891 ProviderKind::Openmodel => self.openmodel_model.clone(),
7892 ProviderKind::Zai => self.zai_model.clone(),
7893 ProviderKind::Stepfun => self.stepfun_model.clone(),
7894 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => self.minimax_model.clone(),
7895 ProviderKind::Deepinfra => self.deepinfra_model.clone(),
7896 ProviderKind::Sakana => self.sakana_model.clone(),
7897 ProviderKind::LongCat => self.longcat_model.clone(),
7898 ProviderKind::OpencodeGo => self.opencode_go_model.clone(),
7899 ProviderKind::OpencodeZen => self.opencode_zen_model.clone(),
7900 ProviderKind::Meta => self.meta_model.clone(),
7901 ProviderKind::Xai => self.xai_model.clone(),
7902 ProviderKind::Mistral => self.mistral_model.clone(),
7903 ProviderKind::Google => self.google_model.clone(),
7904 ProviderKind::Antigravity => None,
7905 ProviderKind::Telecomjs => self.telecomjs_model.clone(),
7906 ProviderKind::Edenai => self.edenai_model.clone(),
7907 ProviderKind::Zenmux => self.zenmux_model.clone(),
7908 ProviderKind::Csdn => self.csdn_model.clone(),
7909 ProviderKind::Concentrate => self.concentrate_model.clone(),
7910 ProviderKind::Codewhale => self.codewhale_model.clone(),
7911 ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => {
7912 self.modelstudio_token_plan_model.clone()
7913 }
7914 ProviderKind::ModelstudioCodingPlan | ProviderKind::ModelstudioCodingPlanAnthropic => {
7915 self.modelstudio_coding_plan_model.clone()
7916 }
7917 ProviderKind::OllamaCloud => self.ollama_cloud_model.clone(),
7918 _ => None,
7919 }?;
7920
7921 if provider_preserves_custom_base_url_model(provider, base_url) {
7922 Some(model.trim().to_string())
7923 } else {
7924 Some(normalize_model_for_provider(provider, &model))
7925 }
7926 }
7927 }
7928
7929 #[cfg(test)]
7930 mod tests;
7931
7931 lines RUST