1pub mod auth_source;
2pub mod catalog;
3mod config_document;
4pub mod external_credentials;
5mod harness;
6pub mod model_reference;
7pub mod models_dev;
8pub mod persistence;
9pub mod pricing;
10pub mod provider;
11mod provider_defaults;
12mod provider_kind;
13pub mod route;
14pub mod setup_state;
15pub mod user_constitution;
16mod xai_credentials;
17pub use config_document::{
18 create_config_document, mutate_config_document, replace_config_document_if_unchanged,
19 set_config_document_value, unset_config_document_value,
20};
21pub use harness::{
22 HarnessCompactionStrategy, HarnessPosture, HarnessPostureKind, HarnessProfile,
23 HarnessSafetyPosture, HarnessToolSurface, built_in_harness_profiles,
24};
25pub use model_reference::{Modality, ModelReferenceCard, ModelReferenceDatabase};
26pub(crate) use provider_defaults::*;
27pub use provider_kind::ProviderKind;
28pub use setup_state::{
29 ConstitutionAuthoring, ConstitutionChoice, ConstitutionSource, ConstitutionValidity,
30 InheritedConfigFacts, RuntimePostureSource, SetupState, SetupStep, StepEntry, StepStatus,
31};
32pub use user_constitution::{
33 APPROX_BYTES_PER_TOKEN, AutonomyPreference, CacheProjection, ClauseOrigin, ClauseStatus,
34 ConstitutionClause, ConstitutionRecommendation, MigrationOutcome, MigrationReceipt,
35 MigrationRejection, Ratification, RatificationError, RecommendationParse,
36 USER_CONSTITUTION_SCHEMA_VERSION, USER_CONSTITUTION_SCHEMA_VERSION_V1, UntrustedDraftParse,
37 UserConstitution, UserConstitutionLoad,
38};
39pub use xai_credentials::{
40 LEGACY_XAI_OAUTH_FILE_NAME, XAI_OAUTH_GENERATION_PREFIX, XAI_OAUTH_GENERATION_SUFFIX,
41 XaiOAuthCredentialStore, XaiOAuthRevocation, clear_all_xai_oauth_credentials,
42 is_valid_xai_oauth_generation, legacy_xai_oauth_path, remove_xai_oauth_generation,
43 validate_xai_oauth_generation, with_xai_oauth_lifecycle_lock,
44 with_xai_oauth_revocation_transaction, xai_oauth_credentials_dir, xai_oauth_generation_path,
45};
46
47use std::collections::{BTreeMap, BTreeSet};
48use std::ffi::{OsStr, OsString};
49use std::fmt;
50use std::fs;
51#[cfg(unix)]
52use std::io::Read;
53use std::io::Write;
54use std::path::{Component, Path, PathBuf};
55use std::sync::OnceLock;
56
57use anyhow::{Context, Result, bail};
58pub use auth_source::{AuthSourceKind, ProviderAuthSourceToml};
59pub use codewhale_execpolicy::ToolAskRule;
60use codewhale_execpolicy::{ExecPolicyEngine, PermissionAction, Ruleset};
61use codewhale_secrets::SecretSource;
62pub use codewhale_secrets::Secrets;
63pub use external_credentials::{
64 EXTERNAL_CREDENTIAL_CONSENT_VERSION, EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS,
65 ExternalCredentialAccess, ExternalCredentialConsentStatus, ExternalCredentialConsentToml,
66 ExternalCredentialReadGrant, ExternalCredentialSource, external_credential_consent_status,
67 quote_os_path, resolve_external_credential_path,
68};
69use serde::{Deserialize, Serialize};
70use sha2::{Digest as _, Sha256};
71
72#[cfg(unix)]
73use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
74
75pub const CONFIG_FILE_NAME: &str = "config.toml";
76pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml";
77
78pub const API_KEYRING_SENTINEL: &str = "__KEYRING__";
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum ConfigApiKeyValueKind {
84 Empty,
85 SecretStoreSentinel,
86 Literal,
87}
88
89#[must_use]
90pub fn classify_config_api_key_value(value: &str) -> ConfigApiKeyValueKind {
91 match value.trim() {
92 "" => ConfigApiKeyValueKind::Empty,
93 API_KEYRING_SENTINEL => ConfigApiKeyValueKind::SecretStoreSentinel,
94 _ => ConfigApiKeyValueKind::Literal,
95 }
96}
97
98fn http_headers_are_effectively_empty(headers: &BTreeMap<String, String>) -> bool {
99 !headers
100 .iter()
101 .any(|(name, value)| !name.trim().is_empty() && !value.trim().is_empty())
102}
103
104#[must_use]
110pub fn is_upstream_auth_header(name: &str) -> bool {
111 let name = name.trim();
112 is_sensitive_config_key(name) || name.eq_ignore_ascii_case("cookie")
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, Default)]
121pub struct ProviderConfigToml {
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub api_key: Option<String>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub base_url: Option<String>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub model: Option<String>,
128 #[serde(
129 default,
130 skip_serializing_if = "Option::is_none",
131 alias = "contextWindow",
132 alias = "context_window_tokens",
133 alias = "contextWindowTokens",
134 alias = "context_length",
135 alias = "contextLength"
136 )]
137 pub context_window: Option<u32>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub mode: Option<String>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub auth_mode: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub insecure_skip_tls_verify: Option<bool>,
144 #[serde(default, skip_serializing_if = "http_headers_are_effectively_empty")]
145 pub http_headers: BTreeMap<String, String>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub path_suffix: Option<String>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub auth: Option<ProviderAuthSourceToml>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub external_credentials: Option<ExternalCredentialConsentToml>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub oauth_credential_generation: Option<String>,
159 #[serde(flatten)]
162 pub extras: BTreeMap<String, toml::Value>,
163}
164
165impl ProviderConfigToml {
166 #[must_use]
167 pub fn is_empty(&self) -> bool {
168 let blank = |value: Option<&String>| value.is_none_or(|value| value.trim().is_empty());
169
170 blank(self.api_key.as_ref())
171 && blank(self.base_url.as_ref())
172 && blank(self.model.as_ref())
173 && self.context_window.is_none()
174 && blank(self.mode.as_ref())
175 && blank(self.auth_mode.as_ref())
176 && self.insecure_skip_tls_verify.is_none()
177 && http_headers_are_effectively_empty(&self.http_headers)
178 && blank(self.path_suffix.as_ref())
179 && self.auth.is_none()
180 && self.external_credentials.is_none()
181 && self.oauth_credential_generation.is_none()
182 && self.extras.is_empty()
183 }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, Default)]
187pub struct ProvidersToml {
188 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
189 pub deepseek: ProviderConfigToml,
190 #[serde(
191 default,
192 skip_serializing_if = "ProviderConfigToml::is_empty",
193 alias = "deepseek-anthropic",
194 alias = "deepseekAnthropic",
195 alias = "deepseek-claude",
196 alias = "deepseek_claude"
197 )]
198 pub deepseek_anthropic: ProviderConfigToml,
199 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
200 pub nvidia_nim: ProviderConfigToml,
201 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
202 pub openai: ProviderConfigToml,
203 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
204 pub atlascloud: ProviderConfigToml,
205 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
206 pub wanjie_ark: ProviderConfigToml,
207 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
208 pub volcengine: ProviderConfigToml,
209 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
210 pub openrouter: ProviderConfigToml,
211 #[serde(
212 default,
213 skip_serializing_if = "ProviderConfigToml::is_empty",
214 alias = "xiaomi",
215 alias = "mimo",
216 alias = "xiaomimimo"
217 )]
218 pub xiaomi_mimo: ProviderConfigToml,
219 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
220 pub novita: ProviderConfigToml,
221 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
222 pub fireworks: ProviderConfigToml,
223 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
224 pub siliconflow: ProviderConfigToml,
225 #[serde(
226 default,
227 skip_serializing_if = "ProviderConfigToml::is_empty",
228 alias = "siliconflow-CN",
229 alias = "siliconflow-cn"
230 )]
231 pub siliconflow_cn: ProviderConfigToml,
232 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
233 pub arcee: ProviderConfigToml,
234 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
235 pub moonshot: ProviderConfigToml,
236 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
237 pub sglang: ProviderConfigToml,
238 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
239 pub vllm: ProviderConfigToml,
240 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
241 pub ollama: ProviderConfigToml,
242 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
243 pub huggingface: ProviderConfigToml,
244 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
245 pub together: ProviderConfigToml,
246 #[serde(
247 default,
248 skip_serializing_if = "ProviderConfigToml::is_empty",
249 alias = "baidu-qianfan",
250 alias = "baidu_qianfan",
251 alias = "baidu"
252 )]
253 pub qianfan: ProviderConfigToml,
254 #[serde(
255 default,
256 skip_serializing_if = "ProviderConfigToml::is_empty",
257 alias = "openai-codex",
258 alias = "openai_codex",
259 alias = "codex",
260 alias = "chatgpt",
261 alias = "chatgpt-codex"
262 )]
263 pub openai_codex: ProviderConfigToml,
264 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
265 pub anthropic: ProviderConfigToml,
266 #[serde(
267 default,
268 skip_serializing_if = "ProviderConfigToml::is_empty",
269 alias = "open-model",
270 alias = "open_model"
271 )]
272 pub openmodel: ProviderConfigToml,
273 #[serde(
274 default,
275 skip_serializing_if = "ProviderConfigToml::is_empty",
276 alias = "z-ai",
277 alias = "z_ai",
278 alias = "z.ai",
279 alias = "zhipu",
280 alias = "zhipuai",
281 alias = "bigmodel",
282 alias = "big-model"
283 )]
284 pub zai: ProviderConfigToml,
285 #[serde(
286 default,
287 skip_serializing_if = "ProviderConfigToml::is_empty",
288 alias = "step-fun",
289 alias = "step_fun",
290 alias = "stepfun",
291 alias = "stepflash",
292 alias = "step-flash",
293 alias = "step_flash"
294 )]
295 pub stepfun: ProviderConfigToml,
296 #[serde(
297 default,
298 skip_serializing_if = "ProviderConfigToml::is_empty",
299 alias = "mini-max",
300 alias = "mini_max",
301 alias = "minimax"
302 )]
303 pub minimax: ProviderConfigToml,
304 #[serde(
305 default,
306 skip_serializing_if = "ProviderConfigToml::is_empty",
307 alias = "minimax-anthropic",
308 alias = "minimaxAnthropic",
309 alias = "mini-max-anthropic",
310 alias = "mini_max_anthropic"
311 )]
312 pub minimax_anthropic: ProviderConfigToml,
313 #[serde(
314 default,
315 skip_serializing_if = "ProviderConfigToml::is_empty",
316 alias = "deep-infra",
317 alias = "deep_infra"
318 )]
319 pub deepinfra: ProviderConfigToml,
320 #[serde(
321 default,
322 skip_serializing_if = "ProviderConfigToml::is_empty",
323 alias = "sakana-ai",
324 alias = "sakana_ai",
325 alias = "fugu"
326 )]
327 pub sakana: ProviderConfigToml,
328 #[serde(
329 default,
330 skip_serializing_if = "ProviderConfigToml::is_empty",
331 alias = "long-cat",
332 alias = "meituan-longcat",
333 alias = "meituan"
334 )]
335 pub longcat: ProviderConfigToml,
336 #[serde(
337 default,
338 skip_serializing_if = "ProviderConfigToml::is_empty",
339 alias = "opencode-go",
340 alias = "opencodego"
341 )]
342 pub opencode_go: ProviderConfigToml,
343 #[serde(
344 default,
345 skip_serializing_if = "ProviderConfigToml::is_empty",
346 alias = "opencode-zen",
347 alias = "opencodezen",
348 alias = "zen",
349 alias = "opencode"
350 )]
351 pub opencode_zen: ProviderConfigToml,
352 #[serde(
353 default,
354 skip_serializing_if = "ProviderConfigToml::is_empty",
355 alias = "meta-ai",
356 alias = "meta_ai",
357 alias = "meta-model-api",
358 alias = "meta_model_api",
359 alias = "muse",
360 alias = "muse-spark"
361 )]
362 pub meta: ProviderConfigToml,
363 #[serde(
364 default,
365 skip_serializing_if = "ProviderConfigToml::is_empty",
366 alias = "x-ai",
367 alias = "x_ai",
368 alias = "grok"
369 )]
370 pub xai: ProviderConfigToml,
371 #[serde(
373 default,
374 skip_serializing_if = "ProviderConfigToml::is_empty",
375 alias = "telecom-js",
376 alias = "telecom_js",
377 alias = "telecomjs-cn",
378 alias = "tokenhub"
379 )]
380 pub telecomjs: ProviderConfigToml,
381 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
387 pub custom: ProviderConfigToml,
388 #[serde(flatten)]
391 pub extras: BTreeMap<String, toml::Value>,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
400#[serde(deny_unknown_fields)]
401pub struct PermissionsToml {
402 #[serde(default, skip_serializing_if = "Vec::is_empty")]
403 pub rules: Vec<ToolAskRule>,
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub enum PermissionsFileState {
409 Missing,
411 Empty,
413 Present,
415}
416
417#[derive(Debug, Clone, PartialEq, Eq)]
424pub struct PermissionsSnapshot {
425 path: PathBuf,
426 file_state: PermissionsFileState,
427 permissions: PermissionsToml,
428 removal_tokens: Vec<String>,
429}
430
431impl PermissionsSnapshot {
432 #[must_use]
433 pub fn path(&self) -> &Path {
434 &self.path
435 }
436
437 #[must_use]
438 pub fn file_exists(&self) -> bool {
439 self.file_state != PermissionsFileState::Missing
440 }
441
442 #[must_use]
443 pub fn file_state(&self) -> PermissionsFileState {
444 self.file_state
445 }
446
447 #[must_use]
448 pub fn permissions(&self) -> &PermissionsToml {
449 &self.permissions
450 }
451
452 #[must_use]
453 pub fn rules(&self) -> &[ToolAskRule] {
454 &self.permissions.rules
455 }
456
457 #[must_use]
459 pub fn removal_token(&self, index: usize) -> Option<&str> {
460 self.removal_tokens.get(index).map(String::as_str)
461 }
462}
463
464impl PermissionsToml {
465 #[must_use]
466 pub fn is_empty(&self) -> bool {
467 self.rules.is_empty()
468 }
469
470 #[must_use]
471 pub fn ruleset(&self) -> Ruleset {
472 let mut denied = Vec::new();
473 let mut trusted = Vec::new();
474 let mut ask_rules = Vec::new();
475
476 for rule in &self.rules {
477 match rule.action {
478 PermissionAction::Deny => {
479 if let Some(cmd) = &rule.command
482 && !rule.command_exact
483 && rule.workspace.is_none()
484 {
485 denied.push(cmd.clone());
486 }
487 ask_rules.push(rule.clone());
489 }
490 PermissionAction::Allow => {
491 if let Some(cmd) = &rule.command
495 && !rule.command_exact
496 && rule.workspace.is_none()
497 {
498 trusted.push(cmd.clone());
499 }
500 ask_rules.push(rule.clone());
502 }
503 PermissionAction::Ask => {
504 ask_rules.push(rule.clone());
505 }
506 }
507 }
508
509 Ruleset::user(trusted, denied).with_ask_rules(ask_rules)
510 }
511}
512
513impl ProvidersToml {
514 #[must_use]
515 pub fn is_empty(&self) -> bool {
516 self.extras.is_empty()
517 && ProviderKind::all()
518 .iter()
519 .all(|provider| self.for_provider(*provider).is_empty())
520 }
521
522 #[must_use]
523 pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
524 match provider {
525 ProviderKind::Deepseek => &self.deepseek,
526 ProviderKind::DeepseekAnthropic => &self.deepseek_anthropic,
527 ProviderKind::NvidiaNim => &self.nvidia_nim,
528 ProviderKind::Openai => &self.openai,
529 ProviderKind::Atlascloud => &self.atlascloud,
530 ProviderKind::WanjieArk => &self.wanjie_ark,
531 ProviderKind::Volcengine => &self.volcengine,
532 ProviderKind::Openrouter => &self.openrouter,
533 ProviderKind::XiaomiMimo => &self.xiaomi_mimo,
534 ProviderKind::Novita => &self.novita,
535 ProviderKind::Fireworks => &self.fireworks,
536 ProviderKind::Siliconflow => &self.siliconflow,
537 ProviderKind::SiliconflowCN => &self.siliconflow_cn,
538 ProviderKind::Arcee => &self.arcee,
539 ProviderKind::Moonshot => &self.moonshot,
540 ProviderKind::Sglang => &self.sglang,
541 ProviderKind::Vllm => &self.vllm,
542 ProviderKind::Ollama => &self.ollama,
543 ProviderKind::Huggingface => &self.huggingface,
544 ProviderKind::Together => &self.together,
545 ProviderKind::Qianfan => &self.qianfan,
546 ProviderKind::OpenaiCodex => &self.openai_codex,
547 ProviderKind::Anthropic => &self.anthropic,
548 ProviderKind::Openmodel => &self.openmodel,
549 ProviderKind::Zai => &self.zai,
550 ProviderKind::Stepfun => &self.stepfun,
551 ProviderKind::Minimax => &self.minimax,
552 ProviderKind::MinimaxAnthropic => &self.minimax_anthropic,
553 ProviderKind::Deepinfra => &self.deepinfra,
554 ProviderKind::Sakana => &self.sakana,
555 ProviderKind::LongCat => &self.longcat,
556 ProviderKind::OpencodeGo => &self.opencode_go,
557 ProviderKind::OpencodeZen => &self.opencode_zen,
558 ProviderKind::Meta => &self.meta,
559 ProviderKind::Xai => &self.xai,
560 ProviderKind::Telecomjs => &self.telecomjs,
561 ProviderKind::Custom => &self.custom,
562 }
563 }
564
565 pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
566 match provider {
567 ProviderKind::Deepseek => &mut self.deepseek,
568 ProviderKind::DeepseekAnthropic => &mut self.deepseek_anthropic,
569 ProviderKind::NvidiaNim => &mut self.nvidia_nim,
570 ProviderKind::Openai => &mut self.openai,
571 ProviderKind::Atlascloud => &mut self.atlascloud,
572 ProviderKind::WanjieArk => &mut self.wanjie_ark,
573 ProviderKind::Volcengine => &mut self.volcengine,
574 ProviderKind::Openrouter => &mut self.openrouter,
575 ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo,
576 ProviderKind::Novita => &mut self.novita,
577 ProviderKind::Fireworks => &mut self.fireworks,
578 ProviderKind::Siliconflow => &mut self.siliconflow,
579 ProviderKind::SiliconflowCN => &mut self.siliconflow_cn,
580 ProviderKind::Arcee => &mut self.arcee,
581 ProviderKind::Moonshot => &mut self.moonshot,
582 ProviderKind::Sglang => &mut self.sglang,
583 ProviderKind::Vllm => &mut self.vllm,
584 ProviderKind::Ollama => &mut self.ollama,
585 ProviderKind::Huggingface => &mut self.huggingface,
586 ProviderKind::Together => &mut self.together,
587 ProviderKind::Qianfan => &mut self.qianfan,
588 ProviderKind::OpenaiCodex => &mut self.openai_codex,
589 ProviderKind::Anthropic => &mut self.anthropic,
590 ProviderKind::Openmodel => &mut self.openmodel,
591 ProviderKind::Zai => &mut self.zai,
592 ProviderKind::Stepfun => &mut self.stepfun,
593 ProviderKind::Minimax => &mut self.minimax,
594 ProviderKind::MinimaxAnthropic => &mut self.minimax_anthropic,
595 ProviderKind::Deepinfra => &mut self.deepinfra,
596 ProviderKind::Sakana => &mut self.sakana,
597 ProviderKind::LongCat => &mut self.longcat,
598 ProviderKind::OpencodeGo => &mut self.opencode_go,
599 ProviderKind::OpencodeZen => &mut self.opencode_zen,
600 ProviderKind::Meta => &mut self.meta,
601 ProviderKind::Xai => &mut self.xai,
602 ProviderKind::Telecomjs => &mut self.telecomjs,
603 ProviderKind::Custom => &mut self.custom,
604 }
605 }
606}
607
608fn deserialize_root_provider<'de, D>(deserializer: D) -> std::result::Result<ProviderKind, D::Error>
609where
610 D: serde::Deserializer<'de>,
611{
612 let value = String::deserialize(deserializer)?;
613 let strict = serde::de::value::StringDeserializer::<D::Error>::new(value);
614 Ok(ProviderKind::deserialize(strict).unwrap_or(ProviderKind::Custom))
615}
616
617#[derive(Debug, Clone, Serialize, Deserialize, Default)]
618pub struct ConfigToml {
619 pub api_key: Option<String>,
622 pub base_url: Option<String>,
624 #[serde(default, skip_serializing_if = "http_headers_are_effectively_empty")]
626 pub http_headers: BTreeMap<String, String>,
627 pub default_text_model: Option<String>,
629 #[serde(default, deserialize_with = "deserialize_root_provider")]
630 pub provider: ProviderKind,
631 #[doc(hidden)]
638 #[serde(skip)]
639 pub selected_provider_id: Option<String>,
640 pub model: Option<String>,
641 pub auth_mode: Option<String>,
642 pub output_mode: Option<String>,
643 pub verbosity: Option<String>,
644 pub log_level: Option<String>,
645 pub telemetry: Option<bool>,
646 pub approval_policy: Option<String>,
647 pub sandbox_mode: Option<String>,
648 #[serde(default)]
650 pub tools: Option<ToolsToml>,
651 #[serde(default, skip_serializing_if = "ProvidersToml::is_empty")]
652 pub providers: ProvidersToml,
653 #[serde(default, skip_serializing_if = "Vec::is_empty")]
657 pub fallback_providers: Vec<ProviderKind>,
658 #[serde(default)]
661 pub network: Option<NetworkPolicyToml>,
662 #[serde(default)]
665 pub verifier: Option<VerifierConfigToml>,
666 #[serde(default)]
670 pub skills: Option<SkillsToml>,
671 #[serde(default)]
674 pub snapshots: Option<SnapshotsToml>,
675 #[serde(default)]
678 pub lsp: Option<LspConfigToml>,
679 #[serde(default)]
682 pub harness_profiles: Vec<HarnessProfile>,
683 #[serde(default, skip_serializing_if = "Option::is_none")]
686 pub hotbar: Option<Vec<HotbarBindingToml>>,
687 #[serde(default)]
690 pub hook_sinks: Option<HookSinksToml>,
691 #[serde(default)]
694 pub fleet: Option<FleetConfigToml>,
695 #[serde(default)]
699 pub workflow: Option<WorkflowConfigToml>,
700 #[serde(flatten)]
701 pub extras: BTreeMap<String, toml::Value>,
702}
703
704#[derive(Debug, Clone, Copy, PartialEq, Eq)]
705enum ProviderConfigField {
706 ApiKey,
707 BaseUrl,
708 Model,
709 ContextWindow,
710 Mode,
711 AuthMode,
712 InsecureSkipTlsVerify,
713 HttpHeaders,
714 PathSuffix,
715}
716
717impl ProviderConfigField {
718 fn parse(key: &str) -> Option<Self> {
719 Some(match key {
720 "api_key" => Self::ApiKey,
721 "base_url" => Self::BaseUrl,
722 "model" => Self::Model,
723 "context_window" | "context_window_tokens" => Self::ContextWindow,
724 "mode" => Self::Mode,
725 "auth_mode" => Self::AuthMode,
726 "insecure_skip_tls_verify" => Self::InsecureSkipTlsVerify,
727 "http_headers" => Self::HttpHeaders,
728 "path_suffix" => Self::PathSuffix,
729 _ => return None,
730 })
731 }
732
733 fn key(self) -> &'static str {
734 match self {
735 Self::ApiKey => "api_key",
736 Self::BaseUrl => "base_url",
737 Self::Model => "model",
738 Self::ContextWindow => "context_window",
739 Self::Mode => "mode",
740 Self::AuthMode => "auth_mode",
741 Self::InsecureSkipTlsVerify => "insecure_skip_tls_verify",
742 Self::HttpHeaders => "http_headers",
743 Self::PathSuffix => "path_suffix",
744 }
745 }
746}
747
748fn parse_provider_config_key(key: &str) -> Option<(ProviderKind, ProviderConfigField)> {
749 let suffix = key.strip_prefix("providers.")?;
750 let (provider_key, field_key) = suffix.split_once('.')?;
751 let field = ProviderConfigField::parse(field_key)?;
752 let provider = ProviderKind::ALL
753 .iter()
754 .copied()
755 .find(|kind| kind.provider().provider_config_key() == provider_key)?;
756 Some((provider, field))
757}
758
759fn provider_config_key(provider: ProviderKind, field: ProviderConfigField) -> String {
760 format!(
761 "providers.{}.{}",
762 provider.provider().provider_config_key(),
763 field.key()
764 )
765}
766
767fn get_provider_config_value(
768 config: &ProviderConfigToml,
769 field: ProviderConfigField,
770) -> Option<String> {
771 match field {
772 ProviderConfigField::ApiKey => config.api_key.clone(),
773 ProviderConfigField::BaseUrl => config.base_url.clone(),
774 ProviderConfigField::Model => config.model.clone(),
775 ProviderConfigField::ContextWindow => config.context_window.map(|value| value.to_string()),
776 ProviderConfigField::Mode => config.mode.clone(),
777 ProviderConfigField::AuthMode => config.auth_mode.clone(),
778 ProviderConfigField::InsecureSkipTlsVerify => config
779 .insecure_skip_tls_verify
780 .map(|value| value.to_string()),
781 ProviderConfigField::HttpHeaders => serialize_http_headers(&config.http_headers),
782 ProviderConfigField::PathSuffix => config.path_suffix.clone(),
783 }
784}
785
786fn get_provider_config_display_value(
787 config: &ProviderConfigToml,
788 field: ProviderConfigField,
789) -> Option<String> {
790 match field {
791 ProviderConfigField::ApiKey => config.api_key.as_deref().map(redact_secret),
792 ProviderConfigField::HttpHeaders => {
793 serialize_http_headers_for_display(&config.http_headers)
794 }
795 _ => get_provider_config_value(config, field),
796 }
797}
798
799fn parse_context_window(value: &str) -> Result<u32> {
800 let parsed = value.trim().parse::<u32>().with_context(|| {
801 format!("invalid context_window '{value}': expected a positive token count")
802 })?;
803 if parsed == 0 {
804 bail!("context_window must be greater than 0");
805 }
806 Ok(parsed)
807}
808
809fn set_provider_config_value(
810 config: &mut ConfigToml,
811 provider: ProviderKind,
812 field: ProviderConfigField,
813 value: &str,
814) -> Result<()> {
815 match field {
816 ProviderConfigField::ApiKey => {
817 let value = value.to_string();
818 config.providers.for_provider_mut(provider).api_key = Some(value.clone());
819 if provider == ProviderKind::Deepseek {
820 config.api_key = Some(value);
821 }
822 }
823 ProviderConfigField::BaseUrl => {
824 let value = value.to_string();
825 config.providers.for_provider_mut(provider).base_url = Some(value.clone());
826 if provider == ProviderKind::Deepseek {
827 config.base_url = Some(value);
828 }
829 }
830 ProviderConfigField::Model => {
831 let value = value.to_string();
832 config.providers.for_provider_mut(provider).model = Some(value.clone());
833 if provider == ProviderKind::Deepseek {
834 config.default_text_model = Some(value);
835 }
836 }
837 ProviderConfigField::ContextWindow => {
838 config.providers.for_provider_mut(provider).context_window =
839 Some(parse_context_window(value)?);
840 }
841 ProviderConfigField::Mode => {
842 config.providers.for_provider_mut(provider).mode = Some(value.to_string());
843 }
844 ProviderConfigField::AuthMode => {
845 config.providers.for_provider_mut(provider).auth_mode = Some(value.to_string());
846 }
847 ProviderConfigField::InsecureSkipTlsVerify => {
848 config
849 .providers
850 .for_provider_mut(provider)
851 .insecure_skip_tls_verify = Some(parse_bool(value)?);
852 }
853 ProviderConfigField::HttpHeaders => {
854 let headers = parse_http_headers(value)?;
855 config.providers.for_provider_mut(provider).http_headers = headers.clone();
856 if provider == ProviderKind::Deepseek {
857 config.http_headers = headers;
858 }
859 }
860 ProviderConfigField::PathSuffix => {
861 config.providers.for_provider_mut(provider).path_suffix = Some(value.to_string());
862 }
863 }
864 Ok(())
865}
866
867fn unset_provider_config_value(
868 config: &mut ConfigToml,
869 provider: ProviderKind,
870 field: ProviderConfigField,
871) {
872 match field {
873 ProviderConfigField::ApiKey => {
874 config.providers.for_provider_mut(provider).api_key = None;
875 if provider == ProviderKind::Deepseek {
876 config.api_key = None;
877 }
878 }
879 ProviderConfigField::BaseUrl => {
880 config.providers.for_provider_mut(provider).base_url = None;
881 if provider == ProviderKind::Deepseek {
882 config.base_url = None;
883 }
884 }
885 ProviderConfigField::Model => {
886 config.providers.for_provider_mut(provider).model = None;
887 if provider == ProviderKind::Deepseek {
888 config.default_text_model = None;
889 }
890 }
891 ProviderConfigField::ContextWindow => {
892 config.providers.for_provider_mut(provider).context_window = None;
893 }
894 ProviderConfigField::Mode => {
895 config.providers.for_provider_mut(provider).mode = None;
896 }
897 ProviderConfigField::AuthMode => {
898 config.providers.for_provider_mut(provider).auth_mode = None;
899 }
900 ProviderConfigField::InsecureSkipTlsVerify => {
901 config
902 .providers
903 .for_provider_mut(provider)
904 .insecure_skip_tls_verify = None;
905 }
906 ProviderConfigField::HttpHeaders => {
907 config
908 .providers
909 .for_provider_mut(provider)
910 .http_headers
911 .clear();
912 if provider == ProviderKind::Deepseek {
913 config.http_headers.clear();
914 }
915 }
916 ProviderConfigField::PathSuffix => {
917 config.providers.for_provider_mut(provider).path_suffix = None;
918 }
919 }
920}
921
922fn insert_provider_config_values(
923 out: &mut BTreeMap<String, String>,
924 provider: ProviderKind,
925 config: &ProviderConfigToml,
926) {
927 if let Some(v) = config.api_key.as_ref() {
928 out.insert(
929 provider_config_key(provider, ProviderConfigField::ApiKey),
930 redact_secret(v),
931 );
932 }
933 if let Some(v) = config.base_url.as_ref() {
934 out.insert(
935 provider_config_key(provider, ProviderConfigField::BaseUrl),
936 v.clone(),
937 );
938 }
939 if let Some(v) = config.model.as_ref() {
940 out.insert(
941 provider_config_key(provider, ProviderConfigField::Model),
942 v.clone(),
943 );
944 }
945 if let Some(v) = config.context_window {
946 out.insert(
947 provider_config_key(provider, ProviderConfigField::ContextWindow),
948 v.to_string(),
949 );
950 }
951 if let Some(v) = config.mode.as_ref() {
952 out.insert(
953 provider_config_key(provider, ProviderConfigField::Mode),
954 v.clone(),
955 );
956 }
957 if let Some(v) = config.auth_mode.as_ref() {
958 out.insert(
959 provider_config_key(provider, ProviderConfigField::AuthMode),
960 v.clone(),
961 );
962 }
963 if let Some(v) = config.insecure_skip_tls_verify {
964 out.insert(
965 provider_config_key(provider, ProviderConfigField::InsecureSkipTlsVerify),
966 v.to_string(),
967 );
968 }
969 if let Some(v) = serialize_http_headers_for_display(&config.http_headers) {
970 out.insert(
971 provider_config_key(provider, ProviderConfigField::HttpHeaders),
972 v,
973 );
974 }
975 if let Some(v) = config.path_suffix.as_ref() {
976 out.insert(
977 provider_config_key(provider, ProviderConfigField::PathSuffix),
978 v.clone(),
979 );
980 }
981}
982
983impl ConfigToml {
984 #[must_use]
990 pub fn resolve_harness_profile(
991 &self,
992 provider_route: &str,
993 model: &str,
994 ) -> Option<&HarnessProfile> {
995 self.harness_profiles
996 .iter()
997 .chain(built_in_harness_profiles().iter())
998 .find(|profile| profile.matches_route(provider_route, model))
999 }
1000
1001 #[must_use]
1007 pub fn resolve_hotbar_bindings(&self, known_action_ids: &[&str]) -> HotbarConfigResolution {
1008 resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids)
1009 }
1010}
1011
1012#[derive(Debug, Clone, PartialEq, Eq)]
1017pub struct ProviderChain {
1018 providers: Vec<ProviderKind>,
1019 position: usize,
1020}
1021
1022pub const HOTBAR_SLOT_COUNT: u8 = 8;
1023
1024pub const DEFAULT_HOTBAR_ACTIONS: [&str; HOTBAR_SLOT_COUNT as usize] = [
1025 "voice.toggle",
1026 "session.compact",
1027 "mode.plan",
1028 "mode.agent",
1029 "mode.operate",
1030 "palette.open",
1031 "sidebar.toggle",
1032 "trust.toggle",
1033];
1034
1035#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1037#[serde(deny_unknown_fields)]
1038pub struct HotbarBindingToml {
1039 pub slot: u8,
1040 pub action: String,
1041 #[serde(default)]
1042 pub label: Option<String>,
1043}
1044
1045#[derive(Debug, Clone, PartialEq, Eq)]
1047pub struct HotbarBinding {
1048 pub slot: u8,
1049 pub action: String,
1050 pub label: Option<String>,
1051}
1052
1053#[derive(Debug, Clone, PartialEq, Eq)]
1056pub enum HotbarConfigWarning {
1057 SlotOutOfRange {
1058 slot: u8,
1059 action: String,
1060 },
1061 DuplicateSlot {
1062 slot: u8,
1063 previous_action: String,
1064 replacement_action: String,
1065 },
1066 UnknownAction {
1067 slot: u8,
1068 action: String,
1069 },
1070}
1071
1072impl fmt::Display for HotbarConfigWarning {
1073 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1074 match self {
1075 Self::SlotOutOfRange { slot, action } => write!(
1076 f,
1077 "hotbar slot {slot} for action '{action}' is outside 1-{HOTBAR_SLOT_COUNT}; skipped"
1078 ),
1079 Self::DuplicateSlot {
1080 slot,
1081 previous_action,
1082 replacement_action,
1083 } => write!(
1084 f,
1085 "hotbar slot {slot} was bound to '{previous_action}' more than once; using '{replacement_action}'"
1086 ),
1087 Self::UnknownAction { slot, action } => write!(
1088 f,
1089 "hotbar slot {slot} references unknown action '{action}'; keeping binding"
1090 ),
1091 }
1092 }
1093}
1094
1095#[derive(Debug, Clone, PartialEq, Eq)]
1096pub struct HotbarConfigResolution {
1097 pub bindings: Vec<HotbarBinding>,
1098 pub warnings: Vec<HotbarConfigWarning>,
1099}
1100
1101#[must_use]
1102pub fn default_hotbar_bindings() -> Vec<HotbarBinding> {
1103 DEFAULT_HOTBAR_ACTIONS
1104 .iter()
1105 .enumerate()
1106 .map(|(idx, action)| HotbarBinding {
1107 slot: u8::try_from(idx + 1).expect("default hotbar slot fits in u8"),
1108 action: (*action).to_string(),
1109 label: None,
1110 })
1111 .collect()
1112}
1113
1114#[must_use]
1120pub fn default_hotbar_bindings_toml() -> Vec<HotbarBindingToml> {
1121 default_hotbar_bindings()
1122 .into_iter()
1123 .map(|binding| HotbarBindingToml {
1124 slot: binding.slot,
1125 action: binding.action,
1126 label: binding.label,
1127 })
1128 .collect()
1129}
1130
1131#[must_use]
1132pub fn resolve_hotbar_bindings(
1133 configured: Option<&[HotbarBindingToml]>,
1134 known_action_ids: &[&str],
1135) -> HotbarConfigResolution {
1136 let known = known_action_ids.iter().copied().collect::<BTreeSet<&str>>();
1137 let mut warnings = Vec::new();
1138
1139 let source = match configured {
1140 Some(bindings) => bindings
1141 .iter()
1142 .map(|binding| HotbarBinding {
1143 slot: binding.slot,
1144 action: binding.action.clone(),
1145 label: binding.label.clone(),
1146 })
1147 .collect::<Vec<_>>(),
1148 None => Vec::new(),
1152 };
1153
1154 let mut by_slot: BTreeMap<u8, HotbarBinding> = BTreeMap::new();
1155 for binding in source {
1156 if !(1..=HOTBAR_SLOT_COUNT).contains(&binding.slot) {
1157 warnings.push(HotbarConfigWarning::SlotOutOfRange {
1158 slot: binding.slot,
1159 action: binding.action,
1160 });
1161 continue;
1162 }
1163 if !known.is_empty() && !known.contains(binding.action.as_str()) {
1164 warnings.push(HotbarConfigWarning::UnknownAction {
1165 slot: binding.slot,
1166 action: binding.action.clone(),
1167 });
1168 }
1169 if let Some(previous) = by_slot.insert(binding.slot, binding.clone()) {
1170 warnings.push(HotbarConfigWarning::DuplicateSlot {
1171 slot: binding.slot,
1172 previous_action: previous.action,
1173 replacement_action: binding.action,
1174 });
1175 }
1176 }
1177
1178 HotbarConfigResolution {
1179 bindings: by_slot.into_values().collect(),
1180 warnings,
1181 }
1182}
1183
1184impl ProviderChain {
1185 #[must_use]
1186 pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self {
1187 let mut providers = vec![active];
1188 for fallback in fallbacks {
1189 if *fallback != active && !providers.contains(fallback) {
1190 providers.push(*fallback);
1191 }
1192 }
1193 Self {
1194 providers,
1195 position: 0,
1196 }
1197 }
1198
1199 #[must_use]
1200 pub fn providers(&self) -> &[ProviderKind] {
1201 &self.providers
1202 }
1203
1204 #[must_use]
1205 pub fn position(&self) -> usize {
1206 self.position
1207 }
1208
1209 #[must_use]
1210 pub fn current(&self) -> ProviderKind {
1211 self.providers
1212 .get(self.position)
1213 .copied()
1214 .or_else(|| self.providers.first().copied())
1215 .unwrap_or_default()
1216 }
1217
1218 #[must_use]
1219 pub fn has_next(&self) -> bool {
1220 self.position + 1 < self.providers.len()
1221 }
1222
1223 pub fn advance(&mut self) -> Option<ProviderKind> {
1224 if !self.has_next() {
1225 return None;
1226 }
1227 self.position += 1;
1228 Some(self.current())
1229 }
1230
1231 pub fn reset(&mut self) {
1232 self.position = 0;
1233 }
1234
1235 #[must_use]
1236 pub fn is_fallback_active(&self) -> bool {
1237 self.position > 0
1238 }
1239
1240 #[must_use]
1242 pub fn remaining(&self) -> usize {
1243 self.providers.len() - self.position
1244 }
1245}
1246
1247#[cfg(test)]
1248mod provider_chain_tests {
1249 use super::*;
1250
1251 #[test]
1252 fn current_on_empty_chain_returns_default_provider() {
1253 let chain = ProviderChain {
1254 providers: vec![],
1255 position: 0,
1256 };
1257 assert_eq!(chain.current(), ProviderKind::default());
1258 }
1259}
1260
1261#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1263pub struct HookSinksToml {
1264 #[serde(default)]
1269 pub unix_socket_path: Option<PathBuf>,
1270}
1271
1272#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1275pub struct SkillsToml {
1276 #[serde(default)]
1279 pub registry_url: Option<String>,
1280 #[serde(default)]
1283 pub max_install_size_bytes: Option<u64>,
1284}
1285
1286#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1288pub struct ToolsToml {
1289 #[serde(default)]
1291 pub always_load: Vec<String>,
1292}
1293
1294#[derive(Debug, Clone, Serialize, Deserialize)]
1297pub struct SnapshotsToml {
1298 #[serde(default = "default_snapshots_enabled")]
1299 pub enabled: bool,
1300 #[serde(default = "default_snapshot_max_age_days")]
1301 pub max_age_days: u64,
1302}
1303
1304fn default_snapshots_enabled() -> bool {
1305 true
1306}
1307
1308fn default_snapshot_max_age_days() -> u64 {
1309 7
1310}
1311
1312impl Default for SnapshotsToml {
1313 fn default() -> Self {
1314 Self {
1315 enabled: default_snapshots_enabled(),
1316 max_age_days: default_snapshot_max_age_days(),
1317 }
1318 }
1319}
1320
1321#[derive(Debug, Clone, Serialize, Deserialize)]
1324pub struct FleetConfigToml {
1325 #[serde(default = "default_fleet_trust_level_str")]
1328 pub default_trust_level: String,
1329 #[serde(default = "default_fleet_require_identity")]
1332 pub require_identity_verification: bool,
1333 #[serde(default = "default_fleet_max_trust_level_str")]
1336 pub max_trust_level: String,
1337 #[serde(default)]
1344 pub roles: BTreeMap<String, FleetRolePreset>,
1345 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1349 pub profiles: BTreeMap<String, FleetProfile>,
1350 #[serde(default)]
1352 pub exec: FleetExecConfig,
1353}
1354
1355pub const DEFAULT_SPAWN_DEPTH: u32 = 3;
1370pub const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 900;
1371pub const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1;
1372pub const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600;
1373
1374pub const MAX_SPAWN_DEPTH_CEILING: u32 = 8;
1380
1381#[derive(Debug, Clone, Serialize, Deserialize)]
1386pub struct FleetExecConfig {
1387 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1389 pub allowed_tools: Vec<String>,
1390 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1392 pub disallowed_tools: Vec<String>,
1393 #[serde(default = "default_fleet_max_turns")]
1396 pub max_turns: u32,
1397 #[serde(default = "default_fleet_max_spawn_depth")]
1403 pub max_spawn_depth: u32,
1404 #[serde(default, skip_serializing_if = "String::is_empty")]
1407 pub append_system_prompt: String,
1408 #[serde(default = "default_fleet_output_format")]
1411 pub output_format: String,
1412}
1413
1414fn default_fleet_max_turns() -> u32 {
1415 u32::MAX
1416}
1417
1418fn default_fleet_max_spawn_depth() -> u32 {
1419 DEFAULT_SPAWN_DEPTH
1420}
1421
1422fn default_fleet_output_format() -> String {
1423 "text".to_string()
1424}
1425
1426impl Default for FleetExecConfig {
1427 fn default() -> Self {
1428 Self {
1429 allowed_tools: Vec::new(),
1430 disallowed_tools: Vec::new(),
1431 max_turns: default_fleet_max_turns(),
1432 max_spawn_depth: default_fleet_max_spawn_depth(),
1433 append_system_prompt: String::new(),
1434 output_format: default_fleet_output_format(),
1435 }
1436 }
1437}
1438
1439#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1445pub struct FleetProfile {
1446 #[serde(default)]
1448 pub slot: FleetSlot,
1449 #[serde(default)]
1451 pub role: FleetRole,
1452 #[serde(default)]
1454 pub loadout: FleetLoadout,
1455 #[serde(default, skip_serializing_if = "Option::is_none")]
1460 pub model: Option<String>,
1461 #[serde(default, skip_serializing_if = "Option::is_none")]
1474 pub provider: Option<String>,
1475 #[serde(default, skip_serializing_if = "Option::is_none")]
1481 pub reasoning_effort: Option<String>,
1482 #[serde(default)]
1484 pub permissions: FleetProfilePermissions,
1485 #[serde(default)]
1487 pub delegation: FleetDelegationHints,
1488}
1489
1490#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
1495pub struct FleetRole {
1496 pub name: String,
1498 #[serde(default, skip_serializing_if = "Option::is_none")]
1500 pub description: Option<String>,
1501 #[serde(default, skip_serializing_if = "Option::is_none")]
1503 pub instructions: Option<String>,
1504}
1505
1506impl Default for FleetRole {
1507 fn default() -> Self {
1508 Self {
1509 name: "general".to_string(),
1510 description: None,
1511 instructions: None,
1512 }
1513 }
1514}
1515
1516impl<'de> Deserialize<'de> for FleetRole {
1517 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1518 where
1519 D: serde::Deserializer<'de>,
1520 {
1521 #[derive(Deserialize)]
1522 #[serde(untagged)]
1523 enum FleetRoleWire {
1524 Name(String),
1525 Full {
1526 #[serde(default)]
1527 name: Option<String>,
1528 #[serde(default)]
1529 description: Option<String>,
1530 #[serde(default)]
1531 instructions: Option<String>,
1532 },
1533 }
1534
1535 match FleetRoleWire::deserialize(deserializer)? {
1536 FleetRoleWire::Name(name) => Ok(Self {
1537 name,
1538 ..Self::default()
1539 }),
1540 FleetRoleWire::Full {
1541 name,
1542 description,
1543 instructions,
1544 } => Ok(Self {
1545 name: name.unwrap_or_else(|| Self::default().name),
1546 description,
1547 instructions,
1548 }),
1549 }
1550 }
1551}
1552
1553#[derive(Debug, Clone, PartialEq, Eq, Default)]
1555pub enum FleetSlot {
1556 Manager,
1557 Scout,
1558 Implementer,
1559 Reviewer,
1560 Verifier,
1561 Operator,
1562 Summarizer,
1563 #[default]
1564 General,
1565 Custom(String),
1566}
1567
1568impl FleetSlot {
1569 #[must_use]
1570 pub fn as_str(&self) -> &str {
1571 match self {
1572 Self::Manager => "manager",
1573 Self::Scout => "scout",
1574 Self::Implementer => "implementer",
1575 Self::Reviewer => "reviewer",
1576 Self::Verifier => "verifier",
1577 Self::Operator => "operator",
1578 Self::Summarizer => "summarizer",
1579 Self::General => "general",
1580 Self::Custom(value) => value.as_str(),
1581 }
1582 }
1583
1584 #[must_use]
1585 pub fn from_name(value: &str) -> Self {
1586 match value.trim() {
1587 "manager" | "coordinator" => Self::Manager,
1588 "scout" | "research" | "research-worker" => Self::Scout,
1589 "implementer" | "builder" => Self::Implementer,
1590 "reviewer" => Self::Reviewer,
1591 "verifier" | "tester" => Self::Verifier,
1592 "operator" | "incident" | "incident-worker" => Self::Operator,
1593 "summarizer" | "reducer" => Self::Summarizer,
1594 "general" | "" => Self::General,
1595 other => Self::Custom(other.to_string()),
1599 }
1600 }
1601}
1602
1603impl Serialize for FleetSlot {
1604 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1605 where
1606 S: serde::Serializer,
1607 {
1608 serializer.serialize_str(self.as_str())
1609 }
1610}
1611
1612impl<'de> Deserialize<'de> for FleetSlot {
1613 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1614 where
1615 D: serde::Deserializer<'de>,
1616 {
1617 let value = String::deserialize(deserializer)?;
1618 Ok(Self::from_name(&value))
1619 }
1620}
1621
1622#[derive(Debug, Clone, PartialEq, Eq, Default)]
1624pub enum FleetLoadout {
1625 #[default]
1627 Inherit,
1628 Fast,
1630 Custom(String),
1634}
1635
1636impl FleetLoadout {
1637 #[must_use]
1638 pub fn as_str(&self) -> &str {
1639 match self {
1640 Self::Inherit => "inherit",
1641 Self::Fast => "fast",
1642 Self::Custom(value) => value.as_str(),
1643 }
1644 }
1645
1646 #[must_use]
1647 pub fn from_name(value: &str) -> Self {
1648 match value.trim() {
1649 "inherit" | "default" | "auto" | "" => Self::Inherit,
1650 "fast" => Self::Fast,
1651 other => Self::Custom(other.to_string()),
1655 }
1656 }
1657}
1658
1659impl Serialize for FleetLoadout {
1660 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1661 where
1662 S: serde::Serializer,
1663 {
1664 serializer.serialize_str(self.as_str())
1665 }
1666}
1667
1668impl<'de> Deserialize<'de> for FleetLoadout {
1669 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1670 where
1671 D: serde::Deserializer<'de>,
1672 {
1673 let value = String::deserialize(deserializer)?;
1674 Ok(Self::from_name(&value))
1675 }
1676}
1677
1678#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1680pub struct FleetProfilePermissions {
1681 #[serde(default)]
1683 pub allow_shell: bool,
1684 #[serde(default)]
1686 pub trust: bool,
1687 #[serde(default = "default_fleet_profile_approval_required")]
1689 pub approval_required: bool,
1690}
1691
1692fn default_fleet_profile_approval_required() -> bool {
1693 true
1694}
1695
1696impl Default for FleetProfilePermissions {
1697 fn default() -> Self {
1698 Self {
1699 allow_shell: false,
1700 trust: false,
1701 approval_required: true,
1702 }
1703 }
1704}
1705
1706#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1708pub struct FleetDelegationHints {
1709 #[serde(default, skip_serializing_if = "Option::is_none")]
1712 pub max_spawn_depth: Option<u32>,
1713 #[serde(
1715 default,
1716 alias = "concurrency",
1717 skip_serializing_if = "Option::is_none"
1718 )]
1719 pub max_concurrency: Option<usize>,
1720}
1721
1722#[derive(Debug, Clone, Serialize, Deserialize)]
1731pub struct FleetRolePreset {
1732 #[serde(skip_serializing_if = "Option::is_none")]
1734 pub description: Option<String>,
1735 #[serde(skip_serializing_if = "Option::is_none")]
1737 pub tool_profile: Option<String>,
1738 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1740 pub tools: Vec<String>,
1741 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1743 pub capabilities: Vec<String>,
1744 #[serde(skip_serializing_if = "Option::is_none")]
1746 pub timeout_seconds: Option<u64>,
1747 #[serde(skip_serializing_if = "Option::is_none")]
1749 pub trust_level: Option<String>,
1750}
1751
1752fn default_fleet_trust_level_str() -> String {
1753 "sandbox".to_string()
1754}
1755
1756fn default_fleet_require_identity() -> bool {
1757 true
1758}
1759
1760fn default_fleet_max_trust_level_str() -> String {
1761 "operator".to_string()
1762}
1763
1764impl Default for FleetConfigToml {
1765 fn default() -> Self {
1766 Self {
1767 default_trust_level: default_fleet_trust_level_str(),
1768 require_identity_verification: default_fleet_require_identity(),
1769 max_trust_level: default_fleet_max_trust_level_str(),
1770 roles: BTreeMap::new(),
1771 profiles: BTreeMap::new(),
1772 exec: FleetExecConfig::default(),
1773 }
1774 }
1775}
1776
1777impl FleetConfigToml {
1778 #[must_use]
1781 pub fn resolve_role(&self, name: &str) -> Option<FleetRolePreset> {
1782 self.roles
1783 .get(name)
1784 .cloned()
1785 .or_else(|| built_in_role_presets().get(name).cloned())
1786 }
1787}
1788
1789#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1796pub struct WorkflowConfigToml {
1797 #[serde(default = "default_workflow_automatic")]
1800 pub automatic: bool,
1801 #[serde(default = "default_workflow_auto_start_read_only")]
1805 pub auto_start_read_only: bool,
1806 #[serde(default = "default_workflow_require_approval_for_writes")]
1809 pub require_approval_for_writes: bool,
1810 #[serde(default = "default_workflow_auto_start_child_limit")]
1813 pub auto_start_child_limit: u32,
1814 #[serde(default = "default_workflow_max_children")]
1816 pub max_children: u32,
1817 #[serde(default = "default_workflow_max_concurrent")]
1819 pub max_concurrent: u32,
1820 #[serde(default = "default_workflow_max_depth")]
1822 pub max_depth: u32,
1823 #[serde(default = "default_workflow_default_token_budget")]
1825 pub default_token_budget: u64,
1826 #[serde(default = "default_workflow_max_parallel_writes_without_worktree")]
1829 pub max_parallel_writes_without_worktree: u32,
1830 #[serde(default = "default_workflow_persist_completed_activity")]
1833 pub persist_completed_activity: bool,
1834 #[serde(default = "default_workflow_persist_completed_across_restarts")]
1837 pub persist_completed_across_restarts: bool,
1838}
1839
1840fn default_workflow_automatic() -> bool {
1841 true
1842}
1843
1844fn default_workflow_auto_start_read_only() -> bool {
1845 true
1846}
1847
1848fn default_workflow_require_approval_for_writes() -> bool {
1849 true
1850}
1851
1852fn default_workflow_auto_start_child_limit() -> u32 {
1853 16
1855}
1856
1857fn default_workflow_max_children() -> u32 {
1858 1000
1859}
1860
1861fn default_workflow_max_concurrent() -> u32 {
1862 16
1863}
1864
1865fn default_workflow_max_depth() -> u32 {
1866 2
1867}
1868
1869fn default_workflow_default_token_budget() -> u64 {
1870 120_000
1871}
1872
1873fn default_workflow_max_parallel_writes_without_worktree() -> u32 {
1874 0
1875}
1876
1877fn default_workflow_persist_completed_activity() -> bool {
1878 true
1879}
1880
1881fn default_workflow_persist_completed_across_restarts() -> bool {
1882 true
1883}
1884
1885impl Default for WorkflowConfigToml {
1886 fn default() -> Self {
1887 Self {
1888 automatic: default_workflow_automatic(),
1889 auto_start_read_only: default_workflow_auto_start_read_only(),
1890 require_approval_for_writes: default_workflow_require_approval_for_writes(),
1891 auto_start_child_limit: default_workflow_auto_start_child_limit(),
1892 max_children: default_workflow_max_children(),
1893 max_concurrent: default_workflow_max_concurrent(),
1894 max_depth: default_workflow_max_depth(),
1895 default_token_budget: default_workflow_default_token_budget(),
1896 max_parallel_writes_without_worktree:
1897 default_workflow_max_parallel_writes_without_worktree(),
1898 persist_completed_activity: default_workflow_persist_completed_activity(),
1899 persist_completed_across_restarts: default_workflow_persist_completed_across_restarts(),
1900 }
1901 }
1902}
1903
1904#[must_use]
1906pub fn built_in_role_presets() -> BTreeMap<String, FleetRolePreset> {
1907 [
1908 (
1909 "smoke-runner".to_string(),
1910 FleetRolePreset {
1911 description: Some("Lightweight read-only smoke check worker".to_string()),
1912 tool_profile: Some("read-only".to_string()),
1913 tools: vec![],
1914 capabilities: vec![],
1915 timeout_seconds: Some(300),
1916 trust_level: Some("local".to_string()),
1917 },
1918 ),
1919 (
1920 "reviewer".to_string(),
1921 FleetRolePreset {
1922 description: Some("Read-only code and documentation review".to_string()),
1923 tool_profile: Some("read-only".to_string()),
1924 tools: vec![],
1925 capabilities: vec![],
1926 timeout_seconds: Some(600),
1927 trust_level: None,
1928 },
1929 ),
1930 (
1931 "builder".to_string(),
1932 FleetRolePreset {
1933 description: Some(
1934 "Read-write builder with compilation and test access".to_string(),
1935 ),
1936 tool_profile: Some("read-write".to_string()),
1937 tools: vec![],
1938 capabilities: vec![],
1939 timeout_seconds: Some(1800),
1940 trust_level: Some("local".to_string()),
1941 },
1942 ),
1943 (
1944 "read-only".to_string(),
1945 FleetRolePreset {
1946 description: Some(
1947 "Minimal read-only observer with no writes or secrets".to_string(),
1948 ),
1949 tool_profile: Some("read-only".to_string()),
1950 tools: vec![],
1951 capabilities: vec![],
1952 timeout_seconds: Some(300),
1953 trust_level: Some("sandbox".to_string()),
1954 },
1955 ),
1956 ]
1957 .into()
1958}
1959
1960#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1966#[serde(rename_all = "snake_case")]
1967pub enum VerifierVerdictPolicy {
1968 #[default]
1969 Hunt,
1970}
1971
1972#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1974pub struct VerifierConfigToml {
1975 #[serde(default)]
1979 pub enabled: bool,
1980 #[serde(default)]
1982 pub verdict_policy: VerifierVerdictPolicy,
1983}
1984
1985impl Default for VerifierConfigToml {
1986 fn default() -> Self {
1987 Self {
1988 enabled: false,
1989 verdict_policy: VerifierVerdictPolicy::Hunt,
1990 }
1991 }
1992}
1993
1994#[derive(Debug, Clone, Serialize, Deserialize)]
1997pub struct NetworkPolicyToml {
1998 #[serde(default = "default_network_decision")]
2001 pub default: String,
2002 #[serde(default)]
2005 pub allow: Vec<String>,
2006 #[serde(default)]
2008 pub deny: Vec<String>,
2009 #[serde(default)]
2012 pub proxy: Vec<String>,
2013 #[serde(default)]
2016 pub proxy_fake_ip_cidrs: Vec<String>,
2017 #[serde(default = "default_network_audit")]
2019 pub audit: bool,
2020}
2021
2022fn default_network_decision() -> String {
2023 "prompt".to_string()
2024}
2025
2026fn default_network_audit() -> bool {
2027 true
2028}
2029
2030impl Default for NetworkPolicyToml {
2031 fn default() -> Self {
2032 Self {
2033 default: default_network_decision(),
2034 allow: Vec::new(),
2035 deny: Vec::new(),
2036 proxy: Vec::new(),
2037 proxy_fake_ip_cidrs: Vec::new(),
2038 audit: default_network_audit(),
2039 }
2040 }
2041}
2042
2043#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2046pub struct CustomLspDef {
2047 pub language_id: String,
2049 pub command: String,
2051 #[serde(default)]
2053 pub args: Vec<String>,
2054}
2055
2056#[derive(Debug, Clone, Serialize, Deserialize, Default)]
2060pub struct LspConfigToml {
2061 pub enabled: Option<bool>,
2063 pub poll_after_edit_ms: Option<u64>,
2065 pub max_diagnostics_per_file: Option<usize>,
2067 pub include_warnings: Option<bool>,
2069 pub servers: Option<BTreeMap<String, Vec<String>>>,
2071 pub custom: Option<BTreeMap<String, CustomLspDef>>,
2074}
2075
2076impl ConfigToml {
2077 #[must_use]
2080 pub fn provider_id(&self) -> &str {
2081 self.named_custom_provider_id()
2082 .unwrap_or_else(|| self.provider.as_str())
2083 }
2084
2085 #[must_use]
2088 pub fn named_custom_provider_id(&self) -> Option<&str> {
2089 (self.provider == ProviderKind::Custom)
2090 .then_some(self.selected_provider_id.as_deref())
2091 .flatten()
2092 }
2093
2094 fn named_custom_provider_table(&self, provider_id: &str) -> Result<&toml::value::Table> {
2095 let table = self
2096 .providers
2097 .extras
2098 .get(provider_id)
2099 .and_then(toml::Value::as_table)
2100 .with_context(|| {
2101 format!(
2102 "custom provider '{provider_id}' requires a matching [providers.{provider_id}] table"
2103 )
2104 })?;
2105 let compatible = table
2106 .get("kind")
2107 .and_then(toml::Value::as_str)
2108 .is_some_and(|kind| {
2109 kind.trim()
2110 .to_ascii_lowercase()
2111 .replace('_', "-")
2112 .eq("openai-compatible")
2113 });
2114 if !compatible {
2115 bail!(
2116 "custom provider '{provider_id}' must set [providers.{provider_id}].kind = \"openai-compatible\""
2117 );
2118 }
2119 Ok(table)
2120 }
2121
2122 fn named_custom_provider_config(&self) -> Option<ProviderConfigToml> {
2123 let provider_id = self.named_custom_provider_id()?;
2124 self.named_custom_provider_table(provider_id).ok()?;
2125 self.providers
2126 .extras
2127 .get(provider_id)
2128 .cloned()?
2129 .try_into()
2130 .ok()
2131 }
2132
2133 fn bind_persisted_provider_id(&mut self, provider_id: &str) -> Result<()> {
2134 self.selected_provider_id = None;
2135 if self.provider != ProviderKind::Custom || provider_id == ProviderKind::Custom.as_str() {
2136 return Ok(());
2137 }
2138
2139 self.named_custom_provider_table(provider_id)?;
2140 self.selected_provider_id = Some(provider_id.to_string());
2141 Ok(())
2142 }
2143
2144 pub fn merge_project_overrides(&mut self, project: ConfigToml) {
2153 if project.default_text_model.is_some() {
2154 self.default_text_model = project.default_text_model;
2155 }
2156 if project.model.is_some() {
2157 self.model = project.model;
2158 }
2159 if project.output_mode.is_some() {
2160 self.output_mode = project.output_mode;
2161 }
2162 if project.verbosity.is_some() {
2163 self.verbosity = project.verbosity;
2164 }
2165 if project.log_level.is_some() {
2166 self.log_level = project.log_level;
2167 }
2168 if let Some(policy) = project.approval_policy
2169 && project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy)
2170 {
2171 self.approval_policy = Some(policy);
2172 }
2173 if let Some(mode) = project.sandbox_mode
2174 && project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode)
2175 {
2176 self.sandbox_mode = Some(mode);
2177 }
2178 if project.tools.is_some() {
2179 self.tools = project.tools;
2180 }
2181 for provider in ProviderKind::ALL {
2182 merge_project_provider_config(
2183 self.providers.for_provider_mut(provider),
2184 project.providers.for_provider(provider),
2185 );
2186 }
2187 }
2188
2189 #[must_use]
2190 pub fn get_value(&self, key: &str) -> Option<String> {
2191 if let Some((provider, field)) = parse_provider_config_key(key) {
2192 return get_provider_config_value(self.providers.for_provider(provider), field);
2193 }
2194
2195 match key {
2196 "provider" => Some(self.provider_id().to_string()),
2197 "stream_chunk_timeout_secs" | "tui.stream_chunk_timeout_secs" => {
2198 Some(self.stream_chunk_timeout_secs().to_string())
2199 }
2200 "api_key" => self.api_key.clone(),
2201 "base_url" => self.base_url.clone(),
2202 "http_headers" => serialize_http_headers(&self.http_headers),
2203 "default_text_model" => self.default_text_model.clone(),
2204 "model" => self.model.clone(),
2205 "auth.mode" => self.auth_mode.clone(),
2206 "output_mode" => self.output_mode.clone(),
2207 "verbosity" => self.verbosity.clone(),
2208 "log_level" => self.log_level.clone(),
2209 "telemetry" => self.telemetry.map(|v| v.to_string()),
2210 "approval_policy" => self.approval_policy.clone(),
2211 "sandbox_mode" => self.sandbox_mode.clone(),
2212 "tools.always_load" => self.tools.as_ref().map(|tools| tools.always_load.join(",")),
2213 "hook_sinks.unix_socket_path" => self
2214 .hook_sinks
2215 .as_ref()
2216 .and_then(|sinks| sinks.unix_socket_path.as_ref())
2217 .map(|path| path.display().to_string()),
2218 _ => self.extras.get(key).map(toml::Value::to_string),
2219 }
2220 }
2221
2222 #[must_use]
2231 pub fn get_raw_string(&self, key: &str) -> Option<&str> {
2232 self.extras.get(key).and_then(toml::Value::as_str)
2233 }
2234
2235 #[must_use]
2236 pub fn get_display_value(&self, key: &str) -> Option<String> {
2237 if let Some((provider, field)) = parse_provider_config_key(key) {
2238 return get_provider_config_display_value(self.providers.for_provider(provider), field);
2239 }
2240
2241 if key == "http_headers" {
2242 return serialize_http_headers_for_display(&self.http_headers);
2243 }
2244
2245 if let Some(value) = self.extras.get(key) {
2246 return Some(redact_toml_value_for_display(key, value));
2247 }
2248
2249 self.get_value(key).map(|value| {
2250 if is_sensitive_config_key(key) {
2251 redact_secret(&value)
2252 } else {
2253 value
2254 }
2255 })
2256 }
2257
2258 #[must_use]
2259 pub fn stream_chunk_timeout_secs(&self) -> u64 {
2260 let raw = self
2261 .extras
2262 .get("tui")
2263 .and_then(toml::Value::as_table)
2264 .and_then(|table| table.get("stream_chunk_timeout_secs"))
2265 .and_then(toml_value_as_u64)
2266 .or_else(|| {
2267 self.extras
2268 .get("tui.stream_chunk_timeout_secs")
2269 .and_then(toml_value_as_u64)
2270 })
2271 .or_else(|| {
2272 self.extras
2273 .get("stream_chunk_timeout_secs")
2274 .and_then(toml_value_as_u64)
2275 })
2276 .unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS);
2277 if raw == 0 {
2278 DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
2279 } else {
2280 raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS)
2281 }
2282 }
2283
2284 pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
2285 if let Some((provider, field)) = parse_provider_config_key(key) {
2286 return set_provider_config_value(self, provider, field, value);
2287 }
2288
2289 match key {
2290 "provider" => {
2291 if let Some(provider) = ProviderKind::parse(value) {
2292 self.provider = provider;
2293 self.selected_provider_id = None;
2294 } else {
2295 let provider_id = value.trim();
2296 self.named_custom_provider_table(provider_id)
2297 .with_context(|| {
2298 format!(
2299 "unknown provider '{value}': expected {} or a configured custom provider",
2300 ProviderKind::names_hint()
2301 )
2302 })?;
2303 self.provider = ProviderKind::Custom;
2304 self.selected_provider_id = Some(provider_id.to_string());
2305 }
2306 }
2307 "api_key" => self.api_key = Some(value.to_string()),
2308 "base_url" => self.base_url = Some(value.to_string()),
2309 "http_headers" => self.http_headers = parse_http_headers(value)?,
2310 "default_text_model" => self.default_text_model = Some(value.to_string()),
2311 "model" => self.model = Some(value.to_string()),
2312 "auth.mode" => self.auth_mode = Some(value.to_string()),
2313 "output_mode" => self.output_mode = Some(value.to_string()),
2314 "verbosity" => self.verbosity = Some(value.to_string()),
2315 "log_level" => self.log_level = Some(value.to_string()),
2316 "telemetry" => {
2317 self.telemetry = Some(parse_bool(value)?);
2318 }
2319 "approval_policy" => self.approval_policy = Some(value.to_string()),
2320 "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
2321 "hook_sinks.unix_socket_path" => {
2322 self.hook_sinks
2323 .get_or_insert_with(HookSinksToml::default)
2324 .unix_socket_path = Some(PathBuf::from(value));
2325 }
2326 _ => {
2327 self.extras
2328 .insert(key.to_string(), toml::Value::String(value.to_string()));
2329 }
2330 }
2331 Ok(())
2332 }
2333
2334 pub fn unset_value(&mut self, key: &str) -> Result<()> {
2335 if let Some((provider, field)) = parse_provider_config_key(key) {
2336 unset_provider_config_value(self, provider, field);
2337 return Ok(());
2338 }
2339
2340 match key {
2341 "provider" => {
2342 self.provider = ProviderKind::Deepseek;
2343 self.selected_provider_id = None;
2344 }
2345 "api_key" => self.api_key = None,
2346 "base_url" => self.base_url = None,
2347 "http_headers" => self.http_headers.clear(),
2348 "default_text_model" => self.default_text_model = None,
2349 "model" => self.model = None,
2350 "auth.mode" => self.auth_mode = None,
2351 "output_mode" => self.output_mode = None,
2352 "verbosity" => self.verbosity = None,
2353 "log_level" => self.log_level = None,
2354 "telemetry" => self.telemetry = None,
2355 "approval_policy" => self.approval_policy = None,
2356 "sandbox_mode" => self.sandbox_mode = None,
2357 "hook_sinks.unix_socket_path" => {
2358 if let Some(sinks) = self.hook_sinks.as_mut() {
2359 sinks.unix_socket_path = None;
2360 }
2361 }
2362 _ => {
2363 self.extras.remove(key);
2364 }
2365 }
2366 Ok(())
2367 }
2368
2369 #[must_use]
2370 pub fn list_values(&self) -> BTreeMap<String, String> {
2371 let mut out = BTreeMap::new();
2372 out.insert("provider".to_string(), self.provider_id().to_string());
2373
2374 if let Some(v) = self.api_key.as_ref() {
2375 out.insert("api_key".to_string(), redact_secret(v));
2376 }
2377 if let Some(v) = self.base_url.as_ref() {
2378 out.insert("base_url".to_string(), v.clone());
2379 }
2380 if let Some(v) = serialize_http_headers_for_display(&self.http_headers) {
2381 out.insert("http_headers".to_string(), v);
2382 }
2383 if let Some(v) = self.default_text_model.as_ref() {
2384 out.insert("default_text_model".to_string(), v.clone());
2385 }
2386 if let Some(v) = self.model.as_ref() {
2387 out.insert("model".to_string(), v.clone());
2388 }
2389 if let Some(v) = self.auth_mode.as_ref() {
2390 out.insert("auth.mode".to_string(), v.clone());
2391 }
2392 if let Some(v) = self.output_mode.as_ref() {
2393 out.insert("output_mode".to_string(), v.clone());
2394 }
2395 if let Some(v) = self.verbosity.as_ref() {
2396 out.insert("verbosity".to_string(), v.clone());
2397 }
2398 if let Some(v) = self.log_level.as_ref() {
2399 out.insert("log_level".to_string(), v.clone());
2400 }
2401 if let Some(v) = self.telemetry {
2402 out.insert("telemetry".to_string(), v.to_string());
2403 }
2404 if let Some(v) = self.approval_policy.as_ref() {
2405 out.insert("approval_policy".to_string(), v.clone());
2406 }
2407 if let Some(v) = self.sandbox_mode.as_ref() {
2408 out.insert("sandbox_mode".to_string(), v.clone());
2409 }
2410 if let Some(v) = self
2411 .hook_sinks
2412 .as_ref()
2413 .and_then(|sinks| sinks.unix_socket_path.as_ref())
2414 {
2415 out.insert(
2416 "hook_sinks.unix_socket_path".to_string(),
2417 v.display().to_string(),
2418 );
2419 }
2420
2421 for provider in ProviderKind::ALL {
2422 insert_provider_config_values(
2423 &mut out,
2424 provider,
2425 self.providers.for_provider(provider),
2426 );
2427 }
2428
2429 for (k, v) in &self.extras {
2430 out.insert(k.clone(), redact_toml_value_for_display(k, v));
2431 }
2432 out
2433 }
2434
2435 #[must_use]
2442 pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
2443 let no_keyring = Secrets::new(std::sync::Arc::new(
2444 codewhale_secrets::InMemoryKeyringStore::new(),
2445 ));
2446 self.resolve_runtime_options_with_secrets(cli, &no_keyring)
2447 }
2448
2449 #[must_use]
2453 pub fn resolve_runtime_options_with_secrets(
2454 &self,
2455 cli: &CliRuntimeOverrides,
2456 secrets: &Secrets,
2457 ) -> ResolvedRuntimeOptions {
2458 let env = EnvRuntimeOverrides::load();
2459 let (provider, provider_source) = if let Some(provider) = cli.provider {
2460 (provider, ProviderSource::Cli)
2461 } else if let Some(provider) = env.provider {
2462 (
2463 provider,
2464 ProviderSource::Env(env.provider_source.unwrap_or("CODEWHALE_PROVIDER")),
2465 )
2466 } else {
2467 (self.provider, ProviderSource::Config)
2468 };
2469
2470 let mut provider_cfg = if provider == ProviderKind::Custom
2471 && matches!(provider_source, ProviderSource::Config)
2472 {
2473 self.named_custom_provider_config()
2474 .unwrap_or_else(|| self.providers.for_provider(provider).clone())
2475 } else {
2476 self.providers.for_provider(provider).clone()
2477 };
2478 if provider == ProviderKind::SiliconflowCN {
2479 let fb = &self.providers.siliconflow;
2480 if provider_cfg.api_key.is_none() {
2481 provider_cfg.api_key = fb.api_key.clone();
2482 }
2483 if provider_cfg.base_url.is_none() {
2484 provider_cfg.base_url = fb.base_url.clone();
2485 }
2486 if provider_cfg.model.is_none() {
2487 provider_cfg.model = fb.model.clone();
2488 }
2489 }
2490 let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
2491 .then(|| self.api_key.clone())
2492 .flatten();
2493 let root_deepseek_base_url = (provider == ProviderKind::Deepseek)
2494 .then(|| self.base_url.clone())
2495 .flatten();
2496 let auth_mode = cli
2497 .auth_mode
2498 .clone()
2499 .or_else(|| env.auth_mode.clone())
2500 .or_else(|| provider_cfg.auth_mode.clone())
2501 .or_else(|| self.auth_mode.clone());
2502 let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
2503 let cli_base_url = cli.base_url.clone();
2504 let env_base_url = env.base_url_for(provider);
2505 let file_base_url = provider_cfg.base_url.clone().or(root_deepseek_base_url);
2506 let base_url_from_file =
2507 cli_base_url.is_none() && env_base_url.is_none() && file_base_url.is_some();
2508 let configured_base_url = cli_base_url.or(env_base_url).or(file_base_url);
2509 let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo {
2510 env.xiaomi_mimo_mode
2511 .clone()
2512 .or_else(|| provider_cfg.mode.clone())
2513 } else {
2514 None
2515 };
2516 let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo {
2517 xiaomi_mimo_env_api_key_for_runtime(
2518 xiaomi_mimo_mode.as_deref(),
2519 configured_base_url.as_deref(),
2520 )
2521 } else {
2522 None
2523 };
2524 let explicit_api_key_for_endpoint = cli
2525 .api_key
2526 .as_deref()
2527 .or(from_file.as_deref().filter(|value| {
2528 classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal
2529 }))
2530 .or(xiaomi_mimo_env_api_key.as_deref());
2531 let base_url = if provider == ProviderKind::XiaomiMimo {
2532 resolve_xiaomi_mimo_base_url(
2533 configured_base_url,
2534 explicit_api_key_for_endpoint,
2535 xiaomi_mimo_mode.as_deref(),
2536 )
2537 } else {
2538 configured_base_url.unwrap_or_else(|| match provider {
2539 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
2540 ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string(),
2541 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
2542 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
2543 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
2544 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
2545 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
2546 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
2547 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
2548 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
2549 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
2550 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
2551 ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL.to_string(),
2552 ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL.to_string(),
2553 ProviderKind::Moonshot => {
2554 if auth_mode
2555 .as_deref()
2556 .is_some_and(auth_mode_uses_kimi_imported_token)
2557 {
2558 DEFAULT_KIMI_CODE_BASE_URL.to_string()
2559 } else {
2560 DEFAULT_MOONSHOT_BASE_URL.to_string()
2561 }
2562 }
2563 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
2564 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
2565 ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
2566 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(),
2567 ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL.to_string(),
2568 ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL.to_string(),
2569 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
2570 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(),
2571 ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL.to_string(),
2572 ProviderKind::Zai => DEFAULT_ZAI_BASE_URL.to_string(),
2573 ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL.to_string(),
2574 ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL.to_string(),
2575 ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string(),
2576 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL.to_string(),
2577 ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL.to_string(),
2578 ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL.to_string(),
2579 ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL.to_string(),
2580 ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL.to_string(),
2581 ProviderKind::Meta => DEFAULT_META_BASE_URL.to_string(),
2582 ProviderKind::Xai => DEFAULT_XAI_BASE_URL.to_string(),
2583 ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL.to_string(),
2584 ProviderKind::Custom => provider.provider().default_base_url().to_string(),
2588 })
2589 };
2590 let uses_kimi_imported_token = provider == ProviderKind::Moonshot
2598 && auth_mode
2599 .as_deref()
2600 .is_some_and(auth_mode_uses_kimi_imported_token);
2601 let auth_disabled = auth_mode_disables_api_key(auth_mode.as_deref());
2602 let custom_endpoint = provider_preserves_custom_base_url_model(provider, &base_url);
2603 let (api_key, api_key_source) = if auth_disabled {
2604 (None, None)
2605 } else if let Some(value) = cli.api_key.clone() {
2606 (Some(value), Some(RuntimeApiKeySource::Cli))
2607 } else if uses_kimi_imported_token && !custom_endpoint {
2608 (None, None)
2609 } else if (!custom_endpoint || base_url_from_file)
2610 && let Some(value) = from_file.clone().filter(|value| {
2611 classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal
2612 })
2613 {
2614 (Some(value), Some(RuntimeApiKeySource::ConfigFile))
2615 } else if !custom_endpoint
2616 && let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty())
2617 {
2618 (Some(value), Some(RuntimeApiKeySource::Env))
2619 } else if custom_endpoint {
2620 (None, None)
2621 } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
2622 match env_api_key_for_provider(provider) {
2623 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
2624 None => (None, None),
2625 }
2626 } else {
2627 match secrets.resolve_with_source(provider.as_str()) {
2628 Some((value, source)) => {
2629 let source = match source {
2630 SecretSource::Keyring => RuntimeApiKeySource::Keyring,
2631 SecretSource::Env => RuntimeApiKeySource::Env,
2632 };
2633 (Some(value), Some(source))
2634 }
2635 None => match env_api_key_for_provider(provider) {
2636 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
2637 None => (None, None),
2638 },
2639 }
2640 };
2641
2642 let env_provider_model = env.model_for(provider, &base_url);
2643 let root_default_model = self
2656 .default_text_model
2657 .clone()
2658 .filter(|model| !root_default_model_is_foreign_to_provider(provider, model, &base_url));
2659 let model_source = if cli.model.is_some() {
2662 ModelSource::Cli
2663 } else if env.model.is_some() || env_provider_model.is_some() {
2664 ModelSource::Env
2665 } else if provider_cfg.model.is_some() {
2666 ModelSource::ProviderConfig
2667 } else if root_default_model.is_some() {
2668 ModelSource::RootDefaultTextModel
2669 } else if self.model.is_some() {
2670 ModelSource::RootModel
2671 } else {
2672 ModelSource::ProviderDefault
2673 };
2674 let explicit_model = model_source.is_explicit();
2675 let model = cli
2676 .model
2677 .clone()
2678 .or_else(|| env.model.clone())
2679 .or(env_provider_model)
2680 .or_else(|| provider_cfg.model.clone())
2681 .or(root_default_model)
2682 .or_else(|| self.model.clone())
2683 .unwrap_or_else(|| {
2684 if provider == ProviderKind::Moonshot
2685 && (auth_mode
2686 .as_deref()
2687 .is_some_and(auth_mode_uses_kimi_imported_token)
2688 || moonshot_base_url_uses_kimi_code(&base_url))
2689 {
2690 DEFAULT_KIMI_CODE_MODEL.to_string()
2691 } else {
2692 default_model_for_provider(provider).to_string()
2693 }
2694 });
2695 let model = if provider == ProviderKind::OpencodeGo {
2696 normalize_model_for_provider(provider, &model)
2701 } else if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
2702 model.trim().to_string()
2703 } else {
2704 normalize_model_for_provider(provider, &model)
2705 };
2706
2707 let mut http_headers = self.http_headers.clone();
2708 http_headers.extend(provider_cfg.http_headers.clone());
2709 if let Some(env_headers) = env.http_headers {
2710 http_headers.extend(env_headers);
2711 }
2712 http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
2713 if auth_disabled {
2714 http_headers.retain(|name, _| !is_upstream_auth_header(name));
2715 }
2716
2717 let output_mode = cli
2718 .output_mode
2719 .clone()
2720 .or_else(|| env.output_mode.clone())
2721 .or_else(|| self.output_mode.clone());
2722 let log_level = cli
2723 .log_level
2724 .clone()
2725 .or_else(|| env.log_level.clone())
2726 .or_else(|| self.log_level.clone());
2727 let telemetry = cli
2728 .telemetry
2729 .or(env.telemetry)
2730 .or(self.telemetry)
2731 .unwrap_or(false);
2732 let approval_policy = cli
2733 .approval_policy
2734 .clone()
2735 .or_else(|| env.approval_policy.clone())
2736 .or_else(|| self.approval_policy.clone());
2737 let sandbox_mode = cli
2738 .sandbox_mode
2739 .clone()
2740 .or_else(|| env.sandbox_mode.clone())
2741 .or_else(|| self.sandbox_mode.clone());
2742 let yolo = cli.yolo.or(env.yolo);
2743 let verbosity = cli
2744 .verbosity
2745 .clone()
2746 .or_else(|| env.verbosity.clone())
2747 .or_else(|| self.verbosity.clone());
2748
2749 ResolvedRuntimeOptions {
2750 provider,
2751 provider_source,
2752 model,
2753 model_source,
2754 api_key,
2755 api_key_source,
2756 base_url,
2757 auth_mode,
2758 insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false),
2759 output_mode,
2760 log_level,
2761 telemetry,
2762 approval_policy,
2763 sandbox_mode,
2764 yolo,
2765 verbosity,
2766 http_headers,
2767 }
2768 }
2769}
2770
2771fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
2772 if source.model.is_some() {
2773 target.model = source.model.clone();
2774 }
2775}
2776
2777#[must_use]
2778pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
2779 let Some(project_rank) = approval_policy_rank(project) else {
2780 return false;
2781 };
2782 match current.and_then(approval_policy_rank) {
2783 Some(current_rank) => project_rank >= current_rank,
2784 None => project_rank >= 2,
2785 }
2786}
2787
2788#[must_use]
2789pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
2790 let normalized_project = project.trim().to_ascii_lowercase();
2791 if normalized_project == "external-sandbox" {
2792 return current
2793 .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
2794 .unwrap_or(false);
2795 }
2796
2797 let Some(project_rank) = sandbox_mode_rank(project) else {
2798 return false;
2799 };
2800 match current.and_then(sandbox_mode_rank) {
2801 Some(current_rank) => project_rank >= current_rank,
2802 None => project_rank >= 2,
2803 }
2804}
2805
2806fn approval_policy_rank(value: &str) -> Option<u8> {
2807 match value.trim().to_ascii_lowercase().as_str() {
2808 "auto" => Some(0),
2809 "suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
2810 "never" | "deny" | "denied" => Some(2),
2811 _ => None,
2812 }
2813}
2814
2815fn sandbox_mode_rank(value: &str) -> Option<u8> {
2816 match value.trim().to_ascii_lowercase().as_str() {
2817 "danger-full-access" => Some(0),
2818 "external-sandbox" => Some(0),
2819 "workspace-write" => Some(1),
2820 "read-only" => Some(2),
2821 _ => None,
2822 }
2823}
2824
2825#[derive(Debug, Clone)]
2834pub enum ProjectConfigOutcome {
2835 Missing,
2837 Loaded(Box<ConfigToml>),
2839 Invalid {
2842 path: PathBuf,
2844 reason: String,
2846 },
2847}
2848
2849impl ProjectConfigOutcome {
2850 #[must_use]
2852 pub fn into_config(self) -> Option<ConfigToml> {
2853 match self {
2854 Self::Loaded(config) => Some(*config),
2855 Self::Missing | Self::Invalid { .. } => None,
2856 }
2857 }
2858
2859 #[must_use]
2861 pub fn invalid(&self) -> Option<(&Path, &str)> {
2862 match self {
2863 Self::Invalid { path, reason } => Some((path.as_path(), reason.as_str())),
2864 Self::Missing | Self::Loaded(_) => None,
2865 }
2866 }
2867}
2868
2869pub fn load_project_config_outcome(workspace: &Path) -> ProjectConfigOutcome {
2875 for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
2876 let path = workspace.join(dir).join(CONFIG_FILE_NAME);
2877 if !project_config_candidate_exists(&path) {
2878 continue;
2879 }
2880 let raw = match read_checked_config_file(&path) {
2881 Ok(raw) => raw,
2882 Err(e) => {
2883 tracing::warn!("Failed to read project config {}: {e:#}", path.display());
2884 return ProjectConfigOutcome::Invalid {
2885 path,
2886 reason: format!("could not be read: {e}"),
2887 };
2888 }
2889 };
2890 match toml::from_str::<ConfigToml>(&raw) {
2891 Ok(config) => {
2892 let raw_provider = toml::from_str::<toml::Value>(&raw)
2893 .ok()
2894 .and_then(|document| document.get("provider").cloned())
2895 .and_then(|provider| provider.as_str().map(str::to_string));
2896 if config.provider == ProviderKind::Custom
2897 && raw_provider.as_deref() != Some(ProviderKind::Custom.as_str())
2898 {
2899 tracing::warn!(
2903 "Failed to parse project config {}; file contents were omitted",
2904 quote_os_path(&path)
2905 );
2906 return ProjectConfigOutcome::Invalid {
2907 path,
2908 reason: match raw_provider {
2909 Some(name) => format!("unknown provider '{name}'"),
2910 None => "unknown provider".to_string(),
2911 },
2912 };
2913 }
2914 return ProjectConfigOutcome::Loaded(Box::new(config));
2915 }
2916 Err(err) => {
2917 tracing::warn!(
2918 "Failed to parse project config {}; file contents were omitted",
2919 quote_os_path(&path)
2920 );
2921 return ProjectConfigOutcome::Invalid {
2922 path,
2923 reason: err.message().to_string(),
2926 };
2927 }
2928 }
2929 }
2930 ProjectConfigOutcome::Missing
2931}
2932
2933pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
2941 load_project_config_outcome(workspace).into_config()
2942}
2943
2944fn project_config_candidate_exists(path: &Path) -> bool {
2945 fs::symlink_metadata(path).is_ok_and(|metadata| {
2946 let file_type = metadata.file_type();
2947 file_type.is_file() || file_type.is_symlink()
2948 })
2949}
2950
2951fn deepseek_family_model_id(model: &str) -> Option<String> {
2958 let trimmed = model.trim();
2959 if trimmed.is_empty() {
2960 return None;
2961 }
2962 match trimmed.to_ascii_lowercase().as_str() {
2963 "pro" | "deepseek-v4pro" => return Some("deepseek-v4-pro".to_string()),
2964 "flash" | "deepseek-v4flash" => return Some("deepseek-v4-flash".to_string()),
2965 _ => {}
2966 }
2967
2968 let normalized = trimmed.to_ascii_lowercase();
2969 if !normalized.starts_with("deepseek") && !normalized.contains("/deepseek") {
2970 return None;
2971 }
2972 if trimmed
2973 .chars()
2974 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))
2975 {
2976 return Some(trimmed.to_string());
2977 }
2978 None
2979}
2980
2981fn provider_passes_model_through(provider: ProviderKind) -> bool {
2986 matches!(
2987 provider,
2988 ProviderKind::Openai
2989 | ProviderKind::Atlascloud
2990 | ProviderKind::WanjieArk
2991 | ProviderKind::Volcengine
2992 | ProviderKind::XiaomiMimo
2993 | ProviderKind::Moonshot
2994 | ProviderKind::Qianfan
2995 | ProviderKind::Openmodel
2996 | ProviderKind::Ollama
2997 | ProviderKind::Huggingface
2998 | ProviderKind::Meta
2999 | ProviderKind::Xai
3000 | ProviderKind::Telecomjs
3001 | ProviderKind::Custom
3002 )
3003}
3004
3005fn root_default_model_is_foreign_to_provider(
3017 provider: ProviderKind,
3018 model: &str,
3019 base_url: &str,
3020) -> bool {
3021 if deepseek_family_model_id(model).is_none() {
3025 return false;
3026 }
3027 if matches!(
3029 provider,
3030 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
3031 ) {
3032 return false;
3033 }
3034 if provider_preserves_custom_base_url_model(provider, base_url) {
3037 return false;
3038 }
3039 if matches!(
3043 provider,
3044 ProviderKind::Xai | ProviderKind::Openai | ProviderKind::Moonshot
3045 ) {
3046 return true;
3047 }
3048 if provider_passes_model_through(provider) {
3051 return false;
3052 }
3053 if matches!(
3056 provider,
3057 ProviderKind::NvidiaNim
3058 | ProviderKind::Openrouter
3059 | ProviderKind::Novita
3060 | ProviderKind::Fireworks
3061 | ProviderKind::Siliconflow
3062 | ProviderKind::SiliconflowCN
3063 | ProviderKind::Deepinfra
3064 | ProviderKind::Together
3065 | ProviderKind::Sglang
3066 | ProviderKind::Vllm
3067 | ProviderKind::Volcengine
3068 | ProviderKind::Atlascloud
3069 | ProviderKind::OpencodeGo
3070 | ProviderKind::WanjieArk
3071 ) {
3072 return false;
3073 }
3074 true
3077}
3078
3079#[must_use]
3087pub fn known_foreign_model_owner(
3088 provider: ProviderKind,
3089 model: &str,
3090 base_url: &str,
3091) -> Option<ProviderKind> {
3092 root_default_model_is_foreign_to_provider(provider, model, base_url)
3093 .then_some(ProviderKind::Deepseek)
3094}
3095
3096fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
3097 if matches!(provider, ProviderKind::OpencodeGo) {
3098 return opencode_go_chat_model_id(model)
3099 .unwrap_or(DEFAULT_OPENCODE_GO_MODEL)
3100 .to_string();
3101 }
3102 if matches!(provider, ProviderKind::XiaomiMimo)
3103 && let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
3104 {
3105 return canonical.to_string();
3106 }
3107 if matches!(
3108 provider,
3109 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic
3110 ) && let Some(canonical) = canonical_minimax_model_id(model)
3111 {
3112 return canonical.to_string();
3113 }
3114 if matches!(provider, ProviderKind::Zai)
3115 && let Some(canonical) = canonical_zai_model_id(model)
3116 {
3117 return canonical.to_string();
3118 }
3119
3120 if matches!(
3121 provider,
3122 ProviderKind::Atlascloud
3123 | ProviderKind::WanjieArk
3124 | ProviderKind::Volcengine
3125 | ProviderKind::XiaomiMimo
3126 | ProviderKind::Zai
3127 | ProviderKind::Stepfun
3128 | ProviderKind::Minimax
3129 | ProviderKind::MinimaxAnthropic
3130 | ProviderKind::Qianfan
3131 | ProviderKind::Ollama
3132 | ProviderKind::Meta
3133 | ProviderKind::Xai
3134 ) {
3135 return model.to_string();
3136 }
3137
3138 let normalized = model.trim().to_ascii_lowercase();
3139 if provider == ProviderKind::Openrouter
3140 && let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
3141 {
3142 return canonical.to_string();
3143 }
3144 match (provider, normalized.as_str()) {
3145 (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
3146 DEFAULT_NVIDIA_NIM_MODEL.to_string()
3147 }
3148 (
3149 ProviderKind::NvidiaNim,
3150 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3151 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3152 ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
3153 (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
3154 DEFAULT_OPENROUTER_MODEL.to_string()
3155 }
3156 (
3157 ProviderKind::Openrouter,
3158 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3159 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3160 ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
3161 (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
3162 DEFAULT_NOVITA_MODEL.to_string()
3163 }
3164 (
3165 ProviderKind::Novita,
3166 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3167 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3168 ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
3169 (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
3170 DEFAULT_FIREWORKS_MODEL.to_string()
3171 }
3172 (
3173 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
3174 "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
3175 ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
3176 (
3177 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
3178 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
3179 ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
3180 (
3181 ProviderKind::Arcee,
3182 "trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking",
3183 ) => DEFAULT_ARCEE_MODEL.to_string(),
3184 (ProviderKind::Arcee, "trinity-mini" | "arcee-trinity-mini") => {
3185 ARCEE_TRINITY_MINI_MODEL.to_string()
3186 }
3187 (ProviderKind::Arcee, "arcee-trinity-large-preview") => {
3188 ARCEE_TRINITY_LARGE_PREVIEW_MODEL.to_string()
3189 }
3190 (
3191 ProviderKind::Moonshot,
3192 "kimi"
3193 | "kimi-k2"
3194 | "kimi-k2.7"
3195 | "kimi-k2-7"
3196 | "kimi-k2.7-code"
3197 | "kimi-k2-7-code"
3198 | "kimi-code"
3199 | "moonshot-kimi-k2.7-code",
3200 ) => DEFAULT_MOONSHOT_MODEL.to_string(),
3201 (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
3202 MOONSHOT_KIMI_K2_6_MODEL.to_string()
3203 }
3204 (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
3205 DEFAULT_SGLANG_MODEL.to_string()
3206 }
3207 (
3208 ProviderKind::Sglang,
3209 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3210 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3211 ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
3212 (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
3213 DEFAULT_VLLM_MODEL.to_string()
3214 }
3215 (
3216 ProviderKind::Vllm,
3217 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3218 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3219 ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
3220 (ProviderKind::Huggingface, "deepseek-v4-pro" | "deepseek-v4pro") => {
3221 DEFAULT_HUGGINGFACE_MODEL.to_string()
3222 }
3223 (
3224 ProviderKind::Huggingface,
3225 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3226 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3227 ) => DEFAULT_HUGGINGFACE_FLASH_MODEL.to_string(),
3228 (ProviderKind::Together, "deepseek-v4-pro" | "deepseek-v4pro") => {
3229 DEFAULT_TOGETHER_MODEL.to_string()
3230 }
3231 (
3232 ProviderKind::Together,
3233 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3234 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3235 ) => DEFAULT_TOGETHER_FLASH_MODEL.to_string(),
3236 (ProviderKind::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
3237 DEFAULT_DEEPINFRA_MODEL.to_string()
3238 }
3239 (
3240 ProviderKind::Deepinfra,
3241 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3242 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3243 ) => DEFAULT_DEEPINFRA_FLASH_MODEL.to_string(),
3244 _ => model.to_string(),
3245 }
3246}
3247
3248pub const OPENCODE_GO_CHAT_MODELS: &[&str] = &[
3255 DEFAULT_OPENCODE_GO_MODEL,
3256 OPENCODE_GO_GROK_4_5_MODEL,
3257 OPENCODE_GO_GLM_5_2_MODEL,
3258 OPENCODE_GO_GLM_5_1_MODEL,
3259 OPENCODE_GO_KIMI_K3_MODEL,
3260 OPENCODE_GO_KIMI_K2_7_CODE_MODEL,
3261 OPENCODE_GO_KIMI_K2_6_MODEL,
3262 OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL,
3263 OPENCODE_GO_MIMO_V2_5_MODEL,
3264 OPENCODE_GO_MIMO_V2_5_PRO_MODEL,
3265];
3266
3267#[must_use]
3272pub fn opencode_go_chat_model_id(model: &str) -> Option<&'static str> {
3273 let normalized = model.trim().to_ascii_lowercase().replace(['_', ' '], "-");
3274 let normalized = normalized
3275 .strip_prefix("opencode-go/")
3276 .unwrap_or(&normalized);
3277 let familiar_alias = match normalized {
3278 "grok-4-5" => Some(OPENCODE_GO_GROK_4_5_MODEL),
3279 "glm-5-2" => Some(OPENCODE_GO_GLM_5_2_MODEL),
3280 "glm-5-1" => Some(OPENCODE_GO_GLM_5_1_MODEL),
3281 "kimi-k2-7-code" => Some(OPENCODE_GO_KIMI_K2_7_CODE_MODEL),
3282 "kimi-k2-6" => Some(OPENCODE_GO_KIMI_K2_6_MODEL),
3283 "deepseek-v4pro" => Some(DEFAULT_OPENCODE_GO_MODEL),
3284 "deepseek-v4flash" => Some(OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL),
3285 "mimo-v2-5" => Some(OPENCODE_GO_MIMO_V2_5_MODEL),
3286 "mimo-v2-5-pro" => Some(OPENCODE_GO_MIMO_V2_5_PRO_MODEL),
3287 _ => None,
3288 };
3289 familiar_alias.or_else(|| {
3290 OPENCODE_GO_CHAT_MODELS
3291 .iter()
3292 .copied()
3293 .find(|candidate| *candidate == normalized)
3294 })
3295}
3296
3297fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> {
3298 let normalized = model.trim().to_ascii_lowercase();
3299 let normalized = normalized.replace(['_', ' '], "-");
3300 match normalized.as_str() {
3301 "mimo"
3302 | DEFAULT_XIAOMI_MIMO_MODEL
3303 | "mimo-v2-5-pro"
3304 | "xiaomi-mimo-v2.5-pro"
3305 | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL),
3306 XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL
3307 | "mimo-v2-5-pro-ultraspeed"
3308 | "xiaomi-mimo-v2.5-pro-ultraspeed"
3309 | "xiaomi-mimo-v2-5-pro-ultraspeed"
3310 | "ultraspeed"
3311 | "pro-ultraspeed" => Some(XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL),
3312 "omni"
3313 | "mimo-omni"
3314 | "v2.5-omni"
3315 | "v25-omni"
3316 | "mimo-v2.5"
3317 | "mimo-v25"
3318 | "mimo-v2-5"
3319 | "mimo-v2.5-omni"
3320 | "mimo-v25-omni"
3321 | "mimo-v2-5-omni"
3322 | "xiaomi-mimo-v2.5"
3323 | "xiaomi-mimo-v2-5"
3324 | "xiaomi-mimo-v2.5-omni"
3325 | "xiaomi-mimo-v2-5-omni" => Some(XIAOMI_MIMO_V2_5_OMNI_MODEL),
3326 "asr" | "mimo-asr" | "mimo-v2.5-asr" | "speech-to-text" | "transcribe" => {
3327 Some(XIAOMI_MIMO_ASR_MODEL)
3328 }
3329 "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => {
3330 Some(XIAOMI_MIMO_TTS_MODEL)
3331 }
3332 "mimo-tts-voicedesign"
3333 | "mimo-voice-design"
3334 | "mimo-v25-tts-voicedesign"
3335 | "mimo-v2.5-tts-voicedesign"
3336 | "voicedesign"
3337 | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL),
3338 "mimo-tts-voiceclone"
3339 | "mimo-voice-clone"
3340 | "mimo-v25-tts-voiceclone"
3341 | "mimo-v2.5-tts-voiceclone"
3342 | "voiceclone"
3343 | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL),
3344 "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL),
3345 _ => None,
3346 }
3347}
3348
3349fn canonical_minimax_model_id(model: &str) -> Option<&'static str> {
3350 let normalized = model.trim().to_ascii_lowercase();
3351 let normalized = normalized.replace(['_', ' '], "-");
3352 match normalized.as_str() {
3353 "minimax" | "minimax-m3" | "minimax-m-3" | "minimax-m-3-thinking" => {
3354 Some(DEFAULT_MINIMAX_MODEL)
3355 }
3356 "minimax-m2.7" | "minimax-m2-7" | "minimax-m-2.7" | "minimax-m-2-7" => {
3357 Some(MINIMAX_M2_7_MODEL)
3358 }
3359 "minimax-m2.7-highspeed"
3360 | "minimax-m2-7-highspeed"
3361 | "minimax-m-2.7-highspeed"
3362 | "minimax-m-2-7-highspeed" => Some(MINIMAX_M2_7_HIGHSPEED_MODEL),
3363 "minimax-m2.5" | "minimax-m2-5" | "minimax-m-2.5" | "minimax-m-2-5" => {
3364 Some(MINIMAX_M2_5_MODEL)
3365 }
3366 "minimax-m2.5-highspeed"
3367 | "minimax-m2-5-highspeed"
3368 | "minimax-m-2.5-highspeed"
3369 | "minimax-m-2-5-highspeed" => Some(MINIMAX_M2_5_HIGHSPEED_MODEL),
3370 "minimax-m2.1" | "minimax-m2-1" | "minimax-m-2.1" | "minimax-m-2-1" => {
3371 Some(MINIMAX_M2_1_MODEL)
3372 }
3373 "minimax-m2.1-highspeed"
3374 | "minimax-m2-1-highspeed"
3375 | "minimax-m-2.1-highspeed"
3376 | "minimax-m-2-1-highspeed" => Some(MINIMAX_M2_1_HIGHSPEED_MODEL),
3377 "minimax-m2" | "minimax-m-2" => Some(MINIMAX_M2_MODEL),
3378 _ => None,
3379 }
3380}
3381
3382fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
3383 let normalized = model.trim().to_ascii_lowercase();
3384 let normalized = normalized.replace(['_', ' '], "-");
3385 match normalized.as_str() {
3386 "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
3387 "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(DEFAULT_ZAI_MODEL),
3388 "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
3389 _ => None,
3390 }
3391}
3392
3393fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
3394 let normalized = model.trim().to_ascii_lowercase();
3395 let normalized = normalized.replace(['_', ' '], "-");
3396 match normalized.as_str() {
3397 OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
3398 | "trinity"
3399 | "trinity-large-thinking"
3400 | "arcee-trinity"
3401 | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
3402 OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
3403 Some(OPENROUTER_GEMMA_4_31B_MODEL)
3404 }
3405 OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
3406 Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
3407 }
3408 OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
3409 Some(OPENROUTER_GLM_5_1_MODEL)
3410 }
3411 OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
3412 Some(OPENROUTER_GLM_5_2_MODEL)
3413 }
3414 OPENROUTER_KIMI_K2_7_CODE_MODEL
3415 | "kimi"
3416 | "kimi-k2"
3417 | "kimi-k2.7"
3418 | "kimi-k2-7"
3419 | "kimi-k2.7-code"
3420 | "kimi-k2-7-code"
3421 | "kimi-code"
3422 | "moonshot-kimi-k2.7-code"
3423 | "openrouter-kimi-k2.7-code" => Some(OPENROUTER_KIMI_K2_7_CODE_MODEL),
3424 OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
3425 Some(OPENROUTER_KIMI_K2_6_MODEL)
3426 }
3427 OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => {
3428 Some(OPENROUTER_MINIMAX_M3_MODEL)
3429 }
3430 OPENROUTER_MINIMAX_M2_7_MODEL
3431 | "minimax-2.7"
3432 | "minimax-2-7"
3433 | "minimax-m2.7"
3434 | "minimax-m2-7"
3435 | "minimax-m-2.7"
3436 | "minimax-m-2-7" => Some(OPENROUTER_MINIMAX_M2_7_MODEL),
3437 OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
3438 | "nemotron-3-nano-omni"
3439 | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
3440 OPENROUTER_QWEN_3_6_35B_A3B_MODEL
3441 | "qwen3.6-35b-a3b"
3442 | "qwen-3.6-35b-a3b"
3443 | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
3444 OPENROUTER_QWEN_3_6_FLASH_MODEL | "qwen3.6-flash" | "qwen-3.6-flash" => {
3445 Some(OPENROUTER_QWEN_3_6_FLASH_MODEL)
3446 }
3447 OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL
3448 | "qwen3.6-max-preview"
3449 | "qwen-3.6-max-preview"
3450 | "qwen-max-preview" => Some(OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
3451 OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
3452 Some(OPENROUTER_QWEN_3_6_27B_MODEL)
3453 }
3454 OPENROUTER_QWEN_3_6_PLUS_MODEL | "qwen3.6-plus" | "qwen-3.6-plus" => {
3455 Some(OPENROUTER_QWEN_3_6_PLUS_MODEL)
3456 }
3457 OPENROUTER_QWEN_3_7_PLUS_MODEL | "qwen3.7-plus" | "qwen-3.7-plus" => {
3458 Some(OPENROUTER_QWEN_3_7_PLUS_MODEL)
3459 }
3460 OPENROUTER_QWEN_3_7_MAX_MODEL | "qwen3.7-max" | "qwen-3.7-max" => {
3461 Some(OPENROUTER_QWEN_3_7_MAX_MODEL)
3462 }
3463 OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
3464 Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
3465 }
3466 OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
3467 | "mimo-v2.5-pro"
3468 | "mimo-v2-5-pro"
3469 | "xiaomi-mimo-v2.5-pro"
3470 | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
3471 OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
3472 | "mimo-v2.5"
3473 | "mimo-v2-5"
3474 | "xiaomi-mimo-v2.5"
3475 | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
3476 _ => None,
3477 }
3478}
3479
3480fn default_model_for_provider(provider: ProviderKind) -> &'static str {
3481 match provider {
3482 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
3483 ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_MODEL,
3484 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
3485 ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
3486 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
3487 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
3488 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
3489 ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
3490 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
3491 ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
3492 ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
3493 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_MODEL,
3494 ProviderKind::Arcee => DEFAULT_ARCEE_MODEL,
3495 ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
3496 ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
3497 ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
3498 ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
3499 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
3500 ProviderKind::Together => DEFAULT_TOGETHER_MODEL,
3501 ProviderKind::Qianfan => DEFAULT_QIANFAN_MODEL,
3502 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL,
3503 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_MODEL,
3504 ProviderKind::Openmodel => DEFAULT_OPENMODEL_MODEL,
3505 ProviderKind::Zai => DEFAULT_ZAI_MODEL,
3506 ProviderKind::Stepfun => DEFAULT_STEPFUN_MODEL,
3507 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_MODEL,
3508 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
3509 ProviderKind::Sakana => DEFAULT_SAKANA_MODEL,
3510 ProviderKind::LongCat => DEFAULT_LONGCAT_MODEL,
3511 ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_MODEL,
3512 ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_MODEL,
3513 ProviderKind::Meta => DEFAULT_META_MODEL,
3514 ProviderKind::Xai => DEFAULT_XAI_MODEL,
3515 ProviderKind::Telecomjs => DEFAULT_TELECOMJS_MODEL,
3516 ProviderKind::Custom => provider.provider().default_model(),
3518 }
3519}
3520
3521fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
3522 match provider {
3523 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
3524 ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
3525 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
3526 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
3527 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
3528 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
3529 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
3530 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
3531 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
3532 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
3533 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
3534 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
3535 ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL,
3536 ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL,
3537 ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
3538 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
3539 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
3540 ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
3541 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
3542 ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL,
3543 ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL,
3544 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
3545 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
3546 ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL,
3547 ProviderKind::Zai => DEFAULT_ZAI_BASE_URL,
3548 ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL,
3549 ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL,
3550 ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
3551 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
3552 ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL,
3553 ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL,
3554 ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL,
3555 ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL,
3556 ProviderKind::Meta => DEFAULT_META_BASE_URL,
3557 ProviderKind::Xai => DEFAULT_XAI_BASE_URL,
3558 ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL,
3559 ProviderKind::Custom => provider.provider().default_base_url(),
3561 }
3562}
3563
3564fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
3565 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
3566 normalized == DEFAULT_KIMI_CODE_BASE_URL
3567 || normalized == "https://api.kimi.com/coding"
3568 || normalized.starts_with("https://api.kimi.com/coding/")
3569}
3570
3571fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
3572 let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
3573 if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
3574 return None;
3575 }
3576 Some(match normalized.as_str() {
3577 "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => {
3578 DEFAULT_XIAOMI_MIMO_BASE_URL
3579 }
3580 "token-plan-cn"
3581 | "token-plan-china"
3582 | "token-plan-mainland"
3583 | "token-plan-mainland-china"
3584 | "cn"
3585 | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
3586 "token-plan-sgp"
3587 | "token-plan-sg"
3588 | "token-plan-singapore"
3589 | "sgp"
3590 | "sg"
3591 | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
3592 "token-plan-ams"
3593 | "token-plan-eu"
3594 | "token-plan-europe"
3595 | "token-plan-amsterdam"
3596 | "ams"
3597 | "eu"
3598 | "europe"
3599 | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
3600 _ => DEFAULT_XIAOMI_MIMO_BASE_URL,
3601 })
3602}
3603
3604fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
3605 matches!(
3606 normalized_mode,
3607 "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
3608 )
3609}
3610
3611fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
3612 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
3613 normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
3614 || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL
3615 || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
3616}
3617
3618fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option<String> {
3619 candidates.iter().find_map(|name| {
3620 std::env::var(name)
3621 .ok()
3622 .filter(|value| !value.trim().is_empty())
3623 })
3624}
3625
3626fn xiaomi_mimo_env_api_key_for_runtime(
3627 mode: Option<&str>,
3628 base_url: Option<&str>,
3629) -> Option<String> {
3630 const TOKEN_PLAN_ENV_VARS: &[&str] =
3631 &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"];
3632 const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"];
3633
3634 let normalized_mode =
3635 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
3636 let standard_selected = normalized_mode
3637 .as_deref()
3638 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint)
3639 || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go);
3640 if standard_selected {
3641 return xiaomi_mimo_env_var(STANDARD_ENV_VARS);
3642 }
3643
3644 let token_plan_selected = normalized_mode
3645 .as_deref()
3646 .and_then(xiaomi_mimo_base_url_for_mode)
3647 .is_some()
3648 || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan);
3649 if token_plan_selected {
3650 return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS);
3651 }
3652
3653 xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS))
3654}
3655
3656fn resolve_xiaomi_mimo_base_url(
3657 configured: Option<String>,
3658 api_key: Option<&str>,
3659 mode: Option<&str>,
3660) -> String {
3661 let normalized_mode =
3662 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
3663 let uses_standard_mode = normalized_mode
3664 .as_deref()
3665 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint);
3666 let mode_base_url = normalized_mode
3667 .as_deref()
3668 .and_then(xiaomi_mimo_base_url_for_mode);
3669 let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key);
3670 match configured {
3671 Some(base_url) if uses_standard_mode => base_url,
3672 Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => {
3673 mode_base_url
3674 .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL)
3675 .to_string()
3676 }
3677 Some(base_url) => base_url,
3678 None => {
3679 if let Some(base_url) = mode_base_url {
3680 base_url.to_string()
3681 } else if uses_standard_mode {
3682 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
3683 } else if uses_token_plan || api_key.is_none() {
3684 DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()
3685 } else {
3686 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
3687 }
3688 }
3689 }
3690}
3691
3692fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
3693 api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
3694}
3695
3696fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool {
3697 matches!(
3698 base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
3699 "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
3700 )
3701}
3702
3703#[must_use]
3709pub fn provider_base_url_is_official(provider: ProviderKind, base_url: &str) -> bool {
3710 let normalized = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
3711 match provider {
3712 ProviderKind::Deepseek => matches!(
3713 normalized.as_str(),
3714 "https://api.deepseek.com"
3715 | "https://api.deepseek.com/v1"
3716 | "https://api.deepseek.com/beta"
3717 ),
3718 ProviderKind::DeepseekAnthropic => matches!(
3719 normalized.as_str(),
3720 "https://api.deepseek.com/anthropic" | "https://api.deepseek.com/anthropic/v1"
3721 ),
3722 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => matches!(
3723 normalized.as_str(),
3724 "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
3725 ),
3726 ProviderKind::Moonshot => {
3727 normalized == DEFAULT_MOONSHOT_BASE_URL || moonshot_base_url_uses_kimi_code(base_url)
3728 }
3729 ProviderKind::XiaomiMimo => {
3730 xiaomi_mimo_base_url_uses_token_plan(base_url)
3731 || xiaomi_mimo_base_url_is_pay_as_you_go(base_url)
3732 }
3733 ProviderKind::Custom => false,
3736 _ => {
3737 normalized
3738 == default_base_url_for_provider(provider)
3739 .trim()
3740 .trim_end_matches('/')
3741 .to_ascii_lowercase()
3742 }
3743 }
3744}
3745
3746fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
3747 !provider_base_url_is_official(provider, base_url)
3748}
3749
3750#[must_use]
3757pub fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
3758 base_url_is_custom_for_provider(provider, base_url)
3759}
3760
3761fn should_skip_secret_store_for_provider(
3762 provider: ProviderKind,
3763 base_url: &str,
3764 auth_mode: Option<&str>,
3765) -> bool {
3766 if auth_mode_disables_api_key(auth_mode) {
3767 return true;
3768 }
3769 if base_url_is_custom_for_provider(provider, base_url) {
3770 return true;
3771 }
3772 if auth_mode_requires_api_key(auth_mode) {
3773 return false;
3774 }
3775
3776 matches!(
3777 provider,
3778 ProviderKind::Sglang | ProviderKind::Vllm | ProviderKind::Ollama
3779 ) || base_url_uses_local_host(base_url)
3780}
3781
3782fn env_api_key_for_provider(provider: ProviderKind) -> Option<String> {
3783 if provider == ProviderKind::Huggingface {
3784 return std::env::var("HUGGINGFACE_API_KEY")
3785 .ok()
3786 .filter(|value| !value.trim().is_empty())
3787 .or_else(|| {
3788 std::env::var("HF_TOKEN")
3789 .ok()
3790 .filter(|value| !value.trim().is_empty())
3791 });
3792 }
3793
3794 codewhale_secrets::env_for(provider.as_str())
3795}
3796
3797#[must_use]
3799pub fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
3800 matches!(
3801 auth_mode
3802 .map(str::trim)
3803 .filter(|value| !value.is_empty())
3804 .map(|value| value.to_ascii_lowercase()),
3805 Some(value)
3806 if matches!(
3807 value.as_str(),
3808 "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
3809 )
3810 )
3811}
3812
3813#[must_use]
3815pub fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
3816 matches!(
3817 auth_mode
3818 .map(str::trim)
3819 .filter(|value| !value.is_empty())
3820 .map(|value| value.to_ascii_lowercase()),
3821 Some(value)
3822 if matches!(
3823 value.as_str(),
3824 "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
3825 )
3826 )
3827}
3828
3829#[must_use]
3831pub fn auth_mode_uses_kimi_imported_token(auth_mode: &str) -> bool {
3832 matches!(
3833 auth_mode
3834 .trim()
3835 .to_ascii_lowercase()
3836 .replace('-', "_")
3837 .as_str(),
3838 "kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
3839 )
3840}
3841
3842fn base_url_uses_local_host(base_url: &str) -> bool {
3843 let Some(host) = base_url_host(base_url) else {
3844 return false;
3845 };
3846 let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
3847 if matches!(host.as_str(), "localhost" | "0.0.0.0") {
3848 return true;
3849 }
3850 host.parse::<std::net::IpAddr>()
3851 .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
3852}
3853
3854fn base_url_host(base_url: &str) -> Option<&str> {
3855 let without_scheme = base_url
3856 .split_once("://")
3857 .map_or(base_url, |(_, rest)| rest);
3858 let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
3859 if let Some(rest) = authority.strip_prefix('[') {
3860 return rest.split_once(']').map(|(host, _)| host);
3861 }
3862 authority.split(':').next().filter(|host| !host.is_empty())
3863}
3864
3865#[derive(Debug, Clone, Default)]
3866pub struct CliRuntimeOverrides {
3867 pub provider: Option<ProviderKind>,
3868 pub model: Option<String>,
3869 pub api_key: Option<String>,
3870 pub base_url: Option<String>,
3871 pub auth_mode: Option<String>,
3872 pub output_mode: Option<String>,
3873 pub log_level: Option<String>,
3874 pub telemetry: Option<bool>,
3875 pub approval_policy: Option<String>,
3876 pub sandbox_mode: Option<String>,
3877 pub yolo: Option<bool>,
3878 pub verbosity: Option<String>,
3879}
3880
3881#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3882pub enum RuntimeApiKeySource {
3883 Cli,
3884 ConfigFile,
3885 Keyring,
3886 Env,
3887}
3888
3889impl RuntimeApiKeySource {
3890 #[must_use]
3891 pub fn as_env_value(self) -> &'static str {
3892 match self {
3893 Self::Cli => "cli",
3894 Self::ConfigFile => "config",
3895 Self::Keyring => "keyring",
3896 Self::Env => "env",
3897 }
3898 }
3899}
3900
3901#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3902pub enum ProviderSource {
3903 Cli,
3904 Env(&'static str),
3905 Config,
3906}
3907
3908#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3916pub enum ModelSource {
3917 Cli,
3919 Env,
3921 ProviderConfig,
3923 RootDefaultTextModel,
3925 RootModel,
3927 ProviderDefault,
3929}
3930
3931impl ModelSource {
3932 #[must_use]
3934 pub fn is_explicit(self) -> bool {
3935 !matches!(self, Self::ProviderDefault)
3936 }
3937
3938 #[must_use]
3939 pub fn as_str(self) -> &'static str {
3940 match self {
3941 Self::Cli => "--model",
3942 Self::Env => "environment",
3943 Self::ProviderConfig => "config [providers.*].model",
3944 Self::RootDefaultTextModel => "config default_text_model",
3945 Self::RootModel => "config model",
3946 Self::ProviderDefault => "provider default",
3947 }
3948 }
3949}
3950
3951#[derive(Debug, Clone)]
3952pub struct ResolvedRuntimeOptions {
3953 pub provider: ProviderKind,
3954 pub provider_source: ProviderSource,
3955 pub model: String,
3956 pub model_source: ModelSource,
3957 pub api_key: Option<String>,
3958 pub api_key_source: Option<RuntimeApiKeySource>,
3959 pub base_url: String,
3960 pub auth_mode: Option<String>,
3961 pub insecure_skip_tls_verify: bool,
3962 pub output_mode: Option<String>,
3963 pub log_level: Option<String>,
3964 pub telemetry: bool,
3965 pub approval_policy: Option<String>,
3966 pub sandbox_mode: Option<String>,
3967 pub yolo: Option<bool>,
3968 pub verbosity: Option<String>,
3969 pub http_headers: BTreeMap<String, String>,
3970}
3971
3972#[derive(Debug, Clone)]
3973pub struct ConfigStore {
3974 path: PathBuf,
3975 pub config: ConfigToml,
3976 permissions: PermissionsToml,
3977 original_raw: Option<String>,
3980}
3981
3982impl ConfigStore {
3983 pub fn load(path: Option<PathBuf>) -> Result<Self> {
3984 let path = resolve_config_path(path)?;
3985 let (config, original_raw) = if checked_path_exists(&path)? {
3986 let raw = read_checked_config_file(&path)?;
3987 let mut parsed: ConfigToml = toml::from_str(&raw).map_err(|_| {
3988 anyhow::anyhow!(
3989 "failed to parse config at {}; file contents were omitted",
3990 quote_os_path(&path)
3991 )
3992 })?;
3993 let raw_document: toml::Value = toml::from_str(&raw).map_err(|_| {
3994 anyhow::anyhow!(
3995 "failed to parse config at {}; file contents were omitted",
3996 quote_os_path(&path)
3997 )
3998 })?;
3999 if let Some(provider_id) = raw_document.get("provider").and_then(toml::Value::as_str) {
4000 parsed
4001 .bind_persisted_provider_id(provider_id)
4002 .with_context(|| {
4003 format!("failed to parse config at {}", quote_os_path(&path))
4004 })?;
4005 }
4006 (parsed, Some(raw))
4007 } else {
4008 (ConfigToml::default(), None)
4009 };
4010 let permissions = load_sibling_permissions(&path)?;
4011
4012 Ok(Self {
4013 path,
4014 config,
4015 permissions,
4016 original_raw,
4017 })
4018 }
4019
4020 pub fn rendered_body(&self) -> Result<String> {
4026 let mut serialized =
4027 toml::to_string_pretty(&self.config).context("failed to serialize config")?;
4028 if let Some(provider_id) = self.config.named_custom_provider_id() {
4029 let mut document = serialized
4030 .parse::<toml_edit::DocumentMut>()
4031 .context("failed to edit serialized config")?;
4032 document["provider"] = toml_edit::value(provider_id);
4033 serialized = document.to_string();
4034 }
4035 if let Some(ref original_raw) = self.original_raw {
4036 merge_and_preserve_comments(&serialized, original_raw).with_context(|| {
4037 format!(
4038 "cannot safely preserve config at {}; reload it and retry instead of replacing an unmergeable snapshot",
4039 quote_os_path(&self.path)
4040 )
4041 })
4042 } else {
4043 Ok(serialized)
4044 }
4045 }
4046
4047 pub fn save(&mut self) -> Result<()> {
4048 let path = normalize_config_file_path(self.path.clone())?;
4049 let body = self.rendered_body()?;
4050 replace_config_document_if_unchanged(&path, self.original_raw.as_deref(), &body)?;
4051 self.original_raw = Some(body);
4052 Ok(())
4053 }
4054
4055 pub fn reload(&mut self) -> Result<()> {
4059 *self = Self::load(Some(self.path.clone()))?;
4060 Ok(())
4061 }
4062
4063 #[must_use]
4064 pub fn path(&self) -> &Path {
4065 &self.path
4066 }
4067
4068 #[must_use]
4069 pub fn permissions(&self) -> &PermissionsToml {
4070 &self.permissions
4071 }
4072
4073 #[must_use]
4074 pub fn permissions_path(&self) -> PathBuf {
4075 checked_permissions_path_for_config_path(&self.path)
4076 .expect("ConfigStore path is validated before construction")
4077 }
4078
4079 #[must_use]
4080 pub fn exec_policy_engine(&self) -> ExecPolicyEngine {
4081 if self.permissions.is_empty() {
4082 ExecPolicyEngine::new(Vec::new(), Vec::new())
4083 } else {
4084 ExecPolicyEngine::with_rulesets(vec![self.permissions.ruleset()])
4085 }
4086 }
4087
4088 pub fn append_ask_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
4095 self.append_permission_rules(rules, PermissionAction::Ask)
4096 }
4097
4098 pub fn append_allow_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
4105 for rule in rules {
4106 if rule.action != PermissionAction::Allow {
4107 bail!("append_allow_rules only accepts action = \"allow\"");
4108 }
4109 let Some(workspace) = rule
4110 .workspace
4111 .as_deref()
4112 .and_then(codewhale_execpolicy::normalize_workspace_scope)
4113 else {
4114 bail!("persistent allow rules must be scoped to a workspace");
4115 };
4116 if rule.command.is_some() && !rule.command_exact {
4117 bail!("persistent command allow rules must use exact matching");
4118 }
4119 if rule.command.is_none() && rule.path.is_none() {
4120 bail!("persistent allow rules must match an exact command or path");
4121 }
4122 if let Some(command) = rule.command.as_deref()
4123 && command.trim().is_empty()
4124 {
4125 bail!("persistent command allow rules must not be empty");
4126 }
4127 if let Some(path) = rule.path.as_deref()
4128 && codewhale_execpolicy::normalize_workspace_relative_path(path, &workspace)
4129 .is_none_or(|path| path.is_empty())
4130 {
4131 bail!("persistent path allow rules must stay within the workspace");
4132 }
4133 }
4134 self.append_permission_rules(rules, PermissionAction::Allow)
4135 }
4136
4137 fn append_permission_rules(
4138 &mut self,
4139 rules: &[ToolAskRule],
4140 expected_action: PermissionAction,
4141 ) -> Result<usize> {
4142 if rules.is_empty() {
4143 return Ok(0);
4144 }
4145 if rules.iter().any(|rule| rule.action != expected_action) {
4146 bail!(
4147 "permission rule action does not match requested {:?} persistence",
4148 expected_action
4149 );
4150 }
4151
4152 let path = checked_permissions_path_for_config_path(&self.path)?;
4153 let (added, persisted) = config_document::with_config_write_lock(&path, |path| {
4154 let (_, raw, mut permissions) = read_permissions_state(path)?;
4155 let mut document = parse_permissions_document(path, &raw)?;
4156
4157 if !document.contains_key("rules") {
4158 document["rules"] = toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
4159 }
4160 let rules_item = document
4161 .get_mut("rules")
4162 .expect("rules entry was inserted above");
4163
4164 let mut added = 0;
4165 for rule in rules {
4166 if permissions.rules.contains(rule) {
4167 continue;
4168 }
4169 append_permission_rule(rules_item, rule)?;
4170 permissions.rules.push(rule.clone());
4171 added += 1;
4172 }
4173 if added == 0 {
4174 return Ok((0, permissions));
4175 }
4176
4177 let body = document.to_string();
4178 let persisted = parse_generated_permissions(path, &body)?;
4179 write_permissions_atomic(path, body.as_bytes())?;
4180 Ok((added, persisted))
4181 })?;
4182 self.permissions = persisted;
4183 Ok(added)
4184 }
4185}
4186
4187fn config_backup_file_name(path: &Path) -> OsString {
4188 let mut file_name = path
4189 .file_name()
4190 .map(OsString::from)
4191 .unwrap_or_else(|| OsString::from(CONFIG_FILE_NAME));
4192 file_name.push(".bak");
4193 file_name
4194}
4195
4196fn config_sibling_path_unchecked(config_path: &Path, file_name: &OsStr) -> PathBuf {
4197 config_path
4198 .parent()
4199 .unwrap_or_else(|| Path::new("."))
4200 .join(file_name)
4201}
4202
4203fn checked_config_sibling_path(config_path: &Path, file_name: &OsStr) -> Result<PathBuf> {
4204 let config_path = normalize_config_file_path(config_path.to_path_buf())?;
4205 let parent = config_path
4206 .parent()
4207 .context("config path must include a parent directory")?;
4208 let path = parent.join(file_name);
4209 reject_path_symlink(&path)?;
4210 Ok(path)
4211}
4212
4213#[cfg(test)]
4214fn config_backup_path(path: &Path) -> PathBuf {
4215 config_sibling_path_unchecked(path, &config_backup_file_name(path))
4216}
4217
4218fn checked_config_backup_path(path: &Path) -> Result<PathBuf> {
4219 checked_config_sibling_path(path, &config_backup_file_name(path))
4220}
4221
4222pub fn scrub_plaintext_api_keys_from_config_backup(path: &Path) -> Result<()> {
4229 let backup = checked_config_backup_path(path)?;
4230 if !backup.exists() {
4231 return Ok(());
4232 }
4233
4234 let raw = read_checked_toml_file(&backup, "config backup")?;
4235 let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
4236 format!(
4237 "failed to scrub plaintext API keys from config backup {}",
4238 backup.display()
4239 )
4240 })?;
4241 if scrubbed != raw {
4242 persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
4243 format!(
4244 "failed to write credential-free config backup {}",
4245 backup.display()
4246 )
4247 })?;
4248 }
4249 Ok(())
4250}
4251
4252fn write_one_time_config_backup(path: &Path) -> Result<()> {
4253 let backup = checked_config_backup_path(path)?;
4254 if backup.exists() {
4255 return scrub_plaintext_api_keys_from_config_backup(path);
4256 }
4257
4258 let raw = read_checked_config_file(path)?;
4259 let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
4260 format!(
4261 "failed to scrub plaintext API keys while creating config backup {}",
4262 backup.display()
4263 )
4264 })?;
4265 persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
4266 format!(
4267 "failed to create credential-free config backup {} from {}",
4268 backup.display(),
4269 path.display()
4270 )
4271 })?;
4272 Ok(())
4273}
4274
4275fn config_toml_without_plaintext_api_keys(raw: &str) -> Result<String> {
4276 let mut document = raw
4277 .parse::<toml_edit::DocumentMut>()
4278 .map_err(|_| {
4279 anyhow::anyhow!(
4280 "failed to parse config TOML while removing plaintext API keys; file contents were omitted"
4281 )
4282 })?;
4283 remove_plaintext_api_keys_recursive(document.as_table_mut());
4284 Ok(document.to_string())
4285}
4286
4287fn remove_plaintext_api_keys_recursive(table: &mut dyn toml_edit::TableLike) {
4288 table.remove("api_key");
4289 for (_, item) in table.iter_mut() {
4290 if let toml_edit::Item::ArrayOfTables(tables) = item {
4291 for nested in tables.iter_mut() {
4292 remove_plaintext_api_keys_recursive(nested);
4293 }
4294 } else if let Some(nested) = item.as_table_like_mut() {
4295 remove_plaintext_api_keys_recursive(nested);
4296 }
4297 }
4298}
4299
4300pub fn merge_and_preserve_comments(serialized: &str, original_raw: &str) -> Result<String> {
4308 let original = original_raw
4309 .parse::<toml_edit::DocumentMut>()
4310 .map_err(|_| {
4311 anyhow::anyhow!(
4312 "failed to parse original config for comment merge; file contents were omitted"
4313 )
4314 })?;
4315
4316 let mut new_doc = serialized.parse::<toml_edit::DocumentMut>().map_err(|_| {
4317 anyhow::anyhow!(
4318 "failed to parse serialized config for comment merge; file contents were omitted"
4319 )
4320 })?;
4321
4322 new_doc.set_trailing(original.trailing().clone());
4325
4326 *new_doc.as_table_mut().decor_mut() = original.as_table().decor().clone();
4329
4330 merge_decor_table(new_doc.as_table_mut(), original.as_table());
4331
4332 Ok(new_doc.to_string())
4333}
4334
4335fn merge_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
4338 let keys: Vec<String> = source.iter().map(|(k, _)| k.to_owned()).collect();
4341 for key in &keys {
4342 let Some((source_key, source_item)) = source.get_key_value(key) else {
4343 continue;
4344 };
4345 let Some((mut target_key_mut, target_item)) = target.get_key_value_mut(key) else {
4346 continue;
4347 };
4348
4349 *target_key_mut.leaf_decor_mut() = source_key.leaf_decor().clone();
4351
4352 copy_item_decor(target_item, source_item);
4353
4354 if let (Some(tt), Some(st)) = (target_item.as_table_mut(), source_item.as_table()) {
4355 merge_decor_table(tt, st);
4356 }
4357
4358 if let (Some(ta), Some(sa)) = (
4359 target_item.as_array_of_tables_mut(),
4360 source_item.as_array_of_tables(),
4361 ) {
4362 for (i, source_table) in sa.iter().enumerate() {
4363 if let Some(target_table) = ta.get_mut(i) {
4364 copy_item_decor_table(target_table, source_table);
4365 merge_decor_table(target_table, source_table);
4366 }
4367 }
4368 }
4369 }
4370}
4371
4372fn copy_item_decor(target: &mut toml_edit::Item, source: &toml_edit::Item) {
4376 match (target, source) {
4377 (toml_edit::Item::Table(tt), toml_edit::Item::Table(st)) => {
4378 *tt.decor_mut() = st.decor().clone();
4379 }
4380 (toml_edit::Item::Value(tv), toml_edit::Item::Value(sv)) => {
4381 *tv.decor_mut() = sv.decor().clone();
4382 }
4383 _ => {}
4384 }
4385}
4386
4387fn copy_item_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
4388 *target.decor_mut() = source.decor().clone();
4389}
4390
4391pub fn default_secrets() -> &'static Secrets {
4396 static SECRETS: OnceLock<Secrets> = OnceLock::new();
4397 SECRETS.get_or_init(|| {
4398 #[cfg(test)]
4403 {
4404 Secrets::new(std::sync::Arc::new(
4405 codewhale_secrets::InMemoryKeyringStore::new(),
4406 ))
4407 }
4408 #[cfg(not(test))]
4409 {
4410 Secrets::auto_detect()
4411 }
4412 })
4413}
4414
4415pub use codewhale_paths::{CODEWHALE_APP_DIR, LEGACY_APP_DIR};
4423
4424pub fn codewhale_home() -> Result<PathBuf> {
4429 codewhale_paths::codewhale_home().context("failed to resolve home directory")
4430}
4431
4432pub fn codewhale_home_is_explicit() -> bool {
4437 codewhale_paths::codewhale_home_is_explicit()
4438}
4439
4440pub fn legacy_deepseek_home() -> Result<PathBuf> {
4444 codewhale_paths::legacy_deepseek_home().context("failed to resolve home directory")
4445}
4446
4447fn ensure_safe_state_subdir(subdir: &str) -> Result<()> {
4455 if subdir.is_empty() {
4456 bail!("state subdir must not be empty");
4457 }
4458 let path = std::path::Path::new(subdir);
4459 if path.is_absolute() {
4460 bail!("state subdir must not be an absolute path: {subdir}");
4461 }
4462 if path.components().any(|c| {
4463 matches!(
4464 c,
4465 std::path::Component::RootDir | std::path::Component::Prefix(_)
4466 )
4467 }) {
4468 bail!("state subdir must not contain a root or prefix: {subdir}");
4469 }
4470 if path
4471 .components()
4472 .any(|c| matches!(c, std::path::Component::ParentDir))
4473 {
4474 bail!("state subdir must not contain parent-dir (..) components: {subdir}");
4475 }
4476 Ok(())
4477}
4478
4479pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
4486 ensure_safe_state_subdir(subdir)?;
4487 let explicit_codewhale_home = codewhale_home_is_explicit();
4488 let primary = codewhale_home()?.join(subdir);
4489 if explicit_codewhale_home || primary.exists() {
4490 return Ok(primary);
4491 }
4492 let legacy = legacy_deepseek_home()?.join(subdir);
4493 if legacy.exists() {
4494 return Ok(legacy);
4495 }
4496 Ok(primary)
4498}
4499
4500pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
4510 let (dir, migration) = ensure_state_dir_with_migration(subdir)?;
4511 if let Some(migration) = migration {
4512 eprintln!("{}", migration.user_notice());
4513 }
4514 Ok(dir)
4515}
4516
4517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4518pub enum StateMigrationKind {
4519 Relocated,
4520 Copied,
4521}
4522
4523#[derive(Debug, Clone, PartialEq, Eq)]
4524pub struct StateMigration {
4525 pub subdir: String,
4526 pub legacy_path: PathBuf,
4527 pub primary_path: PathBuf,
4528 pub kind: StateMigrationKind,
4529}
4530
4531impl StateMigration {
4532 pub fn user_notice(&self) -> String {
4533 let action = match self.kind {
4534 StateMigrationKind::Relocated => "relocated",
4535 StateMigrationKind::Copied => "copied",
4536 };
4537 let legacy_detail = match self.kind {
4538 StateMigrationKind::Relocated => {
4539 "The legacy .deepseek copy for this state path was removed by the move."
4540 }
4541 StateMigrationKind::Copied => {
4542 "The legacy .deepseek copy was left in place because a direct move failed."
4543 }
4544 };
4545
4546 format!(
4547 "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.",
4548 self.legacy_path.display(),
4549 self.primary_path.display(),
4550 )
4551 }
4552}
4553
4554pub fn ensure_state_dir_with_migration(subdir: &str) -> Result<(PathBuf, Option<StateMigration>)> {
4558 ensure_safe_state_subdir(subdir)?;
4559 let explicit_codewhale_home = codewhale_home_is_explicit();
4560 let dir = codewhale_home()?.join(subdir);
4561 let migration = if !explicit_codewhale_home {
4562 migrate_legacy_state_dir(&dir, subdir)?
4563 } else {
4564 None
4565 };
4566 std::fs::create_dir_all(&dir)
4567 .with_context(|| format!("failed to create {}/", dir.display()))?;
4568 Ok((dir, migration))
4569}
4570
4571fn migrate_legacy_state_dir(primary: &Path, subdir: &str) -> Result<Option<StateMigration>> {
4576 if primary.exists() || subdir == "." || subdir.is_empty() {
4577 return Ok(None);
4578 }
4579 let legacy = match legacy_deepseek_home() {
4580 Ok(home) => home.join(subdir),
4581 Err(_) => return Ok(None),
4582 };
4583 if !legacy.exists() {
4584 return Ok(None);
4585 }
4586 if let Some(parent) = primary.parent()
4588 && let Err(err) = std::fs::create_dir_all(parent)
4589 {
4590 tracing::warn!(
4591 target: "config::migration",
4592 "Could not create {} for state migration ({}); writing to primary anyway",
4593 parent.display(),
4594 err
4595 );
4596 }
4597 match std::fs::rename(&legacy, primary) {
4598 Ok(()) => {
4599 tracing::info!(
4600 target: "config::migration",
4601 "Migrated legacy state directory {} -> {} (relocated). The .deepseek copy was removed.",
4602 legacy.display(),
4603 primary.display()
4604 );
4605 return Ok(Some(StateMigration {
4606 subdir: subdir.to_string(),
4607 legacy_path: legacy,
4608 primary_path: primary.to_path_buf(),
4609 kind: StateMigrationKind::Relocated,
4610 }));
4611 }
4612 Err(err) => {
4613 match copy_dir_recursive(&legacy, primary) {
4618 Ok(()) => {
4619 tracing::info!(
4620 target: "config::migration",
4621 "Migrated legacy state directory {} -> {} (copied; rename failed: {err}). \
4622 The legacy .deepseek copy was left in place.",
4623 legacy.display(),
4624 primary.display()
4625 );
4626 return Ok(Some(StateMigration {
4627 subdir: subdir.to_string(),
4628 legacy_path: legacy,
4629 primary_path: primary.to_path_buf(),
4630 kind: StateMigrationKind::Copied,
4631 }));
4632 }
4633 Err(copy_err) => {
4634 tracing::warn!(
4635 target: "config::migration",
4636 "Could not migrate legacy state {} -> {} (rename: {err}; copy: {copy_err}). \
4637 New data is written to the primary path; the legacy tree remains untouched.",
4638 legacy.display(),
4639 primary.display()
4640 );
4641 }
4642 }
4643 }
4644 }
4645 Ok(None)
4646}
4647
4648fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
4651 std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
4652 for entry in
4653 std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
4654 {
4655 let entry = entry.with_context(|| format!("failed to read entry in {}", src.display()))?;
4656 let path = entry.path();
4657 let target = dst.join(entry.file_name());
4658 let file_type = entry
4659 .file_type()
4660 .with_context(|| format!("failed to read file type for {}", path.display()))?;
4661 if file_type.is_dir() {
4662 copy_dir_recursive(&path, &target)?;
4663 } else if file_type.is_file() {
4664 std::fs::copy(&path, &target).with_context(|| {
4665 format!("failed to copy {} -> {}", path.display(), target.display())
4666 })?;
4667 }
4668 }
4669 Ok(())
4670}
4671
4672pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> Result<(bool, PathBuf)> {
4679 ensure_safe_state_subdir(subdir)?;
4680 let workspace = normalize_project_workspace(workspace)?;
4681 let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
4682 if primary.exists() {
4683 return Ok((true, primary));
4684 }
4685 let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
4686 Ok((false, legacy))
4687}
4688
4689pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
4692 ensure_safe_state_subdir(subdir)?;
4693 let workspace = normalize_project_workspace(workspace)?;
4694 let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
4695 std::fs::create_dir_all(&dir)
4696 .with_context(|| format!("failed to create {}/", dir.display()))?;
4697 Ok(dir)
4698}
4699
4700pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
4701 if let Some(path) = explicit {
4702 return normalize_config_file_path(path);
4703 }
4704 if let Ok(path) = std::env::var("CODEWHALE_CONFIG_PATH") {
4705 if let Some(path) = config_path_from_env_value(&path)? {
4706 return Ok(path);
4707 }
4708 return default_config_path();
4709 }
4710 if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
4711 if let Some(path) = config_path_from_env_value(&path)? {
4712 return Ok(path);
4713 }
4714 return default_config_path();
4715 }
4716 default_config_path()
4717}
4718
4719fn config_path_from_env_value(path: &str) -> Result<Option<PathBuf>> {
4720 let trimmed = path.trim();
4721 if trimmed.is_empty() {
4722 Ok(None)
4723 } else {
4724 normalize_config_file_path(PathBuf::from(trimmed)).map(Some)
4725 }
4726}
4727
4728#[must_use]
4729pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
4730 config_sibling_path_unchecked(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
4731}
4732
4733fn checked_permissions_path_for_config_path(config_path: &Path) -> Result<PathBuf> {
4734 checked_config_sibling_path(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
4735}
4736
4737pub fn resolve_permissions_path(config_path: Option<PathBuf>) -> Result<PathBuf> {
4738 checked_permissions_path_for_config_path(&resolve_config_path(config_path)?)
4739}
4740
4741pub fn load_permissions_snapshot(config_path: Option<PathBuf>) -> Result<PermissionsSnapshot> {
4744 let path = resolve_permissions_path(config_path)?;
4745 let (file_exists, raw, permissions) = read_permissions_state(&path)?;
4746 let file_state = if !file_exists {
4747 PermissionsFileState::Missing
4748 } else if raw.is_empty() {
4749 PermissionsFileState::Empty
4750 } else {
4751 PermissionsFileState::Present
4752 };
4753 let removal_tokens = (0..permissions.rules.len())
4754 .map(|index| permission_removal_token(&path, &raw, index))
4755 .collect();
4756 Ok(PermissionsSnapshot {
4757 path,
4758 file_state,
4759 permissions,
4760 removal_tokens,
4761 })
4762}
4763
4764pub fn remove_permission_rule(
4771 config_path: Option<PathBuf>,
4772 index: usize,
4773 expected_token: &str,
4774) -> Result<ToolAskRule> {
4775 let path = resolve_permissions_path(config_path)?;
4776 config_document::with_config_write_lock(&path, |path| {
4777 let (file_exists, raw, permissions) = read_permissions_state(path)?;
4778 if !file_exists {
4779 bail!(
4780 "permissions changed after they were listed; reload {} and retry",
4781 quote_os_path(path)
4782 );
4783 }
4784 let rule = permissions.rules.get(index).cloned().with_context(|| {
4785 format!(
4786 "permission rule {} no longer exists in {}; list rules again",
4787 index + 1,
4788 quote_os_path(path)
4789 )
4790 })?;
4791 let current_token = permission_removal_token(path, &raw, index);
4792 if current_token != expected_token {
4793 bail!(
4794 "permissions changed after they were listed; reload {} and retry",
4795 quote_os_path(path)
4796 );
4797 }
4798
4799 let mut document = parse_permissions_document(path, &raw)?;
4800 let rules_item = document.get_mut("rules").with_context(|| {
4801 format!(
4802 "permissions at {} no longer contain a rules array",
4803 quote_os_path(path)
4804 )
4805 })?;
4806 let orphaned_header = remove_permission_rule_item(rules_item, index)?;
4807 if let Some(header) = orphaned_header {
4808 let trailing = format!(
4809 "{header}{}",
4810 document.trailing().as_str().unwrap_or_default()
4811 );
4812 document.set_trailing(trailing);
4813 }
4814 let body = document.to_string();
4815 let persisted = parse_generated_permissions(path, &body)?;
4816 if persisted.rules.len() + 1 != permissions.rules.len() {
4817 bail!(
4818 "refusing inconsistent permission removal at {}",
4819 quote_os_path(path)
4820 );
4821 }
4822 write_permissions_atomic(path, body.as_bytes())?;
4823 Ok(rule)
4824 })
4825}
4826
4827pub fn read_permissions_file(path: &Path) -> Result<String> {
4830 read_checked_permissions_file(path)
4831}
4832
4833fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
4834 let permissions_path = checked_permissions_path_for_config_path(config_path)?;
4835 let (_, _, permissions) = read_permissions_state(&permissions_path)?;
4836 Ok(permissions)
4837}
4838
4839fn read_permissions_state(path: &Path) -> Result<(bool, String, PermissionsToml)> {
4840 let file_exists = checked_path_exists(path)?;
4841 let raw = if file_exists {
4842 read_checked_permissions_file(path)?
4843 } else {
4844 String::new()
4845 };
4846 let permissions = if raw.trim().is_empty() {
4847 PermissionsToml::default()
4848 } else {
4849 toml::from_str(&raw).map_err(|_| {
4850 anyhow::anyhow!(
4851 "failed to parse permissions at {}; file contents were omitted",
4852 quote_os_path(path)
4853 )
4854 })?
4855 };
4856 Ok((file_exists, raw, permissions))
4857}
4858
4859fn parse_permissions_document(path: &Path, raw: &str) -> Result<toml_edit::DocumentMut> {
4860 if raw.trim().is_empty() {
4861 Ok(toml_edit::DocumentMut::new())
4862 } else {
4863 raw.parse::<toml_edit::DocumentMut>().map_err(|_| {
4864 anyhow::anyhow!(
4865 "failed to edit permissions at {}; file contents were omitted",
4866 quote_os_path(path)
4867 )
4868 })
4869 }
4870}
4871
4872fn parse_generated_permissions(path: &Path, body: &str) -> Result<PermissionsToml> {
4873 toml::from_str(body).map_err(|_| {
4874 anyhow::anyhow!(
4875 "generated invalid permissions document for {}; file contents were omitted",
4876 quote_os_path(path)
4877 )
4878 })
4879}
4880
4881fn permission_removal_token(path: &Path, raw: &str, index: usize) -> String {
4882 let mut hasher = Sha256::new();
4883 hasher.update(b"codewhale-permission-removal-v1\0");
4884 hasher.update(quote_os_path(path).as_bytes());
4885 hasher.update(b"\0");
4886 hasher.update(index.to_le_bytes());
4887 hasher.update(b"\0");
4888 hasher.update(raw.as_bytes());
4889 let digest = hasher.finalize();
4890 let mut token = String::with_capacity(24);
4891 for byte in &digest[..12] {
4892 use std::fmt::Write as _;
4893 let _ = write!(&mut token, "{byte:02x}");
4894 }
4895 token
4896}
4897
4898fn append_permission_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
4899 match item {
4900 toml_edit::Item::ArrayOfTables(rules) => {
4901 rules.push(permission_rule_table(rule));
4902 Ok(())
4903 }
4904 toml_edit::Item::Value(value) => {
4905 let Some(rules) = value.as_array_mut() else {
4906 bail!("`rules` in permissions.toml must be an array");
4907 };
4908 rules.push(toml_edit::Value::InlineTable(permission_rule_inline_table(
4909 rule,
4910 )));
4911 Ok(())
4912 }
4913 _ => bail!("`rules` in permissions.toml must be an array"),
4914 }
4915}
4916
4917fn remove_permission_rule_item(item: &mut toml_edit::Item, index: usize) -> Result<Option<String>> {
4918 match item {
4919 toml_edit::Item::ArrayOfTables(rules) => {
4920 if index >= rules.len() {
4921 bail!("permission rule index changed before removal");
4922 }
4923 let file_header = if index == 0 {
4924 rules
4925 .get(index)
4926 .and_then(|rule| rule.decor().prefix())
4927 .and_then(toml_edit::RawString::as_str)
4928 .map(str::to_owned)
4929 } else {
4930 None
4931 };
4932 rules.remove(index);
4933 if let Some(header) = file_header.as_deref()
4934 && let Some(next_rule) = rules.get_mut(0)
4935 {
4936 let next_prefix = next_rule
4937 .decor()
4938 .prefix()
4939 .and_then(toml_edit::RawString::as_str)
4940 .unwrap_or_default()
4941 .to_owned();
4942 next_rule
4943 .decor_mut()
4944 .set_prefix(format!("{header}{next_prefix}"));
4945 return Ok(None);
4946 }
4947 Ok(file_header)
4948 }
4949 toml_edit::Item::Value(value) => {
4950 let Some(rules) = value.as_array_mut() else {
4951 bail!("`rules` in permissions.toml must be an array");
4952 };
4953 if index >= rules.len() {
4954 bail!("permission rule index changed before removal");
4955 }
4956 rules.remove(index);
4957 Ok(None)
4958 }
4959 _ => bail!("`rules` in permissions.toml must be an array"),
4960 }
4961}
4962
4963fn permission_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
4964 let mut table = toml_edit::Table::new();
4965 table["tool"] = toml_edit::value(rule.tool.clone());
4966 if let Some(command) = rule.command.as_deref() {
4967 table["command"] = toml_edit::value(command);
4968 }
4969 if rule.command_exact {
4970 table["command_exact"] = toml_edit::value(true);
4971 }
4972 if let Some(path) = rule.path.as_deref() {
4973 table["path"] = toml_edit::value(path);
4974 }
4975 if let Some(workspace) = rule.workspace.as_deref() {
4976 table["workspace"] = toml_edit::value(workspace);
4977 }
4978 if rule.action != PermissionAction::Ask {
4979 table["action"] = toml_edit::value(match rule.action {
4980 PermissionAction::Allow => "allow",
4981 PermissionAction::Ask => "ask",
4982 PermissionAction::Deny => "deny",
4983 });
4984 }
4985 table
4986}
4987
4988fn permission_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
4989 let mut table = toml_edit::InlineTable::new();
4990 table.insert("tool", toml_edit::Value::from(rule.tool.clone()));
4991 if let Some(command) = rule.command.as_deref() {
4992 table.insert("command", toml_edit::Value::from(command));
4993 }
4994 if rule.command_exact {
4995 table.insert("command_exact", toml_edit::Value::from(true));
4996 }
4997 if let Some(path) = rule.path.as_deref() {
4998 table.insert("path", toml_edit::Value::from(path));
4999 }
5000 if let Some(workspace) = rule.workspace.as_deref() {
5001 table.insert("workspace", toml_edit::Value::from(workspace));
5002 }
5003 if rule.action != PermissionAction::Ask {
5004 table.insert(
5005 "action",
5006 toml_edit::Value::from(match rule.action {
5007 PermissionAction::Allow => "allow",
5008 PermissionAction::Ask => "ask",
5009 PermissionAction::Deny => "deny",
5010 }),
5011 );
5012 }
5013 table
5014}
5015
5016fn write_permissions_atomic(path: &Path, body: &[u8]) -> Result<()> {
5017 let parent = path.parent().with_context(|| {
5018 format!(
5019 "permissions path has no parent directory: {}",
5020 path.display()
5021 )
5022 })?;
5023 fs::create_dir_all(parent).with_context(|| {
5024 format!(
5025 "failed to create permissions directory {}",
5026 parent.display()
5027 )
5028 })?;
5029
5030 let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
5031 format!(
5032 "failed to create temporary permissions file in {}",
5033 parent.display()
5034 )
5035 })?;
5036 #[cfg(unix)]
5037 temporary
5038 .as_file()
5039 .set_permissions(fs::Permissions::from_mode(0o600))
5040 .with_context(|| {
5041 format!(
5042 "failed to secure temporary permissions file for {}",
5043 path.display()
5044 )
5045 })?;
5046 temporary
5047 .write_all(body)
5048 .with_context(|| format!("failed to write permissions at {}", path.display()))?;
5049 temporary
5050 .as_file()
5051 .sync_all()
5052 .with_context(|| format!("failed to sync permissions at {}", path.display()))?;
5053 temporary
5054 .persist(path)
5055 .map_err(|error| error.error)
5056 .with_context(|| format!("failed to replace permissions at {}", path.display()))?;
5057 Ok(())
5058}
5059
5060pub fn default_config_path() -> Result<PathBuf> {
5061 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
5064 if codewhale_home_is_explicit() || primary.exists() {
5065 return Ok(primary);
5066 }
5067 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
5068 if legacy.exists() {
5069 return Ok(legacy);
5070 }
5071 Ok(primary)
5073}
5074
5075#[derive(Debug, Clone, PartialEq, Eq)]
5076pub struct ConfigMigration {
5077 pub legacy_path: PathBuf,
5078 pub primary_path: PathBuf,
5079}
5080
5081impl ConfigMigration {
5082 pub fn user_notice(&self) -> String {
5083 format!(
5084 "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.",
5085 self.legacy_path.display(),
5086 self.primary_path.display()
5087 )
5088 }
5089}
5090
5091pub fn migrate_config_if_needed() -> Result<Option<ConfigMigration>> {
5096 if codewhale_home_is_explicit() {
5097 return Ok(None);
5098 }
5099 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
5100 if primary.exists() {
5101 return Ok(None);
5102 }
5103 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
5104 if !legacy.exists() {
5105 return Ok(None);
5106 }
5107 if let Some(parent) = primary.parent() {
5109 std::fs::create_dir_all(parent).context("failed to create codewhale config directory")?;
5110 }
5111 std::fs::copy(&legacy, &primary)
5112 .context("failed to migrate config from deepseek to codewhale home")?;
5113 tracing::info!(
5114 "Migrated config from {} to {}",
5115 legacy.display(),
5116 primary.display()
5117 );
5118 Ok(Some(ConfigMigration {
5119 legacy_path: legacy,
5120 primary_path: primary,
5121 }))
5122}
5123
5124fn parse_bool(raw: &str) -> Result<bool> {
5125 match raw.trim().to_ascii_lowercase().as_str() {
5126 "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
5127 "0" | "false" | "no" | "off" | "disabled" => Ok(false),
5128 _ => bail!("invalid boolean '{raw}'"),
5129 }
5130}
5131
5132fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
5133 let mut headers = BTreeMap::new();
5134 for pair in raw.trim().split(',') {
5135 let pair = pair.trim();
5136 if pair.is_empty() {
5137 continue;
5138 }
5139 let Some((name, value)) = pair.split_once('=') else {
5140 bail!("invalid header pair '{pair}', expected name=value");
5141 };
5142 let name = name.trim();
5143 let value = value.trim();
5144 if name.is_empty() {
5145 bail!("header name cannot be empty");
5146 }
5147 if value.is_empty() {
5148 continue;
5149 }
5150 headers.insert(name.to_string(), value.to_string());
5151 }
5152 Ok(headers)
5153}
5154
5155fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
5156 if headers.is_empty() {
5157 return None;
5158 }
5159 Some(
5160 headers
5161 .iter()
5162 .map(|(name, value)| format!("{name}={value}"))
5163 .collect::<Vec<_>>()
5164 .join(","),
5165 )
5166}
5167
5168fn serialize_http_headers_for_display(headers: &BTreeMap<String, String>) -> Option<String> {
5169 if headers.is_empty() {
5170 return None;
5171 }
5172 Some(
5173 headers
5174 .iter()
5175 .map(|(name, value)| {
5176 let display_value = if is_sensitive_config_key(name) {
5177 redact_secret(value)
5178 } else {
5179 value.clone()
5180 };
5181 format!("{name}={display_value}")
5182 })
5183 .collect::<Vec<_>>()
5184 .join(","),
5185 )
5186}
5187
5188fn redact_secret(secret: &str) -> String {
5189 let chars: Vec<char> = secret.chars().collect();
5190 if chars.len() <= 16 {
5191 return "********".to_string();
5192 }
5193 let prefix: String = chars.iter().take(4).collect();
5194 let suffix: String = chars
5195 .iter()
5196 .rev()
5197 .take(4)
5198 .collect::<Vec<_>>()
5199 .into_iter()
5200 .rev()
5201 .collect();
5202 format!("{prefix}***{suffix}")
5203}
5204
5205#[must_use]
5206pub fn is_sensitive_config_key(key: &str) -> bool {
5207 let Some(segment) = key.rsplit('.').next() else {
5208 return false;
5209 };
5210 let normalized = segment
5211 .trim()
5212 .trim_matches('"')
5213 .replace('-', "_")
5214 .to_ascii_lowercase();
5215
5216 matches!(
5217 normalized.as_str(),
5218 "api_key"
5219 | "apikey"
5220 | "api_keys"
5221 | "authorization"
5222 | "bearer"
5223 | "client_secret"
5224 | "credential"
5225 | "credentials"
5226 | "id_token"
5227 | "password"
5228 | "passwords"
5229 | "passwd"
5230 | "proxy_authorization"
5231 | "refresh_token"
5232 | "secret"
5233 | "secrets"
5234 | "token"
5235 | "tokens"
5236 ) || normalized.ends_with("_api_key")
5237 || normalized.ends_with("_authorization")
5238 || normalized.ends_with("_password")
5239 || normalized.ends_with("_secret")
5240 || normalized.ends_with("_token")
5241}
5242
5243fn redact_toml_value_for_display(key: &str, value: &toml::Value) -> String {
5244 redact_toml_value_for_display_inner(key, false, value).to_string()
5245}
5246
5247fn toml_value_as_u64(value: &toml::Value) -> Option<u64> {
5248 match value {
5249 toml::Value::Integer(value) => u64::try_from(*value).ok(),
5250 toml::Value::String(value) => value.trim().parse().ok(),
5251 _ => None,
5252 }
5253}
5254
5255fn redact_toml_value_for_display_inner(
5256 key: &str,
5257 sensitive_ancestor: bool,
5258 value: &toml::Value,
5259) -> toml::Value {
5260 let sensitive = sensitive_ancestor || is_sensitive_config_key(key);
5261 match value {
5262 toml::Value::String(value) if sensitive => toml::Value::String(redact_secret(value)),
5263 toml::Value::Array(values) => toml::Value::Array(
5264 values
5265 .iter()
5266 .map(|value| redact_toml_value_for_display_inner(key, sensitive, value))
5267 .collect(),
5268 ),
5269 toml::Value::Table(table) => {
5270 let mut redacted = toml::map::Map::new();
5271 for (child_key, child_value) in table {
5272 let path = if key.is_empty() {
5273 child_key.clone()
5274 } else {
5275 format!("{key}.{child_key}")
5276 };
5277 redacted.insert(
5278 child_key.clone(),
5279 redact_toml_value_for_display_inner(&path, sensitive, child_value),
5280 );
5281 }
5282 toml::Value::Table(redacted)
5283 }
5284 _ if sensitive => toml::Value::String("********".to_string()),
5285 _ => value.clone(),
5286 }
5287}
5288
5289fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
5290 if path.as_os_str().is_empty() {
5291 bail!("config path cannot be empty");
5292 }
5293 if path
5294 .components()
5295 .any(|component| matches!(component, Component::ParentDir))
5296 {
5297 bail!("config path cannot contain '..' components");
5298 }
5299 if path.file_name().is_none() {
5300 bail!("config path must include a file name");
5301 }
5302 let absolute = if path.is_absolute() {
5303 path
5304 } else {
5305 std::env::current_dir()
5306 .context("failed to resolve current directory for config path")?
5307 .join(path)
5308 };
5309 let file_name = absolute
5310 .file_name()
5311 .map(OsString::from)
5312 .context("config path must include a file name")?;
5313 let parent = absolute
5314 .parent()
5315 .context("config path must include a parent directory")?;
5316 let parent = match parent.canonicalize() {
5317 Ok(parent) => parent,
5318 Err(err) if err.kind() == std::io::ErrorKind::NotFound => parent.to_path_buf(),
5319 Err(err) => {
5320 return Err(err).with_context(|| {
5321 format!("failed to resolve config directory {}", parent.display())
5322 });
5323 }
5324 };
5325 let normalized = parent.join(file_name);
5326 reject_path_symlink(&normalized)?;
5327 Ok(normalized)
5328}
5329
5330fn normalize_project_workspace(workspace: &Path) -> Result<PathBuf> {
5331 if workspace.as_os_str().is_empty() {
5332 bail!("project workspace path cannot be empty");
5333 }
5334 if workspace
5335 .components()
5336 .any(|component| matches!(component, Component::ParentDir))
5337 {
5338 bail!("project workspace path cannot contain '..' components");
5339 }
5340 let absolute = if workspace.is_absolute() {
5341 workspace.to_path_buf()
5342 } else {
5343 std::env::current_dir()
5344 .context("failed to resolve current directory for project workspace")?
5345 .join(workspace)
5346 };
5347 match absolute.canonicalize() {
5348 Ok(path) => Ok(path),
5349 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
5350 Ok(normalize_path_components(&absolute))
5351 }
5352 Err(err) => Err(err).with_context(|| {
5353 format!(
5354 "failed to resolve project workspace {}",
5355 workspace.display()
5356 )
5357 }),
5358 }
5359}
5360
5361fn normalize_path_components(path: &Path) -> PathBuf {
5362 let mut normalized = PathBuf::new();
5363 for component in path.components() {
5364 match component {
5365 Component::Prefix(_) | Component::RootDir => normalized.push(component.as_os_str()),
5366 Component::CurDir => {}
5367 Component::ParentDir => {
5368 normalized.pop();
5369 }
5370 Component::Normal(part) => normalized.push(part),
5371 }
5372 }
5373 if normalized.as_os_str().is_empty() {
5374 PathBuf::from(".")
5375 } else {
5376 normalized
5377 }
5378}
5379
5380fn checked_path_exists(path: &Path) -> Result<bool> {
5381 let path = normalize_config_file_path(path.to_path_buf())?;
5382 path.try_exists()
5383 .with_context(|| format!("failed to inspect config path {}", path.display()))
5384}
5385
5386fn read_checked_config_file(path: &Path) -> Result<String> {
5387 read_checked_toml_file(path, "config")
5388}
5389
5390fn read_checked_permissions_file(path: &Path) -> Result<String> {
5391 read_checked_toml_file(path, "permissions")
5392}
5393
5394fn read_checked_toml_file(path: &Path, label: &str) -> Result<String> {
5395 let path = normalize_config_file_path(path.to_path_buf())?;
5396 read_string_no_follow(&path)
5397 .with_context(|| format!("failed to read {label} at {}", path.display()))
5398}
5399
5400#[cfg(unix)]
5401fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
5402 let mut file = fs::OpenOptions::new()
5403 .read(true)
5404 .custom_flags(libc::O_NOFOLLOW)
5405 .open(path)?;
5406 let mut raw = String::new();
5407 file.read_to_string(&mut raw)?;
5408 Ok(raw)
5409}
5410
5411#[cfg(not(unix))]
5412fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
5413 fs::read_to_string(path)
5414}
5415
5416fn reject_path_symlink(path: &Path) -> Result<()> {
5417 let Ok(metadata) = fs::symlink_metadata(path) else {
5418 return Ok(());
5419 };
5420 if metadata.file_type().is_symlink() {
5421 bail!("config path must not be a symlink: {}", path.display());
5422 }
5423 Ok(())
5424}
5425
5426#[derive(Debug, Clone, Default)]
5427struct EnvRuntimeOverrides {
5428 provider: Option<ProviderKind>,
5429 provider_source: Option<&'static str>,
5430 model: Option<String>,
5431 volcengine_model: Option<String>,
5432 wanjie_ark_model: Option<String>,
5433 openrouter_model: Option<String>,
5434 moonshot_model: Option<String>,
5435 xiaomi_mimo_model: Option<String>,
5436 xiaomi_mimo_mode: Option<String>,
5437 novita_model: Option<String>,
5438 fireworks_model: Option<String>,
5439 arcee_model: Option<String>,
5440 output_mode: Option<String>,
5441 auth_mode: Option<String>,
5442 log_level: Option<String>,
5443 telemetry: Option<bool>,
5444 approval_policy: Option<String>,
5445 sandbox_mode: Option<String>,
5446 yolo: Option<bool>,
5447 verbosity: Option<String>,
5448 http_headers: Option<BTreeMap<String, String>>,
5449 deepseek_base_url: Option<String>,
5450 deepseek_anthropic_base_url: Option<String>,
5451 nvidia_base_url: Option<String>,
5452 openai_base_url: Option<String>,
5453 atlascloud_base_url: Option<String>,
5454 volcengine_base_url: Option<String>,
5455 wanjie_ark_base_url: Option<String>,
5456 openrouter_base_url: Option<String>,
5457 xiaomi_mimo_base_url: Option<String>,
5458 novita_base_url: Option<String>,
5459 fireworks_base_url: Option<String>,
5460 siliconflow_base_url: Option<String>,
5461 siliconflow_model: Option<String>,
5462 arcee_base_url: Option<String>,
5463 moonshot_base_url: Option<String>,
5464 sglang_base_url: Option<String>,
5465 vllm_base_url: Option<String>,
5466 ollama_base_url: Option<String>,
5467 huggingface_base_url: Option<String>,
5468 huggingface_model: Option<String>,
5469 together_base_url: Option<String>,
5470 together_model: Option<String>,
5471 qianfan_base_url: Option<String>,
5472 qianfan_model: Option<String>,
5473 openai_codex_base_url: Option<String>,
5474 openai_codex_model: Option<String>,
5475 anthropic_base_url: Option<String>,
5476 anthropic_model: Option<String>,
5477 openmodel_base_url: Option<String>,
5478 openmodel_model: Option<String>,
5479 zai_base_url: Option<String>,
5480 zai_model: Option<String>,
5481 stepfun_base_url: Option<String>,
5482 stepfun_model: Option<String>,
5483 minimax_base_url: Option<String>,
5484 minimax_anthropic_base_url: Option<String>,
5485 minimax_model: Option<String>,
5486 deepinfra_base_url: Option<String>,
5487 deepinfra_model: Option<String>,
5488 sakana_base_url: Option<String>,
5489 sakana_model: Option<String>,
5490 longcat_base_url: Option<String>,
5491 longcat_model: Option<String>,
5492 opencode_go_base_url: Option<String>,
5493 opencode_go_model: Option<String>,
5494 opencode_zen_base_url: Option<String>,
5495 opencode_zen_model: Option<String>,
5496 meta_base_url: Option<String>,
5497 meta_model: Option<String>,
5498 xai_base_url: Option<String>,
5499 xai_model: Option<String>,
5500 telecomjs_base_url: Option<String>,
5501 telecomjs_model: Option<String>,
5502}
5503
5504impl EnvRuntimeOverrides {
5505 fn load() -> Self {
5506 let (provider, provider_source) = Self::load_provider();
5507 Self {
5508 provider,
5509 provider_source,
5510 model: std::env::var("CODEWHALE_MODEL")
5511 .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
5512 .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
5513 .ok()
5514 .filter(|v| !v.trim().is_empty()),
5515 volcengine_model: std::env::var("VOLCENGINE_MODEL")
5516 .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
5517 .ok()
5518 .filter(|v| !v.trim().is_empty()),
5519 wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
5520 .or_else(|_| std::env::var("WANJIE_MODEL"))
5521 .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
5522 .ok()
5523 .filter(|v| !v.trim().is_empty()),
5524 openrouter_model: std::env::var("OPENROUTER_MODEL")
5525 .ok()
5526 .filter(|v| !v.trim().is_empty()),
5527 moonshot_model: std::env::var("MOONSHOT_MODEL")
5528 .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
5529 .or_else(|_| std::env::var("KIMI_MODEL"))
5530 .ok()
5531 .filter(|v| !v.trim().is_empty()),
5532 xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
5533 .or_else(|_| std::env::var("MIMO_MODEL"))
5534 .ok()
5535 .filter(|v| !v.trim().is_empty()),
5536 xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
5537 .or_else(|_| std::env::var("MIMO_MODE"))
5538 .ok()
5539 .filter(|v| !v.trim().is_empty()),
5540 novita_model: std::env::var("NOVITA_MODEL")
5541 .ok()
5542 .filter(|v| !v.trim().is_empty()),
5543 fireworks_model: std::env::var("FIREWORKS_MODEL")
5544 .ok()
5545 .filter(|v| !v.trim().is_empty()),
5546 arcee_model: std::env::var("ARCEE_MODEL")
5547 .ok()
5548 .filter(|v| !v.trim().is_empty()),
5549 verbosity: std::env::var("CODEWHALE_VERBOSITY")
5550 .or_else(|_| std::env::var("DEEPSEEK_VERBOSITY"))
5551 .ok(),
5552 output_mode: std::env::var("CODEWHALE_OUTPUT_MODE")
5553 .or_else(|_| std::env::var("DEEPSEEK_OUTPUT_MODE"))
5554 .ok(),
5555 auth_mode: std::env::var("CODEWHALE_AUTH_MODE")
5556 .or_else(|_| std::env::var("DEEPSEEK_AUTH_MODE"))
5557 .ok(),
5558 log_level: std::env::var("CODEWHALE_LOG_LEVEL")
5559 .or_else(|_| std::env::var("DEEPSEEK_LOG_LEVEL"))
5560 .ok(),
5561 telemetry: std::env::var("CODEWHALE_TELEMETRY")
5562 .or_else(|_| std::env::var("DEEPSEEK_TELEMETRY"))
5563 .ok()
5564 .and_then(|v| match parse_bool(&v) {
5565 Ok(b) => Some(b),
5566 Err(_) => {
5567 tracing::warn!("Invalid CODEWHALE_TELEMETRY/DEEPSEEK_TELEMETRY value '{v}', expected true/false");
5568 None
5569 }
5570 }),
5571 approval_policy: std::env::var("CODEWHALE_APPROVAL_POLICY")
5572 .or_else(|_| std::env::var("DEEPSEEK_APPROVAL_POLICY"))
5573 .ok(),
5574 sandbox_mode: std::env::var("CODEWHALE_SANDBOX_MODE")
5575 .or_else(|_| std::env::var("DEEPSEEK_SANDBOX_MODE"))
5576 .ok(),
5577 yolo: std::env::var("CODEWHALE_YOLO")
5578 .or_else(|_| std::env::var("DEEPSEEK_YOLO"))
5579 .ok()
5580 .and_then(|v| match parse_bool(&v) {
5581 Ok(b) => Some(b),
5582 Err(_) => {
5583 tracing::warn!("Invalid CODEWHALE_YOLO/DEEPSEEK_YOLO value '{v}', expected true/false");
5584 None
5585 }
5586 }),
5587 http_headers: std::env::var("CODEWHALE_HTTP_HEADERS")
5588 .or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
5589 .ok()
5590 .and_then(|value| match parse_http_headers(&value) {
5591 Ok(h) => Some(h),
5592 Err(_) => {
5593 tracing::warn!("Invalid CODEWHALE_HTTP_HEADERS/DEEPSEEK_HTTP_HEADERS value, expected format: header1=val1,header2=val2");
5594 None
5595 }
5596 })
5597 .filter(|headers| !headers.is_empty()),
5598 deepseek_base_url: std::env::var("CODEWHALE_BASE_URL")
5599 .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
5600 .ok()
5601 .filter(|v| !v.trim().is_empty()),
5602 deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
5603 .or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
5604 .ok()
5605 .filter(|v| !v.trim().is_empty()),
5606 nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
5607 .or_else(|_| std::env::var("NIM_BASE_URL"))
5608 .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
5609 .ok()
5610 .filter(|v| !v.trim().is_empty()),
5611 openai_base_url: std::env::var("OPENAI_BASE_URL")
5612 .ok()
5613 .filter(|v| !v.trim().is_empty()),
5614 atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
5615 .ok()
5616 .filter(|v| !v.trim().is_empty()),
5617 volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
5618 .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
5619 .or_else(|_| std::env::var("ARK_BASE_URL"))
5620 .ok()
5621 .filter(|v| !v.trim().is_empty()),
5622 wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
5623 .or_else(|_| std::env::var("WANJIE_BASE_URL"))
5624 .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
5625 .ok()
5626 .filter(|v| !v.trim().is_empty()),
5627 openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
5628 .ok()
5629 .filter(|v| !v.trim().is_empty()),
5630 xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
5631 .or_else(|_| std::env::var("MIMO_BASE_URL"))
5632 .ok()
5633 .filter(|v| !v.trim().is_empty()),
5634 novita_base_url: std::env::var("NOVITA_BASE_URL")
5635 .ok()
5636 .filter(|v| !v.trim().is_empty()),
5637 fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
5638 .ok()
5639 .filter(|v| !v.trim().is_empty()),
5640 siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
5641 .ok()
5642 .filter(|v| !v.trim().is_empty()),
5643 siliconflow_model: std::env::var("SILICONFLOW_MODEL")
5644 .ok()
5645 .filter(|v| !v.trim().is_empty()),
5646 arcee_base_url: std::env::var("ARCEE_BASE_URL")
5647 .ok()
5648 .filter(|v| !v.trim().is_empty()),
5649 moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
5650 .or_else(|_| std::env::var("KIMI_BASE_URL"))
5651 .ok()
5652 .filter(|v| !v.trim().is_empty()),
5653 sglang_base_url: std::env::var("SGLANG_BASE_URL")
5654 .ok()
5655 .filter(|v| !v.trim().is_empty()),
5656 vllm_base_url: std::env::var("VLLM_BASE_URL")
5657 .ok()
5658 .filter(|v| !v.trim().is_empty()),
5659 ollama_base_url: std::env::var("OLLAMA_BASE_URL")
5660 .ok()
5661 .filter(|v| !v.trim().is_empty()),
5662 huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
5663 .or_else(|_| std::env::var("HF_BASE_URL"))
5664 .ok()
5665 .filter(|v| !v.trim().is_empty()),
5666 huggingface_model: std::env::var("HUGGINGFACE_MODEL")
5667 .or_else(|_| std::env::var("HF_MODEL"))
5668 .ok()
5669 .filter(|v| !v.trim().is_empty()),
5670 together_base_url: std::env::var("TOGETHER_BASE_URL")
5671 .ok()
5672 .filter(|v| !v.trim().is_empty()),
5673 together_model: std::env::var("TOGETHER_MODEL")
5674 .ok()
5675 .filter(|v| !v.trim().is_empty()),
5676 qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
5677 .ok()
5678 .filter(|v| !v.trim().is_empty())
5679 .or_else(|| {
5680 std::env::var("BAIDU_QIANFAN_BASE_URL")
5681 .ok()
5682 .filter(|v| !v.trim().is_empty())
5683 }),
5684 qianfan_model: std::env::var("QIANFAN_MODEL")
5685 .ok()
5686 .filter(|v| !v.trim().is_empty())
5687 .or_else(|| {
5688 std::env::var("BAIDU_QIANFAN_MODEL")
5689 .ok()
5690 .filter(|v| !v.trim().is_empty())
5691 }),
5692 openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
5693 .or_else(|_| std::env::var("CODEX_BASE_URL"))
5694 .ok()
5695 .filter(|v| !v.trim().is_empty()),
5696 openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
5697 .or_else(|_| std::env::var("CODEX_MODEL"))
5698 .ok()
5699 .filter(|v| !v.trim().is_empty()),
5700 anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
5701 .ok()
5702 .filter(|v| !v.trim().is_empty()),
5703 anthropic_model: std::env::var("ANTHROPIC_MODEL")
5704 .ok()
5705 .filter(|v| !v.trim().is_empty()),
5706 openmodel_base_url: std::env::var("OPENMODEL_BASE_URL")
5707 .ok()
5708 .filter(|v| !v.trim().is_empty()),
5709 openmodel_model: std::env::var("OPENMODEL_MODEL")
5710 .ok()
5711 .filter(|v| !v.trim().is_empty()),
5712 zai_base_url: std::env::var("ZAI_BASE_URL")
5713 .or_else(|_| std::env::var("Z_AI_BASE_URL"))
5714 .or_else(|_| std::env::var("ZHIPU_BASE_URL"))
5715 .or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
5716 .or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
5717 .ok()
5718 .filter(|v| !v.trim().is_empty()),
5719 zai_model: std::env::var("ZAI_MODEL")
5720 .or_else(|_| std::env::var("Z_AI_MODEL"))
5721 .or_else(|_| std::env::var("ZHIPU_MODEL"))
5722 .or_else(|_| std::env::var("ZHIPUAI_MODEL"))
5723 .or_else(|_| std::env::var("BIGMODEL_MODEL"))
5724 .or_else(|_| std::env::var("GLM_MODEL"))
5725 .ok()
5726 .filter(|v| !v.trim().is_empty()),
5727 stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
5728 .or_else(|_| std::env::var("STEP_BASE_URL"))
5729 .ok()
5730 .filter(|v| !v.trim().is_empty()),
5731 stepfun_model: std::env::var("STEPFUN_MODEL")
5732 .or_else(|_| std::env::var("STEP_MODEL"))
5733 .ok()
5734 .filter(|v| !v.trim().is_empty()),
5735 minimax_base_url: std::env::var("MINIMAX_BASE_URL")
5736 .ok()
5737 .filter(|v| !v.trim().is_empty()),
5738 minimax_anthropic_base_url: std::env::var("MINIMAX_ANTHROPIC_BASE_URL")
5739 .ok()
5740 .filter(|v| !v.trim().is_empty()),
5741 minimax_model: std::env::var("MINIMAX_MODEL")
5742 .ok()
5743 .filter(|v| !v.trim().is_empty()),
5744 deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
5745 .ok()
5746 .filter(|v| !v.trim().is_empty()),
5747 deepinfra_model: std::env::var("DEEPINFRA_MODEL")
5748 .ok()
5749 .filter(|v| !v.trim().is_empty()),
5750 sakana_base_url: std::env::var("SAKANA_BASE_URL")
5751 .ok()
5752 .filter(|v| !v.trim().is_empty()),
5753 sakana_model: std::env::var("SAKANA_MODEL")
5754 .ok()
5755 .filter(|v| !v.trim().is_empty()),
5756 longcat_base_url: std::env::var("LONGCAT_BASE_URL")
5757 .ok()
5758 .filter(|v| !v.trim().is_empty()),
5759 longcat_model: std::env::var("LONGCAT_MODEL")
5760 .ok()
5761 .filter(|v| !v.trim().is_empty()),
5762 opencode_go_base_url: std::env::var("OPENCODE_GO_BASE_URL")
5763 .ok()
5764 .filter(|v| !v.trim().is_empty()),
5765 opencode_go_model: std::env::var("OPENCODE_GO_MODEL")
5766 .ok()
5767 .filter(|v| !v.trim().is_empty()),
5768 opencode_zen_base_url: std::env::var("OPENCODE_ZEN_BASE_URL")
5769 .ok()
5770 .filter(|v| !v.trim().is_empty()),
5771 opencode_zen_model: std::env::var("OPENCODE_ZEN_MODEL")
5772 .ok()
5773 .filter(|v| !v.trim().is_empty()),
5774 meta_base_url: std::env::var("META_MODEL_API_BASE_URL")
5775 .ok()
5776 .filter(|v| !v.trim().is_empty())
5777 .or_else(|| {
5778 std::env::var("MODEL_API_BASE_URL")
5779 .ok()
5780 .filter(|v| !v.trim().is_empty())
5781 }),
5782 meta_model: std::env::var("META_MODEL_API_MODEL")
5783 .ok()
5784 .filter(|v| !v.trim().is_empty())
5785 .or_else(|| {
5786 std::env::var("MODEL_API_MODEL")
5787 .ok()
5788 .filter(|v| !v.trim().is_empty())
5789 }),
5790 xai_base_url: std::env::var("XAI_BASE_URL")
5791 .ok()
5792 .filter(|v| !v.trim().is_empty()),
5793 xai_model: std::env::var("XAI_MODEL")
5794 .ok()
5795 .filter(|v| !v.trim().is_empty()),
5796 telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL")
5797 .ok()
5798 .filter(|v| !v.trim().is_empty()),
5799 telecomjs_model: std::env::var("TELECOMJS_MODEL")
5800 .ok()
5801 .filter(|v| !v.trim().is_empty()),
5802 }
5803 }
5804
5805 fn load_provider() -> (Option<ProviderKind>, Option<&'static str>) {
5806 if let Ok(value) = std::env::var("CODEWHALE_PROVIDER") {
5807 let parsed = ProviderKind::parse(&value);
5808 return (parsed, parsed.map(|_| "CODEWHALE_PROVIDER"));
5809 }
5810
5811 if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
5812 let parsed = ProviderKind::parse(&value);
5813 return (parsed, parsed.map(|_| "DEEPSEEK_PROVIDER"));
5814 }
5815
5816 (None, None)
5817 }
5818
5819 fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
5820 match provider {
5823 ProviderKind::Deepseek => self.deepseek_base_url.clone(),
5824 ProviderKind::DeepseekAnthropic => self.deepseek_anthropic_base_url.clone(),
5825 ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
5826 ProviderKind::Openai => self.openai_base_url.clone(),
5827 ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
5828 ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
5829 ProviderKind::Volcengine => self.volcengine_base_url.clone(),
5830 ProviderKind::Openrouter => self.openrouter_base_url.clone(),
5831 ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
5832 ProviderKind::Novita => self.novita_base_url.clone(),
5833 ProviderKind::Fireworks => self.fireworks_base_url.clone(),
5834 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
5835 self.siliconflow_base_url.clone()
5836 }
5837 ProviderKind::Arcee => self.arcee_base_url.clone(),
5838 ProviderKind::Moonshot => self.moonshot_base_url.clone(),
5839 ProviderKind::Sglang => self.sglang_base_url.clone(),
5840 ProviderKind::Vllm => self.vllm_base_url.clone(),
5841 ProviderKind::Ollama => self.ollama_base_url.clone(),
5842 ProviderKind::Huggingface => self.huggingface_base_url.clone(),
5843 ProviderKind::Together => self.together_base_url.clone(),
5844 ProviderKind::Qianfan => self.qianfan_base_url.clone(),
5845 ProviderKind::OpenaiCodex => self.openai_codex_base_url.clone(),
5846 ProviderKind::Anthropic => self.anthropic_base_url.clone(),
5847 ProviderKind::Openmodel => self.openmodel_base_url.clone(),
5848 ProviderKind::Zai => self.zai_base_url.clone(),
5849 ProviderKind::Stepfun => self.stepfun_base_url.clone(),
5850 ProviderKind::Minimax => self.minimax_base_url.clone(),
5851 ProviderKind::MinimaxAnthropic => self.minimax_anthropic_base_url.clone(),
5852 ProviderKind::Deepinfra => self.deepinfra_base_url.clone(),
5853 ProviderKind::Sakana => self.sakana_base_url.clone(),
5854 ProviderKind::LongCat => self.longcat_base_url.clone(),
5855 ProviderKind::OpencodeGo => self.opencode_go_base_url.clone(),
5856 ProviderKind::OpencodeZen => self.opencode_zen_base_url.clone(),
5857 ProviderKind::Meta => self.meta_base_url.clone(),
5858 ProviderKind::Xai => self.xai_base_url.clone(),
5859 ProviderKind::Telecomjs => self.telecomjs_base_url.clone(),
5860 ProviderKind::Custom => None,
5863 }
5864 }
5865
5866 fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
5867 let model = match provider {
5868 ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
5869 ProviderKind::Volcengine => self.volcengine_model.clone(),
5870 ProviderKind::Openrouter => self.openrouter_model.clone(),
5871 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
5872 self.siliconflow_model.clone()
5873 }
5874 ProviderKind::Arcee => self.arcee_model.clone(),
5875 ProviderKind::Moonshot => self.moonshot_model.clone(),
5876 ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
5877 ProviderKind::Novita => self.novita_model.clone(),
5878 ProviderKind::Fireworks => self.fireworks_model.clone(),
5879 ProviderKind::Huggingface => self.huggingface_model.clone(),
5880 ProviderKind::Together => self.together_model.clone(),
5881 ProviderKind::Qianfan => self.qianfan_model.clone(),
5882 ProviderKind::OpenaiCodex => self.openai_codex_model.clone(),
5883 ProviderKind::Anthropic => self.anthropic_model.clone(),
5884 ProviderKind::Openmodel => self.openmodel_model.clone(),
5885 ProviderKind::Zai => self.zai_model.clone(),
5886 ProviderKind::Stepfun => self.stepfun_model.clone(),
5887 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => self.minimax_model.clone(),
5888 ProviderKind::Deepinfra => self.deepinfra_model.clone(),
5889 ProviderKind::Sakana => self.sakana_model.clone(),
5890 ProviderKind::LongCat => self.longcat_model.clone(),
5891 ProviderKind::OpencodeGo => self.opencode_go_model.clone(),
5892 ProviderKind::OpencodeZen => self.opencode_zen_model.clone(),
5893 ProviderKind::Meta => self.meta_model.clone(),
5894 ProviderKind::Xai => self.xai_model.clone(),
5895 ProviderKind::Telecomjs => self.telecomjs_model.clone(),
5896 _ => None,
5897 }?;
5898
5899 if provider_preserves_custom_base_url_model(provider, base_url) {
5900 Some(model.trim().to_string())
5901 } else {
5902 Some(normalize_model_for_provider(provider, &model))
5903 }
5904 }
5905}
5906
5907#[cfg(test)]
5908mod tests;