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