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