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 AutonomyPreference, UntrustedDraftParse, UserConstitution, UserConstitutionLoad,
34};
35pub use xai_credentials::{
36 LEGACY_XAI_OAUTH_FILE_NAME, XAI_OAUTH_GENERATION_PREFIX, XAI_OAUTH_GENERATION_SUFFIX,
37 XaiOAuthCredentialStore, XaiOAuthRevocation, clear_all_xai_oauth_credentials,
38 is_valid_xai_oauth_generation, legacy_xai_oauth_path, remove_xai_oauth_generation,
39 validate_xai_oauth_generation, with_xai_oauth_lifecycle_lock,
40 with_xai_oauth_revocation_transaction, xai_oauth_credentials_dir, xai_oauth_generation_path,
41};
42
43use std::collections::{BTreeMap, BTreeSet};
44use std::ffi::{OsStr, OsString};
45use std::fmt;
46use std::fs;
47#[cfg(unix)]
48use std::io::Read;
49use std::io::Write;
50use std::path::{Component, Path, PathBuf};
51use std::sync::OnceLock;
52
53use anyhow::{Context, Result, bail};
54pub use auth_source::{AuthSourceKind, ProviderAuthSourceToml};
55pub use codewhale_execpolicy::ToolAskRule;
56use codewhale_execpolicy::{ExecPolicyEngine, Ruleset};
57use codewhale_secrets::SecretSource;
58pub use codewhale_secrets::Secrets;
59pub use external_credentials::{
60 EXTERNAL_CREDENTIAL_CONSENT_VERSION, EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS,
61 ExternalCredentialAccess, ExternalCredentialConsentStatus, ExternalCredentialConsentToml,
62 ExternalCredentialReadGrant, ExternalCredentialSource, external_credential_consent_status,
63 quote_os_path, resolve_external_credential_path,
64};
65use serde::{Deserialize, Serialize};
66
67#[cfg(unix)]
68use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
69
70pub const CONFIG_FILE_NAME: &str = "config.toml";
71pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml";
72
73fn http_headers_are_effectively_empty(headers: &BTreeMap<String, String>) -> bool {
74 !headers
75 .iter()
76 .any(|(name, value)| !name.trim().is_empty() && !value.trim().is_empty())
77}
78
79#[must_use]
85pub fn is_upstream_auth_header(name: &str) -> bool {
86 let name = name.trim();
87 is_sensitive_config_key(name) || name.eq_ignore_ascii_case("cookie")
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, Default)]
96pub struct ProviderConfigToml {
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub api_key: Option<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub base_url: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub model: Option<String>,
103 #[serde(
104 default,
105 skip_serializing_if = "Option::is_none",
106 alias = "contextWindow",
107 alias = "context_window_tokens",
108 alias = "contextWindowTokens",
109 alias = "context_length",
110 alias = "contextLength"
111 )]
112 pub context_window: Option<u32>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub mode: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub auth_mode: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub insecure_skip_tls_verify: Option<bool>,
119 #[serde(default, skip_serializing_if = "http_headers_are_effectively_empty")]
120 pub http_headers: BTreeMap<String, String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub path_suffix: Option<String>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub auth: Option<ProviderAuthSourceToml>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub external_credentials: Option<ExternalCredentialConsentToml>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub oauth_credential_generation: Option<String>,
134 #[serde(flatten)]
137 pub extras: BTreeMap<String, toml::Value>,
138}
139
140impl ProviderConfigToml {
141 #[must_use]
142 pub fn is_empty(&self) -> bool {
143 let blank = |value: Option<&String>| value.is_none_or(|value| value.trim().is_empty());
144
145 blank(self.api_key.as_ref())
146 && blank(self.base_url.as_ref())
147 && blank(self.model.as_ref())
148 && self.context_window.is_none()
149 && blank(self.mode.as_ref())
150 && blank(self.auth_mode.as_ref())
151 && self.insecure_skip_tls_verify.is_none()
152 && http_headers_are_effectively_empty(&self.http_headers)
153 && blank(self.path_suffix.as_ref())
154 && self.auth.is_none()
155 && self.external_credentials.is_none()
156 && self.oauth_credential_generation.is_none()
157 && self.extras.is_empty()
158 }
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, Default)]
162pub struct ProvidersToml {
163 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
164 pub deepseek: ProviderConfigToml,
165 #[serde(
166 default,
167 skip_serializing_if = "ProviderConfigToml::is_empty",
168 alias = "deepseek-anthropic",
169 alias = "deepseekAnthropic",
170 alias = "deepseek-claude",
171 alias = "deepseek_claude"
172 )]
173 pub deepseek_anthropic: ProviderConfigToml,
174 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
175 pub nvidia_nim: ProviderConfigToml,
176 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
177 pub openai: ProviderConfigToml,
178 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
179 pub atlascloud: ProviderConfigToml,
180 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
181 pub wanjie_ark: ProviderConfigToml,
182 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
183 pub volcengine: ProviderConfigToml,
184 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
185 pub openrouter: ProviderConfigToml,
186 #[serde(
187 default,
188 skip_serializing_if = "ProviderConfigToml::is_empty",
189 alias = "xiaomi",
190 alias = "mimo",
191 alias = "xiaomimimo"
192 )]
193 pub xiaomi_mimo: ProviderConfigToml,
194 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
195 pub novita: ProviderConfigToml,
196 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
197 pub fireworks: ProviderConfigToml,
198 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
199 pub siliconflow: ProviderConfigToml,
200 #[serde(
201 default,
202 skip_serializing_if = "ProviderConfigToml::is_empty",
203 alias = "siliconflow-CN",
204 alias = "siliconflow-cn"
205 )]
206 pub siliconflow_cn: ProviderConfigToml,
207 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
208 pub arcee: ProviderConfigToml,
209 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
210 pub moonshot: ProviderConfigToml,
211 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
212 pub sglang: ProviderConfigToml,
213 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
214 pub vllm: ProviderConfigToml,
215 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
216 pub ollama: ProviderConfigToml,
217 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
218 pub huggingface: ProviderConfigToml,
219 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
220 pub together: ProviderConfigToml,
221 #[serde(
222 default,
223 skip_serializing_if = "ProviderConfigToml::is_empty",
224 alias = "baidu-qianfan",
225 alias = "baidu_qianfan",
226 alias = "baidu"
227 )]
228 pub qianfan: ProviderConfigToml,
229 #[serde(
230 default,
231 skip_serializing_if = "ProviderConfigToml::is_empty",
232 alias = "openai-codex",
233 alias = "openai_codex",
234 alias = "codex",
235 alias = "chatgpt",
236 alias = "chatgpt-codex"
237 )]
238 pub openai_codex: ProviderConfigToml,
239 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
240 pub anthropic: ProviderConfigToml,
241 #[serde(
242 default,
243 skip_serializing_if = "ProviderConfigToml::is_empty",
244 alias = "open-model",
245 alias = "open_model"
246 )]
247 pub openmodel: ProviderConfigToml,
248 #[serde(
249 default,
250 skip_serializing_if = "ProviderConfigToml::is_empty",
251 alias = "z-ai",
252 alias = "z_ai",
253 alias = "z.ai",
254 alias = "zhipu",
255 alias = "zhipuai",
256 alias = "bigmodel",
257 alias = "big-model"
258 )]
259 pub zai: ProviderConfigToml,
260 #[serde(
261 default,
262 skip_serializing_if = "ProviderConfigToml::is_empty",
263 alias = "step-fun",
264 alias = "step_fun",
265 alias = "stepfun",
266 alias = "stepflash",
267 alias = "step-flash",
268 alias = "step_flash"
269 )]
270 pub stepfun: ProviderConfigToml,
271 #[serde(
272 default,
273 skip_serializing_if = "ProviderConfigToml::is_empty",
274 alias = "mini-max",
275 alias = "mini_max",
276 alias = "minimax"
277 )]
278 pub minimax: ProviderConfigToml,
279 #[serde(
280 default,
281 skip_serializing_if = "ProviderConfigToml::is_empty",
282 alias = "minimax-anthropic",
283 alias = "minimaxAnthropic",
284 alias = "mini-max-anthropic",
285 alias = "mini_max_anthropic"
286 )]
287 pub minimax_anthropic: ProviderConfigToml,
288 #[serde(
289 default,
290 skip_serializing_if = "ProviderConfigToml::is_empty",
291 alias = "deep-infra",
292 alias = "deep_infra"
293 )]
294 pub deepinfra: ProviderConfigToml,
295 #[serde(
296 default,
297 skip_serializing_if = "ProviderConfigToml::is_empty",
298 alias = "sakana-ai",
299 alias = "sakana_ai",
300 alias = "fugu"
301 )]
302 pub sakana: ProviderConfigToml,
303 #[serde(
304 default,
305 skip_serializing_if = "ProviderConfigToml::is_empty",
306 alias = "long-cat",
307 alias = "meituan-longcat",
308 alias = "meituan"
309 )]
310 pub longcat: ProviderConfigToml,
311 #[serde(
312 default,
313 skip_serializing_if = "ProviderConfigToml::is_empty",
314 alias = "opencode-go",
315 alias = "opencodego"
316 )]
317 pub opencode_go: ProviderConfigToml,
318 #[serde(
319 default,
320 skip_serializing_if = "ProviderConfigToml::is_empty",
321 alias = "meta-ai",
322 alias = "meta_ai",
323 alias = "meta-model-api",
324 alias = "meta_model_api",
325 alias = "muse",
326 alias = "muse-spark"
327 )]
328 pub meta: ProviderConfigToml,
329 #[serde(
330 default,
331 skip_serializing_if = "ProviderConfigToml::is_empty",
332 alias = "x-ai",
333 alias = "x_ai",
334 alias = "grok"
335 )]
336 pub xai: ProviderConfigToml,
337 #[serde(
339 default,
340 skip_serializing_if = "ProviderConfigToml::is_empty",
341 alias = "telecom-js",
342 alias = "telecom_js",
343 alias = "telecomjs-cn",
344 alias = "tokenhub"
345 )]
346 pub telecomjs: ProviderConfigToml,
347 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
353 pub custom: ProviderConfigToml,
354 #[serde(flatten)]
357 pub extras: BTreeMap<String, toml::Value>,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
366#[serde(deny_unknown_fields)]
367pub struct PermissionsToml {
368 #[serde(default, skip_serializing_if = "Vec::is_empty")]
369 pub rules: Vec<ToolAskRule>,
370}
371
372impl PermissionsToml {
373 #[must_use]
374 pub fn is_empty(&self) -> bool {
375 self.rules.is_empty()
376 }
377
378 #[must_use]
379 pub fn ruleset(&self) -> Ruleset {
380 use codewhale_execpolicy::PermissionAction;
381 let mut denied = Vec::new();
382 let mut trusted = Vec::new();
383 let mut ask_rules = Vec::new();
384
385 for rule in &self.rules {
386 match rule.action {
387 PermissionAction::Deny => {
388 if let Some(cmd) = &rule.command {
391 denied.push(cmd.clone());
392 }
393 ask_rules.push(rule.clone());
395 }
396 PermissionAction::Allow => {
397 if let Some(cmd) = &rule.command {
401 trusted.push(cmd.clone());
402 }
403 ask_rules.push(rule.clone());
405 }
406 PermissionAction::Ask => {
407 ask_rules.push(rule.clone());
408 }
409 }
410 }
411
412 Ruleset::user(trusted, denied).with_ask_rules(ask_rules)
413 }
414}
415
416impl ProvidersToml {
417 #[must_use]
418 pub fn is_empty(&self) -> bool {
419 self.extras.is_empty()
420 && ProviderKind::all()
421 .iter()
422 .all(|provider| self.for_provider(*provider).is_empty())
423 }
424
425 #[must_use]
426 pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
427 match provider {
428 ProviderKind::Deepseek => &self.deepseek,
429 ProviderKind::DeepseekAnthropic => &self.deepseek_anthropic,
430 ProviderKind::NvidiaNim => &self.nvidia_nim,
431 ProviderKind::Openai => &self.openai,
432 ProviderKind::Atlascloud => &self.atlascloud,
433 ProviderKind::WanjieArk => &self.wanjie_ark,
434 ProviderKind::Volcengine => &self.volcengine,
435 ProviderKind::Openrouter => &self.openrouter,
436 ProviderKind::XiaomiMimo => &self.xiaomi_mimo,
437 ProviderKind::Novita => &self.novita,
438 ProviderKind::Fireworks => &self.fireworks,
439 ProviderKind::Siliconflow => &self.siliconflow,
440 ProviderKind::SiliconflowCN => &self.siliconflow_cn,
441 ProviderKind::Arcee => &self.arcee,
442 ProviderKind::Moonshot => &self.moonshot,
443 ProviderKind::Sglang => &self.sglang,
444 ProviderKind::Vllm => &self.vllm,
445 ProviderKind::Ollama => &self.ollama,
446 ProviderKind::Huggingface => &self.huggingface,
447 ProviderKind::Together => &self.together,
448 ProviderKind::Qianfan => &self.qianfan,
449 ProviderKind::OpenaiCodex => &self.openai_codex,
450 ProviderKind::Anthropic => &self.anthropic,
451 ProviderKind::Openmodel => &self.openmodel,
452 ProviderKind::Zai => &self.zai,
453 ProviderKind::Stepfun => &self.stepfun,
454 ProviderKind::Minimax => &self.minimax,
455 ProviderKind::MinimaxAnthropic => &self.minimax_anthropic,
456 ProviderKind::Deepinfra => &self.deepinfra,
457 ProviderKind::Sakana => &self.sakana,
458 ProviderKind::LongCat => &self.longcat,
459 ProviderKind::OpencodeGo => &self.opencode_go,
460 ProviderKind::Meta => &self.meta,
461 ProviderKind::Xai => &self.xai,
462 ProviderKind::Telecomjs => &self.telecomjs,
463 ProviderKind::Custom => &self.custom,
464 }
465 }
466
467 pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
468 match provider {
469 ProviderKind::Deepseek => &mut self.deepseek,
470 ProviderKind::DeepseekAnthropic => &mut self.deepseek_anthropic,
471 ProviderKind::NvidiaNim => &mut self.nvidia_nim,
472 ProviderKind::Openai => &mut self.openai,
473 ProviderKind::Atlascloud => &mut self.atlascloud,
474 ProviderKind::WanjieArk => &mut self.wanjie_ark,
475 ProviderKind::Volcengine => &mut self.volcengine,
476 ProviderKind::Openrouter => &mut self.openrouter,
477 ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo,
478 ProviderKind::Novita => &mut self.novita,
479 ProviderKind::Fireworks => &mut self.fireworks,
480 ProviderKind::Siliconflow => &mut self.siliconflow,
481 ProviderKind::SiliconflowCN => &mut self.siliconflow_cn,
482 ProviderKind::Arcee => &mut self.arcee,
483 ProviderKind::Moonshot => &mut self.moonshot,
484 ProviderKind::Sglang => &mut self.sglang,
485 ProviderKind::Vllm => &mut self.vllm,
486 ProviderKind::Ollama => &mut self.ollama,
487 ProviderKind::Huggingface => &mut self.huggingface,
488 ProviderKind::Together => &mut self.together,
489 ProviderKind::Qianfan => &mut self.qianfan,
490 ProviderKind::OpenaiCodex => &mut self.openai_codex,
491 ProviderKind::Anthropic => &mut self.anthropic,
492 ProviderKind::Openmodel => &mut self.openmodel,
493 ProviderKind::Zai => &mut self.zai,
494 ProviderKind::Stepfun => &mut self.stepfun,
495 ProviderKind::Minimax => &mut self.minimax,
496 ProviderKind::MinimaxAnthropic => &mut self.minimax_anthropic,
497 ProviderKind::Deepinfra => &mut self.deepinfra,
498 ProviderKind::Sakana => &mut self.sakana,
499 ProviderKind::LongCat => &mut self.longcat,
500 ProviderKind::OpencodeGo => &mut self.opencode_go,
501 ProviderKind::Meta => &mut self.meta,
502 ProviderKind::Xai => &mut self.xai,
503 ProviderKind::Telecomjs => &mut self.telecomjs,
504 ProviderKind::Custom => &mut self.custom,
505 }
506 }
507}
508
509fn deserialize_root_provider<'de, D>(deserializer: D) -> std::result::Result<ProviderKind, D::Error>
510where
511 D: serde::Deserializer<'de>,
512{
513 let value = String::deserialize(deserializer)?;
514 let strict = serde::de::value::StringDeserializer::<D::Error>::new(value);
515 Ok(ProviderKind::deserialize(strict).unwrap_or(ProviderKind::Custom))
516}
517
518#[derive(Debug, Clone, Serialize, Deserialize, Default)]
519pub struct ConfigToml {
520 pub api_key: Option<String>,
523 pub base_url: Option<String>,
525 #[serde(default, skip_serializing_if = "http_headers_are_effectively_empty")]
527 pub http_headers: BTreeMap<String, String>,
528 pub default_text_model: Option<String>,
530 #[serde(default, deserialize_with = "deserialize_root_provider")]
531 pub provider: ProviderKind,
532 #[doc(hidden)]
539 #[serde(skip)]
540 pub selected_provider_id: Option<String>,
541 pub model: Option<String>,
542 pub auth_mode: Option<String>,
543 pub output_mode: Option<String>,
544 pub verbosity: Option<String>,
545 pub log_level: Option<String>,
546 pub telemetry: Option<bool>,
547 pub approval_policy: Option<String>,
548 pub sandbox_mode: Option<String>,
549 #[serde(default)]
551 pub tools: Option<ToolsToml>,
552 #[serde(default, skip_serializing_if = "ProvidersToml::is_empty")]
553 pub providers: ProvidersToml,
554 #[serde(default, skip_serializing_if = "Vec::is_empty")]
558 pub fallback_providers: Vec<ProviderKind>,
559 #[serde(default)]
562 pub network: Option<NetworkPolicyToml>,
563 #[serde(default)]
566 pub verifier: Option<VerifierConfigToml>,
567 #[serde(default)]
571 pub skills: Option<SkillsToml>,
572 #[serde(default)]
575 pub snapshots: Option<SnapshotsToml>,
576 #[serde(default)]
579 pub lsp: Option<LspConfigToml>,
580 #[serde(default)]
583 pub harness_profiles: Vec<HarnessProfile>,
584 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub hotbar: Option<Vec<HotbarBindingToml>>,
588 #[serde(default)]
591 pub hook_sinks: Option<HookSinksToml>,
592 #[serde(default)]
595 pub fleet: Option<FleetConfigToml>,
596 #[serde(default)]
600 pub workflow: Option<WorkflowConfigToml>,
601 #[serde(flatten)]
602 pub extras: BTreeMap<String, toml::Value>,
603}
604
605#[derive(Debug, Clone, Copy, PartialEq, Eq)]
606enum ProviderConfigField {
607 ApiKey,
608 BaseUrl,
609 Model,
610 ContextWindow,
611 Mode,
612 AuthMode,
613 InsecureSkipTlsVerify,
614 HttpHeaders,
615 PathSuffix,
616}
617
618impl ProviderConfigField {
619 fn parse(key: &str) -> Option<Self> {
620 Some(match key {
621 "api_key" => Self::ApiKey,
622 "base_url" => Self::BaseUrl,
623 "model" => Self::Model,
624 "context_window" | "context_window_tokens" => Self::ContextWindow,
625 "mode" => Self::Mode,
626 "auth_mode" => Self::AuthMode,
627 "insecure_skip_tls_verify" => Self::InsecureSkipTlsVerify,
628 "http_headers" => Self::HttpHeaders,
629 "path_suffix" => Self::PathSuffix,
630 _ => return None,
631 })
632 }
633
634 fn key(self) -> &'static str {
635 match self {
636 Self::ApiKey => "api_key",
637 Self::BaseUrl => "base_url",
638 Self::Model => "model",
639 Self::ContextWindow => "context_window",
640 Self::Mode => "mode",
641 Self::AuthMode => "auth_mode",
642 Self::InsecureSkipTlsVerify => "insecure_skip_tls_verify",
643 Self::HttpHeaders => "http_headers",
644 Self::PathSuffix => "path_suffix",
645 }
646 }
647}
648
649fn parse_provider_config_key(key: &str) -> Option<(ProviderKind, ProviderConfigField)> {
650 let suffix = key.strip_prefix("providers.")?;
651 let (provider_key, field_key) = suffix.split_once('.')?;
652 let field = ProviderConfigField::parse(field_key)?;
653 let provider = ProviderKind::ALL
654 .iter()
655 .copied()
656 .find(|kind| kind.provider().provider_config_key() == provider_key)?;
657 Some((provider, field))
658}
659
660fn provider_config_key(provider: ProviderKind, field: ProviderConfigField) -> String {
661 format!(
662 "providers.{}.{}",
663 provider.provider().provider_config_key(),
664 field.key()
665 )
666}
667
668fn get_provider_config_value(
669 config: &ProviderConfigToml,
670 field: ProviderConfigField,
671) -> Option<String> {
672 match field {
673 ProviderConfigField::ApiKey => config.api_key.clone(),
674 ProviderConfigField::BaseUrl => config.base_url.clone(),
675 ProviderConfigField::Model => config.model.clone(),
676 ProviderConfigField::ContextWindow => config.context_window.map(|value| value.to_string()),
677 ProviderConfigField::Mode => config.mode.clone(),
678 ProviderConfigField::AuthMode => config.auth_mode.clone(),
679 ProviderConfigField::InsecureSkipTlsVerify => config
680 .insecure_skip_tls_verify
681 .map(|value| value.to_string()),
682 ProviderConfigField::HttpHeaders => serialize_http_headers(&config.http_headers),
683 ProviderConfigField::PathSuffix => config.path_suffix.clone(),
684 }
685}
686
687fn get_provider_config_display_value(
688 config: &ProviderConfigToml,
689 field: ProviderConfigField,
690) -> Option<String> {
691 match field {
692 ProviderConfigField::ApiKey => config.api_key.as_deref().map(redact_secret),
693 ProviderConfigField::HttpHeaders => {
694 serialize_http_headers_for_display(&config.http_headers)
695 }
696 _ => get_provider_config_value(config, field),
697 }
698}
699
700fn parse_context_window(value: &str) -> Result<u32> {
701 let parsed = value.trim().parse::<u32>().with_context(|| {
702 format!("invalid context_window '{value}': expected a positive token count")
703 })?;
704 if parsed == 0 {
705 bail!("context_window must be greater than 0");
706 }
707 Ok(parsed)
708}
709
710fn set_provider_config_value(
711 config: &mut ConfigToml,
712 provider: ProviderKind,
713 field: ProviderConfigField,
714 value: &str,
715) -> Result<()> {
716 match field {
717 ProviderConfigField::ApiKey => {
718 let value = value.to_string();
719 config.providers.for_provider_mut(provider).api_key = Some(value.clone());
720 if provider == ProviderKind::Deepseek {
721 config.api_key = Some(value);
722 }
723 }
724 ProviderConfigField::BaseUrl => {
725 let value = value.to_string();
726 config.providers.for_provider_mut(provider).base_url = Some(value.clone());
727 if provider == ProviderKind::Deepseek {
728 config.base_url = Some(value);
729 }
730 }
731 ProviderConfigField::Model => {
732 let value = value.to_string();
733 config.providers.for_provider_mut(provider).model = Some(value.clone());
734 if provider == ProviderKind::Deepseek {
735 config.default_text_model = Some(value);
736 }
737 }
738 ProviderConfigField::ContextWindow => {
739 config.providers.for_provider_mut(provider).context_window =
740 Some(parse_context_window(value)?);
741 }
742 ProviderConfigField::Mode => {
743 config.providers.for_provider_mut(provider).mode = Some(value.to_string());
744 }
745 ProviderConfigField::AuthMode => {
746 config.providers.for_provider_mut(provider).auth_mode = Some(value.to_string());
747 }
748 ProviderConfigField::InsecureSkipTlsVerify => {
749 config
750 .providers
751 .for_provider_mut(provider)
752 .insecure_skip_tls_verify = Some(parse_bool(value)?);
753 }
754 ProviderConfigField::HttpHeaders => {
755 let headers = parse_http_headers(value)?;
756 config.providers.for_provider_mut(provider).http_headers = headers.clone();
757 if provider == ProviderKind::Deepseek {
758 config.http_headers = headers;
759 }
760 }
761 ProviderConfigField::PathSuffix => {
762 config.providers.for_provider_mut(provider).path_suffix = Some(value.to_string());
763 }
764 }
765 Ok(())
766}
767
768fn unset_provider_config_value(
769 config: &mut ConfigToml,
770 provider: ProviderKind,
771 field: ProviderConfigField,
772) {
773 match field {
774 ProviderConfigField::ApiKey => {
775 config.providers.for_provider_mut(provider).api_key = None;
776 if provider == ProviderKind::Deepseek {
777 config.api_key = None;
778 }
779 }
780 ProviderConfigField::BaseUrl => {
781 config.providers.for_provider_mut(provider).base_url = None;
782 if provider == ProviderKind::Deepseek {
783 config.base_url = None;
784 }
785 }
786 ProviderConfigField::Model => {
787 config.providers.for_provider_mut(provider).model = None;
788 if provider == ProviderKind::Deepseek {
789 config.default_text_model = None;
790 }
791 }
792 ProviderConfigField::ContextWindow => {
793 config.providers.for_provider_mut(provider).context_window = None;
794 }
795 ProviderConfigField::Mode => {
796 config.providers.for_provider_mut(provider).mode = None;
797 }
798 ProviderConfigField::AuthMode => {
799 config.providers.for_provider_mut(provider).auth_mode = None;
800 }
801 ProviderConfigField::InsecureSkipTlsVerify => {
802 config
803 .providers
804 .for_provider_mut(provider)
805 .insecure_skip_tls_verify = None;
806 }
807 ProviderConfigField::HttpHeaders => {
808 config
809 .providers
810 .for_provider_mut(provider)
811 .http_headers
812 .clear();
813 if provider == ProviderKind::Deepseek {
814 config.http_headers.clear();
815 }
816 }
817 ProviderConfigField::PathSuffix => {
818 config.providers.for_provider_mut(provider).path_suffix = None;
819 }
820 }
821}
822
823fn insert_provider_config_values(
824 out: &mut BTreeMap<String, String>,
825 provider: ProviderKind,
826 config: &ProviderConfigToml,
827) {
828 if let Some(v) = config.api_key.as_ref() {
829 out.insert(
830 provider_config_key(provider, ProviderConfigField::ApiKey),
831 redact_secret(v),
832 );
833 }
834 if let Some(v) = config.base_url.as_ref() {
835 out.insert(
836 provider_config_key(provider, ProviderConfigField::BaseUrl),
837 v.clone(),
838 );
839 }
840 if let Some(v) = config.model.as_ref() {
841 out.insert(
842 provider_config_key(provider, ProviderConfigField::Model),
843 v.clone(),
844 );
845 }
846 if let Some(v) = config.context_window {
847 out.insert(
848 provider_config_key(provider, ProviderConfigField::ContextWindow),
849 v.to_string(),
850 );
851 }
852 if let Some(v) = config.mode.as_ref() {
853 out.insert(
854 provider_config_key(provider, ProviderConfigField::Mode),
855 v.clone(),
856 );
857 }
858 if let Some(v) = config.auth_mode.as_ref() {
859 out.insert(
860 provider_config_key(provider, ProviderConfigField::AuthMode),
861 v.clone(),
862 );
863 }
864 if let Some(v) = config.insecure_skip_tls_verify {
865 out.insert(
866 provider_config_key(provider, ProviderConfigField::InsecureSkipTlsVerify),
867 v.to_string(),
868 );
869 }
870 if let Some(v) = serialize_http_headers_for_display(&config.http_headers) {
871 out.insert(
872 provider_config_key(provider, ProviderConfigField::HttpHeaders),
873 v,
874 );
875 }
876 if let Some(v) = config.path_suffix.as_ref() {
877 out.insert(
878 provider_config_key(provider, ProviderConfigField::PathSuffix),
879 v.clone(),
880 );
881 }
882}
883
884impl ConfigToml {
885 #[must_use]
891 pub fn resolve_harness_profile(
892 &self,
893 provider_route: &str,
894 model: &str,
895 ) -> Option<&HarnessProfile> {
896 self.harness_profiles
897 .iter()
898 .chain(built_in_harness_profiles().iter())
899 .find(|profile| profile.matches_route(provider_route, model))
900 }
901
902 #[must_use]
908 pub fn resolve_hotbar_bindings(&self, known_action_ids: &[&str]) -> HotbarConfigResolution {
909 resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids)
910 }
911}
912
913#[derive(Debug, Clone, PartialEq, Eq)]
918pub struct ProviderChain {
919 providers: Vec<ProviderKind>,
920 position: usize,
921}
922
923pub const HOTBAR_SLOT_COUNT: u8 = 8;
924
925pub const DEFAULT_HOTBAR_ACTIONS: [&str; HOTBAR_SLOT_COUNT as usize] = [
926 "voice.toggle",
927 "session.compact",
928 "mode.plan",
929 "mode.agent",
930 "mode.operate",
931 "palette.open",
932 "sidebar.toggle",
933 "trust.toggle",
934];
935
936#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
938#[serde(deny_unknown_fields)]
939pub struct HotbarBindingToml {
940 pub slot: u8,
941 pub action: String,
942 #[serde(default)]
943 pub label: Option<String>,
944}
945
946#[derive(Debug, Clone, PartialEq, Eq)]
948pub struct HotbarBinding {
949 pub slot: u8,
950 pub action: String,
951 pub label: Option<String>,
952}
953
954#[derive(Debug, Clone, PartialEq, Eq)]
957pub enum HotbarConfigWarning {
958 SlotOutOfRange {
959 slot: u8,
960 action: String,
961 },
962 DuplicateSlot {
963 slot: u8,
964 previous_action: String,
965 replacement_action: String,
966 },
967 UnknownAction {
968 slot: u8,
969 action: String,
970 },
971}
972
973impl fmt::Display for HotbarConfigWarning {
974 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
975 match self {
976 Self::SlotOutOfRange { slot, action } => write!(
977 f,
978 "hotbar slot {slot} for action '{action}' is outside 1-{HOTBAR_SLOT_COUNT}; skipped"
979 ),
980 Self::DuplicateSlot {
981 slot,
982 previous_action,
983 replacement_action,
984 } => write!(
985 f,
986 "hotbar slot {slot} was bound to '{previous_action}' more than once; using '{replacement_action}'"
987 ),
988 Self::UnknownAction { slot, action } => write!(
989 f,
990 "hotbar slot {slot} references unknown action '{action}'; keeping binding"
991 ),
992 }
993 }
994}
995
996#[derive(Debug, Clone, PartialEq, Eq)]
997pub struct HotbarConfigResolution {
998 pub bindings: Vec<HotbarBinding>,
999 pub warnings: Vec<HotbarConfigWarning>,
1000}
1001
1002#[must_use]
1003pub fn default_hotbar_bindings() -> Vec<HotbarBinding> {
1004 DEFAULT_HOTBAR_ACTIONS
1005 .iter()
1006 .enumerate()
1007 .map(|(idx, action)| HotbarBinding {
1008 slot: u8::try_from(idx + 1).expect("default hotbar slot fits in u8"),
1009 action: (*action).to_string(),
1010 label: None,
1011 })
1012 .collect()
1013}
1014
1015#[must_use]
1021pub fn default_hotbar_bindings_toml() -> Vec<HotbarBindingToml> {
1022 default_hotbar_bindings()
1023 .into_iter()
1024 .map(|binding| HotbarBindingToml {
1025 slot: binding.slot,
1026 action: binding.action,
1027 label: binding.label,
1028 })
1029 .collect()
1030}
1031
1032#[must_use]
1033pub fn resolve_hotbar_bindings(
1034 configured: Option<&[HotbarBindingToml]>,
1035 known_action_ids: &[&str],
1036) -> HotbarConfigResolution {
1037 let known = known_action_ids.iter().copied().collect::<BTreeSet<&str>>();
1038 let mut warnings = Vec::new();
1039
1040 let source = match configured {
1041 Some(bindings) => bindings
1042 .iter()
1043 .map(|binding| HotbarBinding {
1044 slot: binding.slot,
1045 action: binding.action.clone(),
1046 label: binding.label.clone(),
1047 })
1048 .collect::<Vec<_>>(),
1049 None => Vec::new(),
1053 };
1054
1055 let mut by_slot: BTreeMap<u8, HotbarBinding> = BTreeMap::new();
1056 for binding in source {
1057 if !(1..=HOTBAR_SLOT_COUNT).contains(&binding.slot) {
1058 warnings.push(HotbarConfigWarning::SlotOutOfRange {
1059 slot: binding.slot,
1060 action: binding.action,
1061 });
1062 continue;
1063 }
1064 if !known.is_empty() && !known.contains(binding.action.as_str()) {
1065 warnings.push(HotbarConfigWarning::UnknownAction {
1066 slot: binding.slot,
1067 action: binding.action.clone(),
1068 });
1069 }
1070 if let Some(previous) = by_slot.insert(binding.slot, binding.clone()) {
1071 warnings.push(HotbarConfigWarning::DuplicateSlot {
1072 slot: binding.slot,
1073 previous_action: previous.action,
1074 replacement_action: binding.action,
1075 });
1076 }
1077 }
1078
1079 HotbarConfigResolution {
1080 bindings: by_slot.into_values().collect(),
1081 warnings,
1082 }
1083}
1084
1085impl ProviderChain {
1086 #[must_use]
1087 pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self {
1088 let mut providers = vec![active];
1089 for fallback in fallbacks {
1090 if *fallback != active && !providers.contains(fallback) {
1091 providers.push(*fallback);
1092 }
1093 }
1094 Self {
1095 providers,
1096 position: 0,
1097 }
1098 }
1099
1100 #[must_use]
1101 pub fn providers(&self) -> &[ProviderKind] {
1102 &self.providers
1103 }
1104
1105 #[must_use]
1106 pub fn position(&self) -> usize {
1107 self.position
1108 }
1109
1110 #[must_use]
1111 pub fn current(&self) -> ProviderKind {
1112 self.providers
1113 .get(self.position)
1114 .copied()
1115 .or_else(|| self.providers.first().copied())
1116 .unwrap_or_default()
1117 }
1118
1119 #[must_use]
1120 pub fn has_next(&self) -> bool {
1121 self.position + 1 < self.providers.len()
1122 }
1123
1124 pub fn advance(&mut self) -> Option<ProviderKind> {
1125 if !self.has_next() {
1126 return None;
1127 }
1128 self.position += 1;
1129 Some(self.current())
1130 }
1131
1132 pub fn reset(&mut self) {
1133 self.position = 0;
1134 }
1135
1136 #[must_use]
1137 pub fn is_fallback_active(&self) -> bool {
1138 self.position > 0
1139 }
1140
1141 #[must_use]
1143 pub fn remaining(&self) -> usize {
1144 self.providers.len() - self.position
1145 }
1146}
1147
1148#[cfg(test)]
1149mod provider_chain_tests {
1150 use super::*;
1151
1152 #[test]
1153 fn current_on_empty_chain_returns_default_provider() {
1154 let chain = ProviderChain {
1155 providers: vec![],
1156 position: 0,
1157 };
1158 assert_eq!(chain.current(), ProviderKind::default());
1159 }
1160}
1161
1162#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1164pub struct HookSinksToml {
1165 #[serde(default)]
1170 pub unix_socket_path: Option<PathBuf>,
1171}
1172
1173#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1176pub struct SkillsToml {
1177 #[serde(default)]
1180 pub registry_url: Option<String>,
1181 #[serde(default)]
1184 pub max_install_size_bytes: Option<u64>,
1185}
1186
1187#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1189pub struct ToolsToml {
1190 #[serde(default)]
1192 pub always_load: Vec<String>,
1193}
1194
1195#[derive(Debug, Clone, Serialize, Deserialize)]
1198pub struct SnapshotsToml {
1199 #[serde(default = "default_snapshots_enabled")]
1200 pub enabled: bool,
1201 #[serde(default = "default_snapshot_max_age_days")]
1202 pub max_age_days: u64,
1203}
1204
1205fn default_snapshots_enabled() -> bool {
1206 true
1207}
1208
1209fn default_snapshot_max_age_days() -> u64 {
1210 7
1211}
1212
1213impl Default for SnapshotsToml {
1214 fn default() -> Self {
1215 Self {
1216 enabled: default_snapshots_enabled(),
1217 max_age_days: default_snapshot_max_age_days(),
1218 }
1219 }
1220}
1221
1222#[derive(Debug, Clone, Serialize, Deserialize)]
1225pub struct FleetConfigToml {
1226 #[serde(default = "default_fleet_trust_level_str")]
1229 pub default_trust_level: String,
1230 #[serde(default = "default_fleet_require_identity")]
1233 pub require_identity_verification: bool,
1234 #[serde(default = "default_fleet_max_trust_level_str")]
1237 pub max_trust_level: String,
1238 #[serde(default)]
1245 pub roles: BTreeMap<String, FleetRolePreset>,
1246 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1250 pub profiles: BTreeMap<String, FleetProfile>,
1251 #[serde(default)]
1253 pub exec: FleetExecConfig,
1254}
1255
1256pub const DEFAULT_SPAWN_DEPTH: u32 = 3;
1271pub const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 900;
1272pub const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1;
1273pub const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600;
1274
1275pub const MAX_SPAWN_DEPTH_CEILING: u32 = 8;
1281
1282#[derive(Debug, Clone, Serialize, Deserialize)]
1287pub struct FleetExecConfig {
1288 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1290 pub allowed_tools: Vec<String>,
1291 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1293 pub disallowed_tools: Vec<String>,
1294 #[serde(default = "default_fleet_max_turns")]
1297 pub max_turns: u32,
1298 #[serde(default = "default_fleet_max_spawn_depth")]
1304 pub max_spawn_depth: u32,
1305 #[serde(default, skip_serializing_if = "String::is_empty")]
1308 pub append_system_prompt: String,
1309 #[serde(default = "default_fleet_output_format")]
1312 pub output_format: String,
1313}
1314
1315fn default_fleet_max_turns() -> u32 {
1316 u32::MAX
1317}
1318
1319fn default_fleet_max_spawn_depth() -> u32 {
1320 DEFAULT_SPAWN_DEPTH
1321}
1322
1323fn default_fleet_output_format() -> String {
1324 "text".to_string()
1325}
1326
1327impl Default for FleetExecConfig {
1328 fn default() -> Self {
1329 Self {
1330 allowed_tools: Vec::new(),
1331 disallowed_tools: Vec::new(),
1332 max_turns: default_fleet_max_turns(),
1333 max_spawn_depth: default_fleet_max_spawn_depth(),
1334 append_system_prompt: String::new(),
1335 output_format: default_fleet_output_format(),
1336 }
1337 }
1338}
1339
1340#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1346pub struct FleetProfile {
1347 #[serde(default)]
1349 pub slot: FleetSlot,
1350 #[serde(default)]
1352 pub role: FleetRole,
1353 #[serde(default)]
1355 pub loadout: FleetLoadout,
1356 #[serde(default, skip_serializing_if = "Option::is_none")]
1361 pub model: Option<String>,
1362 #[serde(default, skip_serializing_if = "Option::is_none")]
1375 pub provider: Option<String>,
1376 #[serde(default, skip_serializing_if = "Option::is_none")]
1382 pub reasoning_effort: Option<String>,
1383 #[serde(default)]
1385 pub permissions: FleetProfilePermissions,
1386 #[serde(default)]
1388 pub delegation: FleetDelegationHints,
1389}
1390
1391#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
1396pub struct FleetRole {
1397 pub name: String,
1399 #[serde(default, skip_serializing_if = "Option::is_none")]
1401 pub description: Option<String>,
1402 #[serde(default, skip_serializing_if = "Option::is_none")]
1404 pub instructions: Option<String>,
1405}
1406
1407impl Default for FleetRole {
1408 fn default() -> Self {
1409 Self {
1410 name: "general".to_string(),
1411 description: None,
1412 instructions: None,
1413 }
1414 }
1415}
1416
1417impl<'de> Deserialize<'de> for FleetRole {
1418 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1419 where
1420 D: serde::Deserializer<'de>,
1421 {
1422 #[derive(Deserialize)]
1423 #[serde(untagged)]
1424 enum FleetRoleWire {
1425 Name(String),
1426 Full {
1427 #[serde(default)]
1428 name: Option<String>,
1429 #[serde(default)]
1430 description: Option<String>,
1431 #[serde(default)]
1432 instructions: Option<String>,
1433 },
1434 }
1435
1436 match FleetRoleWire::deserialize(deserializer)? {
1437 FleetRoleWire::Name(name) => Ok(Self {
1438 name,
1439 ..Self::default()
1440 }),
1441 FleetRoleWire::Full {
1442 name,
1443 description,
1444 instructions,
1445 } => Ok(Self {
1446 name: name.unwrap_or_else(|| Self::default().name),
1447 description,
1448 instructions,
1449 }),
1450 }
1451 }
1452}
1453
1454#[derive(Debug, Clone, PartialEq, Eq, Default)]
1456pub enum FleetSlot {
1457 Manager,
1458 Scout,
1459 Implementer,
1460 Reviewer,
1461 Verifier,
1462 Operator,
1463 Summarizer,
1464 #[default]
1465 General,
1466 Custom(String),
1467}
1468
1469impl FleetSlot {
1470 #[must_use]
1471 pub fn as_str(&self) -> &str {
1472 match self {
1473 Self::Manager => "manager",
1474 Self::Scout => "scout",
1475 Self::Implementer => "implementer",
1476 Self::Reviewer => "reviewer",
1477 Self::Verifier => "verifier",
1478 Self::Operator => "operator",
1479 Self::Summarizer => "summarizer",
1480 Self::General => "general",
1481 Self::Custom(value) => value.as_str(),
1482 }
1483 }
1484
1485 #[must_use]
1486 pub fn from_name(value: &str) -> Self {
1487 match value.trim() {
1488 "manager" | "coordinator" => Self::Manager,
1489 "scout" | "research" | "research-worker" => Self::Scout,
1490 "implementer" | "builder" => Self::Implementer,
1491 "reviewer" => Self::Reviewer,
1492 "verifier" | "tester" => Self::Verifier,
1493 "operator" | "incident" | "incident-worker" => Self::Operator,
1494 "summarizer" | "reducer" => Self::Summarizer,
1495 "general" | "" => Self::General,
1496 other => Self::Custom(other.to_string()),
1500 }
1501 }
1502}
1503
1504impl Serialize for FleetSlot {
1505 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1506 where
1507 S: serde::Serializer,
1508 {
1509 serializer.serialize_str(self.as_str())
1510 }
1511}
1512
1513impl<'de> Deserialize<'de> for FleetSlot {
1514 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1515 where
1516 D: serde::Deserializer<'de>,
1517 {
1518 let value = String::deserialize(deserializer)?;
1519 Ok(Self::from_name(&value))
1520 }
1521}
1522
1523#[derive(Debug, Clone, PartialEq, Eq, Default)]
1525pub enum FleetLoadout {
1526 #[default]
1528 Inherit,
1529 Fast,
1531 Custom(String),
1535}
1536
1537impl FleetLoadout {
1538 #[must_use]
1539 pub fn as_str(&self) -> &str {
1540 match self {
1541 Self::Inherit => "inherit",
1542 Self::Fast => "fast",
1543 Self::Custom(value) => value.as_str(),
1544 }
1545 }
1546
1547 #[must_use]
1548 pub fn from_name(value: &str) -> Self {
1549 match value.trim() {
1550 "inherit" | "default" | "auto" | "" => Self::Inherit,
1551 "fast" => Self::Fast,
1552 other => Self::Custom(other.to_string()),
1556 }
1557 }
1558}
1559
1560impl Serialize for FleetLoadout {
1561 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1562 where
1563 S: serde::Serializer,
1564 {
1565 serializer.serialize_str(self.as_str())
1566 }
1567}
1568
1569impl<'de> Deserialize<'de> for FleetLoadout {
1570 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1571 where
1572 D: serde::Deserializer<'de>,
1573 {
1574 let value = String::deserialize(deserializer)?;
1575 Ok(Self::from_name(&value))
1576 }
1577}
1578
1579#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1581pub struct FleetProfilePermissions {
1582 #[serde(default)]
1584 pub allow_shell: bool,
1585 #[serde(default)]
1587 pub trust: bool,
1588 #[serde(default = "default_fleet_profile_approval_required")]
1590 pub approval_required: bool,
1591}
1592
1593fn default_fleet_profile_approval_required() -> bool {
1594 true
1595}
1596
1597impl Default for FleetProfilePermissions {
1598 fn default() -> Self {
1599 Self {
1600 allow_shell: false,
1601 trust: false,
1602 approval_required: true,
1603 }
1604 }
1605}
1606
1607#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1609pub struct FleetDelegationHints {
1610 #[serde(default, skip_serializing_if = "Option::is_none")]
1613 pub max_spawn_depth: Option<u32>,
1614 #[serde(
1616 default,
1617 alias = "concurrency",
1618 skip_serializing_if = "Option::is_none"
1619 )]
1620 pub max_concurrency: Option<usize>,
1621}
1622
1623#[derive(Debug, Clone, Serialize, Deserialize)]
1632pub struct FleetRolePreset {
1633 #[serde(skip_serializing_if = "Option::is_none")]
1635 pub description: Option<String>,
1636 #[serde(skip_serializing_if = "Option::is_none")]
1638 pub tool_profile: Option<String>,
1639 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1641 pub tools: Vec<String>,
1642 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1644 pub capabilities: Vec<String>,
1645 #[serde(skip_serializing_if = "Option::is_none")]
1647 pub timeout_seconds: Option<u64>,
1648 #[serde(skip_serializing_if = "Option::is_none")]
1650 pub trust_level: Option<String>,
1651}
1652
1653fn default_fleet_trust_level_str() -> String {
1654 "sandbox".to_string()
1655}
1656
1657fn default_fleet_require_identity() -> bool {
1658 true
1659}
1660
1661fn default_fleet_max_trust_level_str() -> String {
1662 "operator".to_string()
1663}
1664
1665impl Default for FleetConfigToml {
1666 fn default() -> Self {
1667 Self {
1668 default_trust_level: default_fleet_trust_level_str(),
1669 require_identity_verification: default_fleet_require_identity(),
1670 max_trust_level: default_fleet_max_trust_level_str(),
1671 roles: BTreeMap::new(),
1672 profiles: BTreeMap::new(),
1673 exec: FleetExecConfig::default(),
1674 }
1675 }
1676}
1677
1678impl FleetConfigToml {
1679 #[must_use]
1682 pub fn resolve_role(&self, name: &str) -> Option<FleetRolePreset> {
1683 self.roles
1684 .get(name)
1685 .cloned()
1686 .or_else(|| built_in_role_presets().get(name).cloned())
1687 }
1688}
1689
1690#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1697pub struct WorkflowConfigToml {
1698 #[serde(default = "default_workflow_automatic")]
1701 pub automatic: bool,
1702 #[serde(default = "default_workflow_auto_start_read_only")]
1706 pub auto_start_read_only: bool,
1707 #[serde(default = "default_workflow_require_approval_for_writes")]
1710 pub require_approval_for_writes: bool,
1711 #[serde(default = "default_workflow_auto_start_child_limit")]
1714 pub auto_start_child_limit: u32,
1715 #[serde(default = "default_workflow_max_children")]
1717 pub max_children: u32,
1718 #[serde(default = "default_workflow_max_concurrent")]
1720 pub max_concurrent: u32,
1721 #[serde(default = "default_workflow_max_depth")]
1723 pub max_depth: u32,
1724 #[serde(default = "default_workflow_default_token_budget")]
1726 pub default_token_budget: u64,
1727 #[serde(default = "default_workflow_max_parallel_writes_without_worktree")]
1730 pub max_parallel_writes_without_worktree: u32,
1731 #[serde(default = "default_workflow_persist_completed_activity")]
1734 pub persist_completed_activity: bool,
1735 #[serde(default = "default_workflow_persist_completed_across_restarts")]
1738 pub persist_completed_across_restarts: bool,
1739}
1740
1741fn default_workflow_automatic() -> bool {
1742 true
1743}
1744
1745fn default_workflow_auto_start_read_only() -> bool {
1746 true
1747}
1748
1749fn default_workflow_require_approval_for_writes() -> bool {
1750 true
1751}
1752
1753fn default_workflow_auto_start_child_limit() -> u32 {
1754 16
1756}
1757
1758fn default_workflow_max_children() -> u32 {
1759 1000
1760}
1761
1762fn default_workflow_max_concurrent() -> u32 {
1763 16
1764}
1765
1766fn default_workflow_max_depth() -> u32 {
1767 2
1768}
1769
1770fn default_workflow_default_token_budget() -> u64 {
1771 120_000
1772}
1773
1774fn default_workflow_max_parallel_writes_without_worktree() -> u32 {
1775 0
1776}
1777
1778fn default_workflow_persist_completed_activity() -> bool {
1779 true
1780}
1781
1782fn default_workflow_persist_completed_across_restarts() -> bool {
1783 true
1784}
1785
1786impl Default for WorkflowConfigToml {
1787 fn default() -> Self {
1788 Self {
1789 automatic: default_workflow_automatic(),
1790 auto_start_read_only: default_workflow_auto_start_read_only(),
1791 require_approval_for_writes: default_workflow_require_approval_for_writes(),
1792 auto_start_child_limit: default_workflow_auto_start_child_limit(),
1793 max_children: default_workflow_max_children(),
1794 max_concurrent: default_workflow_max_concurrent(),
1795 max_depth: default_workflow_max_depth(),
1796 default_token_budget: default_workflow_default_token_budget(),
1797 max_parallel_writes_without_worktree:
1798 default_workflow_max_parallel_writes_without_worktree(),
1799 persist_completed_activity: default_workflow_persist_completed_activity(),
1800 persist_completed_across_restarts: default_workflow_persist_completed_across_restarts(),
1801 }
1802 }
1803}
1804
1805#[must_use]
1807pub fn built_in_role_presets() -> BTreeMap<String, FleetRolePreset> {
1808 [
1809 (
1810 "smoke-runner".to_string(),
1811 FleetRolePreset {
1812 description: Some("Lightweight read-only smoke check worker".to_string()),
1813 tool_profile: Some("read-only".to_string()),
1814 tools: vec![],
1815 capabilities: vec![],
1816 timeout_seconds: Some(300),
1817 trust_level: Some("local".to_string()),
1818 },
1819 ),
1820 (
1821 "reviewer".to_string(),
1822 FleetRolePreset {
1823 description: Some("Read-only code and documentation review".to_string()),
1824 tool_profile: Some("read-only".to_string()),
1825 tools: vec![],
1826 capabilities: vec![],
1827 timeout_seconds: Some(600),
1828 trust_level: None,
1829 },
1830 ),
1831 (
1832 "builder".to_string(),
1833 FleetRolePreset {
1834 description: Some(
1835 "Read-write builder with compilation and test access".to_string(),
1836 ),
1837 tool_profile: Some("read-write".to_string()),
1838 tools: vec![],
1839 capabilities: vec![],
1840 timeout_seconds: Some(1800),
1841 trust_level: Some("local".to_string()),
1842 },
1843 ),
1844 (
1845 "read-only".to_string(),
1846 FleetRolePreset {
1847 description: Some(
1848 "Minimal read-only observer with no writes or secrets".to_string(),
1849 ),
1850 tool_profile: Some("read-only".to_string()),
1851 tools: vec![],
1852 capabilities: vec![],
1853 timeout_seconds: Some(300),
1854 trust_level: Some("sandbox".to_string()),
1855 },
1856 ),
1857 ]
1858 .into()
1859}
1860
1861#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1867#[serde(rename_all = "snake_case")]
1868pub enum VerifierVerdictPolicy {
1869 #[default]
1870 Hunt,
1871}
1872
1873#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1875pub struct VerifierConfigToml {
1876 #[serde(default)]
1880 pub enabled: bool,
1881 #[serde(default)]
1883 pub verdict_policy: VerifierVerdictPolicy,
1884}
1885
1886impl Default for VerifierConfigToml {
1887 fn default() -> Self {
1888 Self {
1889 enabled: false,
1890 verdict_policy: VerifierVerdictPolicy::Hunt,
1891 }
1892 }
1893}
1894
1895#[derive(Debug, Clone, Serialize, Deserialize)]
1898pub struct NetworkPolicyToml {
1899 #[serde(default = "default_network_decision")]
1902 pub default: String,
1903 #[serde(default)]
1906 pub allow: Vec<String>,
1907 #[serde(default)]
1909 pub deny: Vec<String>,
1910 #[serde(default)]
1913 pub proxy: Vec<String>,
1914 #[serde(default)]
1917 pub proxy_fake_ip_cidrs: Vec<String>,
1918 #[serde(default = "default_network_audit")]
1920 pub audit: bool,
1921}
1922
1923fn default_network_decision() -> String {
1924 "prompt".to_string()
1925}
1926
1927fn default_network_audit() -> bool {
1928 true
1929}
1930
1931impl Default for NetworkPolicyToml {
1932 fn default() -> Self {
1933 Self {
1934 default: default_network_decision(),
1935 allow: Vec::new(),
1936 deny: Vec::new(),
1937 proxy: Vec::new(),
1938 proxy_fake_ip_cidrs: Vec::new(),
1939 audit: default_network_audit(),
1940 }
1941 }
1942}
1943
1944#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1947pub struct CustomLspDef {
1948 pub language_id: String,
1950 pub command: String,
1952 #[serde(default)]
1954 pub args: Vec<String>,
1955}
1956
1957#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1961pub struct LspConfigToml {
1962 pub enabled: Option<bool>,
1964 pub poll_after_edit_ms: Option<u64>,
1966 pub max_diagnostics_per_file: Option<usize>,
1968 pub include_warnings: Option<bool>,
1970 pub servers: Option<BTreeMap<String, Vec<String>>>,
1972 pub custom: Option<BTreeMap<String, CustomLspDef>>,
1975}
1976
1977impl ConfigToml {
1978 #[must_use]
1981 pub fn provider_id(&self) -> &str {
1982 self.named_custom_provider_id()
1983 .unwrap_or_else(|| self.provider.as_str())
1984 }
1985
1986 #[must_use]
1989 pub fn named_custom_provider_id(&self) -> Option<&str> {
1990 (self.provider == ProviderKind::Custom)
1991 .then_some(self.selected_provider_id.as_deref())
1992 .flatten()
1993 }
1994
1995 fn named_custom_provider_table(&self, provider_id: &str) -> Result<&toml::value::Table> {
1996 let table = self
1997 .providers
1998 .extras
1999 .get(provider_id)
2000 .and_then(toml::Value::as_table)
2001 .with_context(|| {
2002 format!(
2003 "custom provider '{provider_id}' requires a matching [providers.{provider_id}] table"
2004 )
2005 })?;
2006 let compatible = table
2007 .get("kind")
2008 .and_then(toml::Value::as_str)
2009 .is_some_and(|kind| {
2010 kind.trim()
2011 .to_ascii_lowercase()
2012 .replace('_', "-")
2013 .eq("openai-compatible")
2014 });
2015 if !compatible {
2016 bail!(
2017 "custom provider '{provider_id}' must set [providers.{provider_id}].kind = \"openai-compatible\""
2018 );
2019 }
2020 Ok(table)
2021 }
2022
2023 fn named_custom_provider_config(&self) -> Option<ProviderConfigToml> {
2024 let provider_id = self.named_custom_provider_id()?;
2025 self.named_custom_provider_table(provider_id).ok()?;
2026 self.providers
2027 .extras
2028 .get(provider_id)
2029 .cloned()?
2030 .try_into()
2031 .ok()
2032 }
2033
2034 fn bind_persisted_provider_id(&mut self, provider_id: &str) -> Result<()> {
2035 self.selected_provider_id = None;
2036 if self.provider != ProviderKind::Custom || provider_id == ProviderKind::Custom.as_str() {
2037 return Ok(());
2038 }
2039
2040 self.named_custom_provider_table(provider_id)?;
2041 self.selected_provider_id = Some(provider_id.to_string());
2042 Ok(())
2043 }
2044
2045 pub fn merge_project_overrides(&mut self, project: ConfigToml) {
2054 if project.default_text_model.is_some() {
2055 self.default_text_model = project.default_text_model;
2056 }
2057 if project.model.is_some() {
2058 self.model = project.model;
2059 }
2060 if project.output_mode.is_some() {
2061 self.output_mode = project.output_mode;
2062 }
2063 if project.verbosity.is_some() {
2064 self.verbosity = project.verbosity;
2065 }
2066 if project.log_level.is_some() {
2067 self.log_level = project.log_level;
2068 }
2069 if let Some(policy) = project.approval_policy
2070 && project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy)
2071 {
2072 self.approval_policy = Some(policy);
2073 }
2074 if let Some(mode) = project.sandbox_mode
2075 && project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode)
2076 {
2077 self.sandbox_mode = Some(mode);
2078 }
2079 if project.tools.is_some() {
2080 self.tools = project.tools;
2081 }
2082 for provider in ProviderKind::ALL {
2083 merge_project_provider_config(
2084 self.providers.for_provider_mut(provider),
2085 project.providers.for_provider(provider),
2086 );
2087 }
2088 }
2089
2090 #[must_use]
2091 pub fn get_value(&self, key: &str) -> Option<String> {
2092 if let Some((provider, field)) = parse_provider_config_key(key) {
2093 return get_provider_config_value(self.providers.for_provider(provider), field);
2094 }
2095
2096 match key {
2097 "provider" => Some(self.provider_id().to_string()),
2098 "stream_chunk_timeout_secs" | "tui.stream_chunk_timeout_secs" => {
2099 Some(self.stream_chunk_timeout_secs().to_string())
2100 }
2101 "api_key" => self.api_key.clone(),
2102 "base_url" => self.base_url.clone(),
2103 "http_headers" => serialize_http_headers(&self.http_headers),
2104 "default_text_model" => self.default_text_model.clone(),
2105 "model" => self.model.clone(),
2106 "auth.mode" => self.auth_mode.clone(),
2107 "output_mode" => self.output_mode.clone(),
2108 "verbosity" => self.verbosity.clone(),
2109 "log_level" => self.log_level.clone(),
2110 "telemetry" => self.telemetry.map(|v| v.to_string()),
2111 "approval_policy" => self.approval_policy.clone(),
2112 "sandbox_mode" => self.sandbox_mode.clone(),
2113 "tools.always_load" => self.tools.as_ref().map(|tools| tools.always_load.join(",")),
2114 "hook_sinks.unix_socket_path" => self
2115 .hook_sinks
2116 .as_ref()
2117 .and_then(|sinks| sinks.unix_socket_path.as_ref())
2118 .map(|path| path.display().to_string()),
2119 _ => self.extras.get(key).map(toml::Value::to_string),
2120 }
2121 }
2122
2123 #[must_use]
2124 pub fn get_display_value(&self, key: &str) -> Option<String> {
2125 if let Some((provider, field)) = parse_provider_config_key(key) {
2126 return get_provider_config_display_value(self.providers.for_provider(provider), field);
2127 }
2128
2129 if key == "http_headers" {
2130 return serialize_http_headers_for_display(&self.http_headers);
2131 }
2132
2133 if let Some(value) = self.extras.get(key) {
2134 return Some(redact_toml_value_for_display(key, value));
2135 }
2136
2137 self.get_value(key).map(|value| {
2138 if is_sensitive_config_key(key) {
2139 redact_secret(&value)
2140 } else {
2141 value
2142 }
2143 })
2144 }
2145
2146 #[must_use]
2147 pub fn stream_chunk_timeout_secs(&self) -> u64 {
2148 let raw = self
2149 .extras
2150 .get("tui")
2151 .and_then(toml::Value::as_table)
2152 .and_then(|table| table.get("stream_chunk_timeout_secs"))
2153 .and_then(toml_value_as_u64)
2154 .or_else(|| {
2155 self.extras
2156 .get("tui.stream_chunk_timeout_secs")
2157 .and_then(toml_value_as_u64)
2158 })
2159 .or_else(|| {
2160 self.extras
2161 .get("stream_chunk_timeout_secs")
2162 .and_then(toml_value_as_u64)
2163 })
2164 .unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS);
2165 if raw == 0 {
2166 DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
2167 } else {
2168 raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS)
2169 }
2170 }
2171
2172 pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
2173 if let Some((provider, field)) = parse_provider_config_key(key) {
2174 return set_provider_config_value(self, provider, field, value);
2175 }
2176
2177 match key {
2178 "provider" => {
2179 if let Some(provider) = ProviderKind::parse(value) {
2180 self.provider = provider;
2181 self.selected_provider_id = None;
2182 } else {
2183 let provider_id = value.trim();
2184 self.named_custom_provider_table(provider_id)
2185 .with_context(|| {
2186 format!(
2187 "unknown provider '{value}': expected {} or a configured custom provider",
2188 ProviderKind::names_hint()
2189 )
2190 })?;
2191 self.provider = ProviderKind::Custom;
2192 self.selected_provider_id = Some(provider_id.to_string());
2193 }
2194 }
2195 "api_key" => self.api_key = Some(value.to_string()),
2196 "base_url" => self.base_url = Some(value.to_string()),
2197 "http_headers" => self.http_headers = parse_http_headers(value)?,
2198 "default_text_model" => self.default_text_model = Some(value.to_string()),
2199 "model" => self.model = Some(value.to_string()),
2200 "auth.mode" => self.auth_mode = Some(value.to_string()),
2201 "output_mode" => self.output_mode = Some(value.to_string()),
2202 "verbosity" => self.verbosity = Some(value.to_string()),
2203 "log_level" => self.log_level = Some(value.to_string()),
2204 "telemetry" => {
2205 self.telemetry = Some(parse_bool(value)?);
2206 }
2207 "approval_policy" => self.approval_policy = Some(value.to_string()),
2208 "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
2209 "hook_sinks.unix_socket_path" => {
2210 self.hook_sinks
2211 .get_or_insert_with(HookSinksToml::default)
2212 .unix_socket_path = Some(PathBuf::from(value));
2213 }
2214 _ => {
2215 self.extras
2216 .insert(key.to_string(), toml::Value::String(value.to_string()));
2217 }
2218 }
2219 Ok(())
2220 }
2221
2222 pub fn unset_value(&mut self, key: &str) -> Result<()> {
2223 if let Some((provider, field)) = parse_provider_config_key(key) {
2224 unset_provider_config_value(self, provider, field);
2225 return Ok(());
2226 }
2227
2228 match key {
2229 "provider" => {
2230 self.provider = ProviderKind::Deepseek;
2231 self.selected_provider_id = None;
2232 }
2233 "api_key" => self.api_key = None,
2234 "base_url" => self.base_url = None,
2235 "http_headers" => self.http_headers.clear(),
2236 "default_text_model" => self.default_text_model = None,
2237 "model" => self.model = None,
2238 "auth.mode" => self.auth_mode = None,
2239 "output_mode" => self.output_mode = None,
2240 "verbosity" => self.verbosity = None,
2241 "log_level" => self.log_level = None,
2242 "telemetry" => self.telemetry = None,
2243 "approval_policy" => self.approval_policy = None,
2244 "sandbox_mode" => self.sandbox_mode = None,
2245 "hook_sinks.unix_socket_path" => {
2246 if let Some(sinks) = self.hook_sinks.as_mut() {
2247 sinks.unix_socket_path = None;
2248 }
2249 }
2250 _ => {
2251 self.extras.remove(key);
2252 }
2253 }
2254 Ok(())
2255 }
2256
2257 #[must_use]
2258 pub fn list_values(&self) -> BTreeMap<String, String> {
2259 let mut out = BTreeMap::new();
2260 out.insert("provider".to_string(), self.provider_id().to_string());
2261
2262 if let Some(v) = self.api_key.as_ref() {
2263 out.insert("api_key".to_string(), redact_secret(v));
2264 }
2265 if let Some(v) = self.base_url.as_ref() {
2266 out.insert("base_url".to_string(), v.clone());
2267 }
2268 if let Some(v) = serialize_http_headers_for_display(&self.http_headers) {
2269 out.insert("http_headers".to_string(), v);
2270 }
2271 if let Some(v) = self.default_text_model.as_ref() {
2272 out.insert("default_text_model".to_string(), v.clone());
2273 }
2274 if let Some(v) = self.model.as_ref() {
2275 out.insert("model".to_string(), v.clone());
2276 }
2277 if let Some(v) = self.auth_mode.as_ref() {
2278 out.insert("auth.mode".to_string(), v.clone());
2279 }
2280 if let Some(v) = self.output_mode.as_ref() {
2281 out.insert("output_mode".to_string(), v.clone());
2282 }
2283 if let Some(v) = self.verbosity.as_ref() {
2284 out.insert("verbosity".to_string(), v.clone());
2285 }
2286 if let Some(v) = self.log_level.as_ref() {
2287 out.insert("log_level".to_string(), v.clone());
2288 }
2289 if let Some(v) = self.telemetry {
2290 out.insert("telemetry".to_string(), v.to_string());
2291 }
2292 if let Some(v) = self.approval_policy.as_ref() {
2293 out.insert("approval_policy".to_string(), v.clone());
2294 }
2295 if let Some(v) = self.sandbox_mode.as_ref() {
2296 out.insert("sandbox_mode".to_string(), v.clone());
2297 }
2298 if let Some(v) = self
2299 .hook_sinks
2300 .as_ref()
2301 .and_then(|sinks| sinks.unix_socket_path.as_ref())
2302 {
2303 out.insert(
2304 "hook_sinks.unix_socket_path".to_string(),
2305 v.display().to_string(),
2306 );
2307 }
2308
2309 for provider in ProviderKind::ALL {
2310 insert_provider_config_values(
2311 &mut out,
2312 provider,
2313 self.providers.for_provider(provider),
2314 );
2315 }
2316
2317 for (k, v) in &self.extras {
2318 out.insert(k.clone(), redact_toml_value_for_display(k, v));
2319 }
2320 out
2321 }
2322
2323 #[must_use]
2330 pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
2331 let no_keyring = Secrets::new(std::sync::Arc::new(
2332 codewhale_secrets::InMemoryKeyringStore::new(),
2333 ));
2334 self.resolve_runtime_options_with_secrets(cli, &no_keyring)
2335 }
2336
2337 #[must_use]
2341 pub fn resolve_runtime_options_with_secrets(
2342 &self,
2343 cli: &CliRuntimeOverrides,
2344 secrets: &Secrets,
2345 ) -> ResolvedRuntimeOptions {
2346 let env = EnvRuntimeOverrides::load();
2347 let (provider, provider_source) = if let Some(provider) = cli.provider {
2348 (provider, ProviderSource::Cli)
2349 } else if let Some(provider) = env.provider {
2350 (
2351 provider,
2352 ProviderSource::Env(env.provider_source.unwrap_or("CODEWHALE_PROVIDER")),
2353 )
2354 } else {
2355 (self.provider, ProviderSource::Config)
2356 };
2357
2358 let mut provider_cfg = if provider == ProviderKind::Custom
2359 && matches!(provider_source, ProviderSource::Config)
2360 {
2361 self.named_custom_provider_config()
2362 .unwrap_or_else(|| self.providers.for_provider(provider).clone())
2363 } else {
2364 self.providers.for_provider(provider).clone()
2365 };
2366 if provider == ProviderKind::SiliconflowCN {
2367 let fb = &self.providers.siliconflow;
2368 if provider_cfg.api_key.is_none() {
2369 provider_cfg.api_key = fb.api_key.clone();
2370 }
2371 if provider_cfg.base_url.is_none() {
2372 provider_cfg.base_url = fb.base_url.clone();
2373 }
2374 if provider_cfg.model.is_none() {
2375 provider_cfg.model = fb.model.clone();
2376 }
2377 }
2378 let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
2379 .then(|| self.api_key.clone())
2380 .flatten();
2381 let root_deepseek_base_url = (provider == ProviderKind::Deepseek)
2382 .then(|| self.base_url.clone())
2383 .flatten();
2384 let root_deepseek_model = (provider == ProviderKind::Deepseek)
2385 .then(|| self.default_text_model.clone())
2386 .flatten();
2387 let auth_mode = cli
2388 .auth_mode
2389 .clone()
2390 .or_else(|| env.auth_mode.clone())
2391 .or_else(|| provider_cfg.auth_mode.clone())
2392 .or_else(|| self.auth_mode.clone());
2393 let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
2394 let cli_base_url = cli.base_url.clone();
2395 let env_base_url = env.base_url_for(provider);
2396 let file_base_url = provider_cfg.base_url.clone().or(root_deepseek_base_url);
2397 let base_url_from_file =
2398 cli_base_url.is_none() && env_base_url.is_none() && file_base_url.is_some();
2399 let configured_base_url = cli_base_url.or(env_base_url).or(file_base_url);
2400 let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo {
2401 env.xiaomi_mimo_mode
2402 .clone()
2403 .or_else(|| provider_cfg.mode.clone())
2404 } else {
2405 None
2406 };
2407 let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo {
2408 xiaomi_mimo_env_api_key_for_runtime(
2409 xiaomi_mimo_mode.as_deref(),
2410 configured_base_url.as_deref(),
2411 )
2412 } else {
2413 None
2414 };
2415 let explicit_api_key_for_endpoint = cli
2416 .api_key
2417 .as_deref()
2418 .or(from_file.as_deref())
2419 .or(xiaomi_mimo_env_api_key.as_deref());
2420 let base_url = if provider == ProviderKind::XiaomiMimo {
2421 resolve_xiaomi_mimo_base_url(
2422 configured_base_url,
2423 explicit_api_key_for_endpoint,
2424 xiaomi_mimo_mode.as_deref(),
2425 )
2426 } else {
2427 configured_base_url.unwrap_or_else(|| match provider {
2428 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
2429 ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string(),
2430 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
2431 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
2432 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
2433 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
2434 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
2435 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
2436 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
2437 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
2438 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
2439 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
2440 ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL.to_string(),
2441 ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL.to_string(),
2442 ProviderKind::Moonshot => {
2443 if auth_mode
2444 .as_deref()
2445 .is_some_and(auth_mode_uses_kimi_imported_token)
2446 {
2447 DEFAULT_KIMI_CODE_BASE_URL.to_string()
2448 } else {
2449 DEFAULT_MOONSHOT_BASE_URL.to_string()
2450 }
2451 }
2452 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
2453 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
2454 ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
2455 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(),
2456 ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL.to_string(),
2457 ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL.to_string(),
2458 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
2459 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(),
2460 ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL.to_string(),
2461 ProviderKind::Zai => DEFAULT_ZAI_BASE_URL.to_string(),
2462 ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL.to_string(),
2463 ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL.to_string(),
2464 ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string(),
2465 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL.to_string(),
2466 ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL.to_string(),
2467 ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL.to_string(),
2468 ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL.to_string(),
2469 ProviderKind::Meta => DEFAULT_META_BASE_URL.to_string(),
2470 ProviderKind::Xai => DEFAULT_XAI_BASE_URL.to_string(),
2471 ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL.to_string(),
2472 ProviderKind::Custom => provider.provider().default_base_url().to_string(),
2476 })
2477 };
2478 let uses_kimi_imported_token = provider == ProviderKind::Moonshot
2486 && auth_mode
2487 .as_deref()
2488 .is_some_and(auth_mode_uses_kimi_imported_token);
2489 let auth_disabled = auth_mode_disables_api_key(auth_mode.as_deref());
2490 let custom_endpoint = provider_preserves_custom_base_url_model(provider, &base_url);
2491 let (api_key, api_key_source) = if auth_disabled {
2492 (None, None)
2493 } else if let Some(value) = cli.api_key.clone() {
2494 (Some(value), Some(RuntimeApiKeySource::Cli))
2495 } else if uses_kimi_imported_token && !custom_endpoint {
2496 (None, None)
2497 } else if (!custom_endpoint || base_url_from_file)
2498 && let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty())
2499 {
2500 (Some(value), Some(RuntimeApiKeySource::ConfigFile))
2501 } else if !custom_endpoint
2502 && let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty())
2503 {
2504 (Some(value), Some(RuntimeApiKeySource::Env))
2505 } else if custom_endpoint {
2506 (None, None)
2507 } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
2508 match env_api_key_for_provider(provider) {
2509 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
2510 None => (None, None),
2511 }
2512 } else {
2513 match secrets.resolve_with_source(provider.as_str()) {
2514 Some((value, source)) => {
2515 let source = match source {
2516 SecretSource::Keyring => RuntimeApiKeySource::Keyring,
2517 SecretSource::Env => RuntimeApiKeySource::Env,
2518 };
2519 (Some(value), Some(source))
2520 }
2521 None => match env_api_key_for_provider(provider) {
2522 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
2523 None => (None, None),
2524 },
2525 }
2526 };
2527
2528 let env_provider_model = env.model_for(provider, &base_url);
2529 let explicit_model = cli.model.is_some()
2530 || env.model.is_some()
2531 || env_provider_model.is_some()
2532 || provider_cfg.model.is_some()
2533 || root_deepseek_model.is_some()
2534 || self.model.is_some();
2535 let model = cli
2536 .model
2537 .clone()
2538 .or_else(|| env.model.clone())
2539 .or(env_provider_model)
2540 .or_else(|| provider_cfg.model.clone())
2541 .or(root_deepseek_model)
2542 .or_else(|| self.model.clone())
2543 .unwrap_or_else(|| {
2544 if provider == ProviderKind::Moonshot
2545 && (auth_mode
2546 .as_deref()
2547 .is_some_and(auth_mode_uses_kimi_imported_token)
2548 || moonshot_base_url_uses_kimi_code(&base_url))
2549 {
2550 DEFAULT_KIMI_CODE_MODEL.to_string()
2551 } else {
2552 default_model_for_provider(provider).to_string()
2553 }
2554 });
2555 let model = if provider == ProviderKind::OpencodeGo {
2556 normalize_model_for_provider(provider, &model)
2561 } else if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
2562 model.trim().to_string()
2563 } else {
2564 normalize_model_for_provider(provider, &model)
2565 };
2566
2567 let mut http_headers = self.http_headers.clone();
2568 http_headers.extend(provider_cfg.http_headers.clone());
2569 if let Some(env_headers) = env.http_headers {
2570 http_headers.extend(env_headers);
2571 }
2572 http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
2573 if auth_disabled {
2574 http_headers.retain(|name, _| !is_upstream_auth_header(name));
2575 }
2576
2577 let output_mode = cli
2578 .output_mode
2579 .clone()
2580 .or_else(|| env.output_mode.clone())
2581 .or_else(|| self.output_mode.clone());
2582 let log_level = cli
2583 .log_level
2584 .clone()
2585 .or_else(|| env.log_level.clone())
2586 .or_else(|| self.log_level.clone());
2587 let telemetry = cli
2588 .telemetry
2589 .or(env.telemetry)
2590 .or(self.telemetry)
2591 .unwrap_or(false);
2592 let approval_policy = cli
2593 .approval_policy
2594 .clone()
2595 .or_else(|| env.approval_policy.clone())
2596 .or_else(|| self.approval_policy.clone());
2597 let sandbox_mode = cli
2598 .sandbox_mode
2599 .clone()
2600 .or_else(|| env.sandbox_mode.clone())
2601 .or_else(|| self.sandbox_mode.clone());
2602 let yolo = cli.yolo.or(env.yolo);
2603 let verbosity = cli
2604 .verbosity
2605 .clone()
2606 .or_else(|| env.verbosity.clone())
2607 .or_else(|| self.verbosity.clone());
2608
2609 ResolvedRuntimeOptions {
2610 provider,
2611 provider_source,
2612 model,
2613 api_key,
2614 api_key_source,
2615 base_url,
2616 auth_mode,
2617 insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false),
2618 output_mode,
2619 log_level,
2620 telemetry,
2621 approval_policy,
2622 sandbox_mode,
2623 yolo,
2624 verbosity,
2625 http_headers,
2626 }
2627 }
2628}
2629
2630fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
2631 if source.model.is_some() {
2632 target.model = source.model.clone();
2633 }
2634}
2635
2636#[must_use]
2637pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
2638 let Some(project_rank) = approval_policy_rank(project) else {
2639 return false;
2640 };
2641 match current.and_then(approval_policy_rank) {
2642 Some(current_rank) => project_rank >= current_rank,
2643 None => project_rank >= 2,
2644 }
2645}
2646
2647#[must_use]
2648pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
2649 let normalized_project = project.trim().to_ascii_lowercase();
2650 if normalized_project == "external-sandbox" {
2651 return current
2652 .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
2653 .unwrap_or(false);
2654 }
2655
2656 let Some(project_rank) = sandbox_mode_rank(project) else {
2657 return false;
2658 };
2659 match current.and_then(sandbox_mode_rank) {
2660 Some(current_rank) => project_rank >= current_rank,
2661 None => project_rank >= 2,
2662 }
2663}
2664
2665fn approval_policy_rank(value: &str) -> Option<u8> {
2666 match value.trim().to_ascii_lowercase().as_str() {
2667 "auto" => Some(0),
2668 "suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
2669 "never" | "deny" | "denied" => Some(2),
2670 _ => None,
2671 }
2672}
2673
2674fn sandbox_mode_rank(value: &str) -> Option<u8> {
2675 match value.trim().to_ascii_lowercase().as_str() {
2676 "danger-full-access" => Some(0),
2677 "external-sandbox" => Some(0),
2678 "workspace-write" => Some(1),
2679 "read-only" => Some(2),
2680 _ => None,
2681 }
2682}
2683
2684pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
2690 for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
2691 let path = workspace.join(dir).join(CONFIG_FILE_NAME);
2692 if !project_config_candidate_exists(&path) {
2693 continue;
2694 }
2695 let raw = match read_checked_config_file(&path) {
2696 Ok(raw) => raw,
2697 Err(e) => {
2698 tracing::warn!("Failed to read project config {}: {e:#}", path.display());
2699 return None;
2700 }
2701 };
2702 match toml::from_str::<ConfigToml>(&raw) {
2703 Ok(config) => {
2704 let raw_provider = toml::from_str::<toml::Value>(&raw)
2705 .ok()
2706 .and_then(|document| document.get("provider").cloned())
2707 .and_then(|provider| provider.as_str().map(str::to_string));
2708 if config.provider == ProviderKind::Custom
2709 && raw_provider.as_deref() != Some(ProviderKind::Custom.as_str())
2710 {
2711 tracing::warn!(
2712 "Failed to parse project config {}; file contents were omitted",
2713 quote_os_path(&path)
2714 );
2715 return None;
2716 }
2717 return Some(config);
2718 }
2719 Err(_) => {
2720 tracing::warn!(
2721 "Failed to parse project config {}; file contents were omitted",
2722 quote_os_path(&path)
2723 );
2724 return None;
2725 }
2726 }
2727 }
2728 None
2729}
2730
2731fn project_config_candidate_exists(path: &Path) -> bool {
2732 fs::symlink_metadata(path).is_ok_and(|metadata| {
2733 let file_type = metadata.file_type();
2734 file_type.is_file() || file_type.is_symlink()
2735 })
2736}
2737
2738fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
2739 if matches!(provider, ProviderKind::OpencodeGo) {
2740 return opencode_go_chat_model_id(model)
2741 .unwrap_or(DEFAULT_OPENCODE_GO_MODEL)
2742 .to_string();
2743 }
2744 if matches!(provider, ProviderKind::XiaomiMimo)
2745 && let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
2746 {
2747 return canonical.to_string();
2748 }
2749 if matches!(
2750 provider,
2751 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic
2752 ) && let Some(canonical) = canonical_minimax_model_id(model)
2753 {
2754 return canonical.to_string();
2755 }
2756 if matches!(provider, ProviderKind::Zai)
2757 && let Some(canonical) = canonical_zai_model_id(model)
2758 {
2759 return canonical.to_string();
2760 }
2761
2762 if matches!(
2763 provider,
2764 ProviderKind::Atlascloud
2765 | ProviderKind::WanjieArk
2766 | ProviderKind::Volcengine
2767 | ProviderKind::XiaomiMimo
2768 | ProviderKind::Zai
2769 | ProviderKind::Stepfun
2770 | ProviderKind::Minimax
2771 | ProviderKind::MinimaxAnthropic
2772 | ProviderKind::Qianfan
2773 | ProviderKind::Ollama
2774 | ProviderKind::Meta
2775 | ProviderKind::Xai
2776 ) {
2777 return model.to_string();
2778 }
2779
2780 let normalized = model.trim().to_ascii_lowercase();
2781 if provider == ProviderKind::Openrouter
2782 && let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
2783 {
2784 return canonical.to_string();
2785 }
2786 match (provider, normalized.as_str()) {
2787 (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
2788 DEFAULT_NVIDIA_NIM_MODEL.to_string()
2789 }
2790 (
2791 ProviderKind::NvidiaNim,
2792 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2793 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2794 ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
2795 (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
2796 DEFAULT_OPENROUTER_MODEL.to_string()
2797 }
2798 (
2799 ProviderKind::Openrouter,
2800 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2801 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2802 ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
2803 (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
2804 DEFAULT_NOVITA_MODEL.to_string()
2805 }
2806 (
2807 ProviderKind::Novita,
2808 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2809 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2810 ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
2811 (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
2812 DEFAULT_FIREWORKS_MODEL.to_string()
2813 }
2814 (
2815 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2816 "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
2817 ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
2818 (
2819 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2820 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
2821 ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
2822 (
2823 ProviderKind::Arcee,
2824 "trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking",
2825 ) => DEFAULT_ARCEE_MODEL.to_string(),
2826 (ProviderKind::Arcee, "trinity-mini" | "arcee-trinity-mini") => {
2827 ARCEE_TRINITY_MINI_MODEL.to_string()
2828 }
2829 (ProviderKind::Arcee, "arcee-trinity-large-preview") => {
2830 ARCEE_TRINITY_LARGE_PREVIEW_MODEL.to_string()
2831 }
2832 (
2833 ProviderKind::Moonshot,
2834 "kimi"
2835 | "kimi-k2"
2836 | "kimi-k2.7"
2837 | "kimi-k2-7"
2838 | "kimi-k2.7-code"
2839 | "kimi-k2-7-code"
2840 | "kimi-code"
2841 | "moonshot-kimi-k2.7-code",
2842 ) => DEFAULT_MOONSHOT_MODEL.to_string(),
2843 (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
2844 MOONSHOT_KIMI_K2_6_MODEL.to_string()
2845 }
2846 (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
2847 DEFAULT_SGLANG_MODEL.to_string()
2848 }
2849 (
2850 ProviderKind::Sglang,
2851 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2852 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2853 ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
2854 (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
2855 DEFAULT_VLLM_MODEL.to_string()
2856 }
2857 (
2858 ProviderKind::Vllm,
2859 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2860 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2861 ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
2862 (ProviderKind::Huggingface, "deepseek-v4-pro" | "deepseek-v4pro") => {
2863 DEFAULT_HUGGINGFACE_MODEL.to_string()
2864 }
2865 (
2866 ProviderKind::Huggingface,
2867 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2868 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2869 ) => DEFAULT_HUGGINGFACE_FLASH_MODEL.to_string(),
2870 (ProviderKind::Together, "deepseek-v4-pro" | "deepseek-v4pro") => {
2871 DEFAULT_TOGETHER_MODEL.to_string()
2872 }
2873 (
2874 ProviderKind::Together,
2875 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2876 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2877 ) => DEFAULT_TOGETHER_FLASH_MODEL.to_string(),
2878 (ProviderKind::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
2879 DEFAULT_DEEPINFRA_MODEL.to_string()
2880 }
2881 (
2882 ProviderKind::Deepinfra,
2883 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2884 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2885 ) => DEFAULT_DEEPINFRA_FLASH_MODEL.to_string(),
2886 _ => model.to_string(),
2887 }
2888}
2889
2890pub const OPENCODE_GO_CHAT_MODELS: &[&str] = &[
2897 DEFAULT_OPENCODE_GO_MODEL,
2898 OPENCODE_GO_GROK_4_5_MODEL,
2899 OPENCODE_GO_GLM_5_2_MODEL,
2900 OPENCODE_GO_GLM_5_1_MODEL,
2901 OPENCODE_GO_KIMI_K3_MODEL,
2902 OPENCODE_GO_KIMI_K2_7_CODE_MODEL,
2903 OPENCODE_GO_KIMI_K2_6_MODEL,
2904 OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL,
2905 OPENCODE_GO_MIMO_V2_5_MODEL,
2906 OPENCODE_GO_MIMO_V2_5_PRO_MODEL,
2907];
2908
2909#[must_use]
2914pub fn opencode_go_chat_model_id(model: &str) -> Option<&'static str> {
2915 let normalized = model.trim().to_ascii_lowercase().replace(['_', ' '], "-");
2916 let normalized = normalized
2917 .strip_prefix("opencode-go/")
2918 .unwrap_or(&normalized);
2919 let familiar_alias = match normalized {
2920 "grok-4-5" => Some(OPENCODE_GO_GROK_4_5_MODEL),
2921 "glm-5-2" => Some(OPENCODE_GO_GLM_5_2_MODEL),
2922 "glm-5-1" => Some(OPENCODE_GO_GLM_5_1_MODEL),
2923 "kimi-k2-7-code" => Some(OPENCODE_GO_KIMI_K2_7_CODE_MODEL),
2924 "kimi-k2-6" => Some(OPENCODE_GO_KIMI_K2_6_MODEL),
2925 "deepseek-v4pro" => Some(DEFAULT_OPENCODE_GO_MODEL),
2926 "deepseek-v4flash" => Some(OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL),
2927 "mimo-v2-5" => Some(OPENCODE_GO_MIMO_V2_5_MODEL),
2928 "mimo-v2-5-pro" => Some(OPENCODE_GO_MIMO_V2_5_PRO_MODEL),
2929 _ => None,
2930 };
2931 familiar_alias.or_else(|| {
2932 OPENCODE_GO_CHAT_MODELS
2933 .iter()
2934 .copied()
2935 .find(|candidate| *candidate == normalized)
2936 })
2937}
2938
2939fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> {
2940 let normalized = model.trim().to_ascii_lowercase();
2941 let normalized = normalized.replace(['_', ' '], "-");
2942 match normalized.as_str() {
2943 "mimo"
2944 | DEFAULT_XIAOMI_MIMO_MODEL
2945 | "mimo-v2-5-pro"
2946 | "xiaomi-mimo-v2.5-pro"
2947 | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL),
2948 XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL
2949 | "mimo-v2-5-pro-ultraspeed"
2950 | "xiaomi-mimo-v2.5-pro-ultraspeed"
2951 | "xiaomi-mimo-v2-5-pro-ultraspeed"
2952 | "ultraspeed"
2953 | "pro-ultraspeed" => Some(XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL),
2954 "omni"
2955 | "mimo-omni"
2956 | "v2.5-omni"
2957 | "v25-omni"
2958 | "mimo-v2.5"
2959 | "mimo-v25"
2960 | "mimo-v2-5"
2961 | "mimo-v2.5-omni"
2962 | "mimo-v25-omni"
2963 | "mimo-v2-5-omni"
2964 | "xiaomi-mimo-v2.5"
2965 | "xiaomi-mimo-v2-5"
2966 | "xiaomi-mimo-v2.5-omni"
2967 | "xiaomi-mimo-v2-5-omni" => Some(XIAOMI_MIMO_V2_5_OMNI_MODEL),
2968 "asr" | "mimo-asr" | "mimo-v2.5-asr" | "speech-to-text" | "transcribe" => {
2969 Some(XIAOMI_MIMO_ASR_MODEL)
2970 }
2971 "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => {
2972 Some(XIAOMI_MIMO_TTS_MODEL)
2973 }
2974 "mimo-tts-voicedesign"
2975 | "mimo-voice-design"
2976 | "mimo-v25-tts-voicedesign"
2977 | "mimo-v2.5-tts-voicedesign"
2978 | "voicedesign"
2979 | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL),
2980 "mimo-tts-voiceclone"
2981 | "mimo-voice-clone"
2982 | "mimo-v25-tts-voiceclone"
2983 | "mimo-v2.5-tts-voiceclone"
2984 | "voiceclone"
2985 | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL),
2986 "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL),
2987 _ => None,
2988 }
2989}
2990
2991fn canonical_minimax_model_id(model: &str) -> Option<&'static str> {
2992 let normalized = model.trim().to_ascii_lowercase();
2993 let normalized = normalized.replace(['_', ' '], "-");
2994 match normalized.as_str() {
2995 "minimax" | "minimax-m3" | "minimax-m-3" | "minimax-m-3-thinking" => {
2996 Some(DEFAULT_MINIMAX_MODEL)
2997 }
2998 "minimax-m2.7" | "minimax-m2-7" | "minimax-m-2.7" | "minimax-m-2-7" => {
2999 Some(MINIMAX_M2_7_MODEL)
3000 }
3001 "minimax-m2.7-highspeed"
3002 | "minimax-m2-7-highspeed"
3003 | "minimax-m-2.7-highspeed"
3004 | "minimax-m-2-7-highspeed" => Some(MINIMAX_M2_7_HIGHSPEED_MODEL),
3005 "minimax-m2.5" | "minimax-m2-5" | "minimax-m-2.5" | "minimax-m-2-5" => {
3006 Some(MINIMAX_M2_5_MODEL)
3007 }
3008 "minimax-m2.5-highspeed"
3009 | "minimax-m2-5-highspeed"
3010 | "minimax-m-2.5-highspeed"
3011 | "minimax-m-2-5-highspeed" => Some(MINIMAX_M2_5_HIGHSPEED_MODEL),
3012 "minimax-m2.1" | "minimax-m2-1" | "minimax-m-2.1" | "minimax-m-2-1" => {
3013 Some(MINIMAX_M2_1_MODEL)
3014 }
3015 "minimax-m2.1-highspeed"
3016 | "minimax-m2-1-highspeed"
3017 | "minimax-m-2.1-highspeed"
3018 | "minimax-m-2-1-highspeed" => Some(MINIMAX_M2_1_HIGHSPEED_MODEL),
3019 "minimax-m2" | "minimax-m-2" => Some(MINIMAX_M2_MODEL),
3020 _ => None,
3021 }
3022}
3023
3024fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
3025 let normalized = model.trim().to_ascii_lowercase();
3026 let normalized = normalized.replace(['_', ' '], "-");
3027 match normalized.as_str() {
3028 "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
3029 "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(DEFAULT_ZAI_MODEL),
3030 "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
3031 _ => None,
3032 }
3033}
3034
3035fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
3036 let normalized = model.trim().to_ascii_lowercase();
3037 let normalized = normalized.replace(['_', ' '], "-");
3038 match normalized.as_str() {
3039 OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
3040 | "trinity"
3041 | "trinity-large-thinking"
3042 | "arcee-trinity"
3043 | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
3044 OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
3045 Some(OPENROUTER_GEMMA_4_31B_MODEL)
3046 }
3047 OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
3048 Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
3049 }
3050 OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
3051 Some(OPENROUTER_GLM_5_1_MODEL)
3052 }
3053 OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
3054 Some(OPENROUTER_GLM_5_2_MODEL)
3055 }
3056 OPENROUTER_KIMI_K2_7_CODE_MODEL
3057 | "kimi"
3058 | "kimi-k2"
3059 | "kimi-k2.7"
3060 | "kimi-k2-7"
3061 | "kimi-k2.7-code"
3062 | "kimi-k2-7-code"
3063 | "kimi-code"
3064 | "moonshot-kimi-k2.7-code"
3065 | "openrouter-kimi-k2.7-code" => Some(OPENROUTER_KIMI_K2_7_CODE_MODEL),
3066 OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
3067 Some(OPENROUTER_KIMI_K2_6_MODEL)
3068 }
3069 OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => {
3070 Some(OPENROUTER_MINIMAX_M3_MODEL)
3071 }
3072 OPENROUTER_MINIMAX_M2_7_MODEL
3073 | "minimax-2.7"
3074 | "minimax-2-7"
3075 | "minimax-m2.7"
3076 | "minimax-m2-7"
3077 | "minimax-m-2.7"
3078 | "minimax-m-2-7" => Some(OPENROUTER_MINIMAX_M2_7_MODEL),
3079 OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
3080 | "nemotron-3-nano-omni"
3081 | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
3082 OPENROUTER_QWEN_3_6_35B_A3B_MODEL
3083 | "qwen3.6-35b-a3b"
3084 | "qwen-3.6-35b-a3b"
3085 | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
3086 OPENROUTER_QWEN_3_6_FLASH_MODEL | "qwen3.6-flash" | "qwen-3.6-flash" => {
3087 Some(OPENROUTER_QWEN_3_6_FLASH_MODEL)
3088 }
3089 OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL
3090 | "qwen3.6-max-preview"
3091 | "qwen-3.6-max-preview"
3092 | "qwen-max-preview" => Some(OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
3093 OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
3094 Some(OPENROUTER_QWEN_3_6_27B_MODEL)
3095 }
3096 OPENROUTER_QWEN_3_6_PLUS_MODEL | "qwen3.6-plus" | "qwen-3.6-plus" => {
3097 Some(OPENROUTER_QWEN_3_6_PLUS_MODEL)
3098 }
3099 OPENROUTER_QWEN_3_7_PLUS_MODEL | "qwen3.7-plus" | "qwen-3.7-plus" => {
3100 Some(OPENROUTER_QWEN_3_7_PLUS_MODEL)
3101 }
3102 OPENROUTER_QWEN_3_7_MAX_MODEL | "qwen3.7-max" | "qwen-3.7-max" => {
3103 Some(OPENROUTER_QWEN_3_7_MAX_MODEL)
3104 }
3105 OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
3106 Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
3107 }
3108 OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
3109 | "mimo-v2.5-pro"
3110 | "mimo-v2-5-pro"
3111 | "xiaomi-mimo-v2.5-pro"
3112 | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
3113 OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
3114 | "mimo-v2.5"
3115 | "mimo-v2-5"
3116 | "xiaomi-mimo-v2.5"
3117 | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
3118 _ => None,
3119 }
3120}
3121
3122fn default_model_for_provider(provider: ProviderKind) -> &'static str {
3123 match provider {
3124 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
3125 ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_MODEL,
3126 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
3127 ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
3128 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
3129 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
3130 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
3131 ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
3132 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
3133 ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
3134 ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
3135 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_MODEL,
3136 ProviderKind::Arcee => DEFAULT_ARCEE_MODEL,
3137 ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
3138 ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
3139 ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
3140 ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
3141 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
3142 ProviderKind::Together => DEFAULT_TOGETHER_MODEL,
3143 ProviderKind::Qianfan => DEFAULT_QIANFAN_MODEL,
3144 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL,
3145 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_MODEL,
3146 ProviderKind::Openmodel => DEFAULT_OPENMODEL_MODEL,
3147 ProviderKind::Zai => DEFAULT_ZAI_MODEL,
3148 ProviderKind::Stepfun => DEFAULT_STEPFUN_MODEL,
3149 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_MODEL,
3150 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
3151 ProviderKind::Sakana => DEFAULT_SAKANA_MODEL,
3152 ProviderKind::LongCat => DEFAULT_LONGCAT_MODEL,
3153 ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_MODEL,
3154 ProviderKind::Meta => DEFAULT_META_MODEL,
3155 ProviderKind::Xai => DEFAULT_XAI_MODEL,
3156 ProviderKind::Telecomjs => DEFAULT_TELECOMJS_MODEL,
3157 ProviderKind::Custom => provider.provider().default_model(),
3159 }
3160}
3161
3162fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
3163 match provider {
3164 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
3165 ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
3166 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
3167 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
3168 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
3169 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
3170 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
3171 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
3172 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
3173 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
3174 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
3175 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
3176 ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL,
3177 ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL,
3178 ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
3179 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
3180 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
3181 ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
3182 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
3183 ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL,
3184 ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL,
3185 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
3186 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
3187 ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL,
3188 ProviderKind::Zai => DEFAULT_ZAI_BASE_URL,
3189 ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL,
3190 ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL,
3191 ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
3192 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
3193 ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL,
3194 ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL,
3195 ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL,
3196 ProviderKind::Meta => DEFAULT_META_BASE_URL,
3197 ProviderKind::Xai => DEFAULT_XAI_BASE_URL,
3198 ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL,
3199 ProviderKind::Custom => provider.provider().default_base_url(),
3201 }
3202}
3203
3204fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
3205 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
3206 normalized == DEFAULT_KIMI_CODE_BASE_URL
3207 || normalized == "https://api.kimi.com/coding"
3208 || normalized.starts_with("https://api.kimi.com/coding/")
3209}
3210
3211fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
3212 let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
3213 if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
3214 return None;
3215 }
3216 Some(match normalized.as_str() {
3217 "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => {
3218 DEFAULT_XIAOMI_MIMO_BASE_URL
3219 }
3220 "token-plan-cn"
3221 | "token-plan-china"
3222 | "token-plan-mainland"
3223 | "token-plan-mainland-china"
3224 | "cn"
3225 | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
3226 "token-plan-sgp"
3227 | "token-plan-sg"
3228 | "token-plan-singapore"
3229 | "sgp"
3230 | "sg"
3231 | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
3232 "token-plan-ams"
3233 | "token-plan-eu"
3234 | "token-plan-europe"
3235 | "token-plan-amsterdam"
3236 | "ams"
3237 | "eu"
3238 | "europe"
3239 | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
3240 _ => DEFAULT_XIAOMI_MIMO_BASE_URL,
3241 })
3242}
3243
3244fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
3245 matches!(
3246 normalized_mode,
3247 "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
3248 )
3249}
3250
3251fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
3252 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
3253 normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
3254 || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL
3255 || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
3256}
3257
3258fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option<String> {
3259 candidates.iter().find_map(|name| {
3260 std::env::var(name)
3261 .ok()
3262 .filter(|value| !value.trim().is_empty())
3263 })
3264}
3265
3266fn xiaomi_mimo_env_api_key_for_runtime(
3267 mode: Option<&str>,
3268 base_url: Option<&str>,
3269) -> Option<String> {
3270 const TOKEN_PLAN_ENV_VARS: &[&str] =
3271 &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"];
3272 const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"];
3273
3274 let normalized_mode =
3275 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
3276 let standard_selected = normalized_mode
3277 .as_deref()
3278 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint)
3279 || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go);
3280 if standard_selected {
3281 return xiaomi_mimo_env_var(STANDARD_ENV_VARS);
3282 }
3283
3284 let token_plan_selected = normalized_mode
3285 .as_deref()
3286 .and_then(xiaomi_mimo_base_url_for_mode)
3287 .is_some()
3288 || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan);
3289 if token_plan_selected {
3290 return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS);
3291 }
3292
3293 xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS))
3294}
3295
3296fn resolve_xiaomi_mimo_base_url(
3297 configured: Option<String>,
3298 api_key: Option<&str>,
3299 mode: Option<&str>,
3300) -> String {
3301 let normalized_mode =
3302 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
3303 let uses_standard_mode = normalized_mode
3304 .as_deref()
3305 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint);
3306 let mode_base_url = normalized_mode
3307 .as_deref()
3308 .and_then(xiaomi_mimo_base_url_for_mode);
3309 let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key);
3310 match configured {
3311 Some(base_url) if uses_standard_mode => base_url,
3312 Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => {
3313 mode_base_url
3314 .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL)
3315 .to_string()
3316 }
3317 Some(base_url) => base_url,
3318 None => {
3319 if let Some(base_url) = mode_base_url {
3320 base_url.to_string()
3321 } else if uses_standard_mode {
3322 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
3323 } else if uses_token_plan || api_key.is_none() {
3324 DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()
3325 } else {
3326 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
3327 }
3328 }
3329 }
3330}
3331
3332fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
3333 api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
3334}
3335
3336fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool {
3337 matches!(
3338 base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
3339 "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
3340 )
3341}
3342
3343#[must_use]
3349pub fn provider_base_url_is_official(provider: ProviderKind, base_url: &str) -> bool {
3350 let normalized = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
3351 match provider {
3352 ProviderKind::Deepseek => matches!(
3353 normalized.as_str(),
3354 "https://api.deepseek.com"
3355 | "https://api.deepseek.com/v1"
3356 | "https://api.deepseek.com/beta"
3357 ),
3358 ProviderKind::DeepseekAnthropic => matches!(
3359 normalized.as_str(),
3360 "https://api.deepseek.com/anthropic" | "https://api.deepseek.com/anthropic/v1"
3361 ),
3362 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => matches!(
3363 normalized.as_str(),
3364 "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
3365 ),
3366 ProviderKind::Moonshot => {
3367 normalized == DEFAULT_MOONSHOT_BASE_URL || moonshot_base_url_uses_kimi_code(base_url)
3368 }
3369 ProviderKind::XiaomiMimo => {
3370 xiaomi_mimo_base_url_uses_token_plan(base_url)
3371 || xiaomi_mimo_base_url_is_pay_as_you_go(base_url)
3372 }
3373 ProviderKind::Custom => false,
3376 _ => {
3377 normalized
3378 == default_base_url_for_provider(provider)
3379 .trim()
3380 .trim_end_matches('/')
3381 .to_ascii_lowercase()
3382 }
3383 }
3384}
3385
3386fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
3387 !provider_base_url_is_official(provider, base_url)
3388}
3389
3390#[must_use]
3397pub fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
3398 base_url_is_custom_for_provider(provider, base_url)
3399}
3400
3401fn should_skip_secret_store_for_provider(
3402 provider: ProviderKind,
3403 base_url: &str,
3404 auth_mode: Option<&str>,
3405) -> bool {
3406 if auth_mode_disables_api_key(auth_mode) {
3407 return true;
3408 }
3409 if base_url_is_custom_for_provider(provider, base_url) {
3410 return true;
3411 }
3412 if auth_mode_requires_api_key(auth_mode) {
3413 return false;
3414 }
3415
3416 matches!(
3417 provider,
3418 ProviderKind::Sglang | ProviderKind::Vllm | ProviderKind::Ollama
3419 ) || base_url_uses_local_host(base_url)
3420}
3421
3422fn env_api_key_for_provider(provider: ProviderKind) -> Option<String> {
3423 if provider == ProviderKind::Huggingface {
3424 return std::env::var("HUGGINGFACE_API_KEY")
3425 .ok()
3426 .filter(|value| !value.trim().is_empty())
3427 .or_else(|| {
3428 std::env::var("HF_TOKEN")
3429 .ok()
3430 .filter(|value| !value.trim().is_empty())
3431 });
3432 }
3433
3434 codewhale_secrets::env_for(provider.as_str())
3435}
3436
3437fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
3438 matches!(
3439 auth_mode
3440 .map(str::trim)
3441 .filter(|value| !value.is_empty())
3442 .map(|value| value.to_ascii_lowercase()),
3443 Some(value)
3444 if matches!(
3445 value.as_str(),
3446 "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
3447 )
3448 )
3449}
3450
3451#[must_use]
3453pub fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
3454 matches!(
3455 auth_mode
3456 .map(str::trim)
3457 .filter(|value| !value.is_empty())
3458 .map(|value| value.to_ascii_lowercase()),
3459 Some(value)
3460 if matches!(
3461 value.as_str(),
3462 "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
3463 )
3464 )
3465}
3466
3467fn auth_mode_uses_kimi_imported_token(auth_mode: &str) -> bool {
3468 matches!(
3469 auth_mode
3470 .trim()
3471 .to_ascii_lowercase()
3472 .replace('-', "_")
3473 .as_str(),
3474 "kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
3475 )
3476}
3477
3478fn base_url_uses_local_host(base_url: &str) -> bool {
3479 let Some(host) = base_url_host(base_url) else {
3480 return false;
3481 };
3482 let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
3483 if matches!(host.as_str(), "localhost" | "0.0.0.0") {
3484 return true;
3485 }
3486 host.parse::<std::net::IpAddr>()
3487 .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
3488}
3489
3490fn base_url_host(base_url: &str) -> Option<&str> {
3491 let without_scheme = base_url
3492 .split_once("://")
3493 .map_or(base_url, |(_, rest)| rest);
3494 let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
3495 if let Some(rest) = authority.strip_prefix('[') {
3496 return rest.split_once(']').map(|(host, _)| host);
3497 }
3498 authority.split(':').next().filter(|host| !host.is_empty())
3499}
3500
3501#[derive(Debug, Clone, Default)]
3502pub struct CliRuntimeOverrides {
3503 pub provider: Option<ProviderKind>,
3504 pub model: Option<String>,
3505 pub api_key: Option<String>,
3506 pub base_url: Option<String>,
3507 pub auth_mode: Option<String>,
3508 pub output_mode: Option<String>,
3509 pub log_level: Option<String>,
3510 pub telemetry: Option<bool>,
3511 pub approval_policy: Option<String>,
3512 pub sandbox_mode: Option<String>,
3513 pub yolo: Option<bool>,
3514 pub verbosity: Option<String>,
3515}
3516
3517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3518pub enum RuntimeApiKeySource {
3519 Cli,
3520 ConfigFile,
3521 Keyring,
3522 Env,
3523}
3524
3525impl RuntimeApiKeySource {
3526 #[must_use]
3527 pub fn as_env_value(self) -> &'static str {
3528 match self {
3529 Self::Cli => "cli",
3530 Self::ConfigFile => "config",
3531 Self::Keyring => "keyring",
3532 Self::Env => "env",
3533 }
3534 }
3535}
3536
3537#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3538pub enum ProviderSource {
3539 Cli,
3540 Env(&'static str),
3541 Config,
3542}
3543
3544#[derive(Debug, Clone)]
3545pub struct ResolvedRuntimeOptions {
3546 pub provider: ProviderKind,
3547 pub provider_source: ProviderSource,
3548 pub model: String,
3549 pub api_key: Option<String>,
3550 pub api_key_source: Option<RuntimeApiKeySource>,
3551 pub base_url: String,
3552 pub auth_mode: Option<String>,
3553 pub insecure_skip_tls_verify: bool,
3554 pub output_mode: Option<String>,
3555 pub log_level: Option<String>,
3556 pub telemetry: bool,
3557 pub approval_policy: Option<String>,
3558 pub sandbox_mode: Option<String>,
3559 pub yolo: Option<bool>,
3560 pub verbosity: Option<String>,
3561 pub http_headers: BTreeMap<String, String>,
3562}
3563
3564#[derive(Debug, Clone)]
3565pub struct ConfigStore {
3566 path: PathBuf,
3567 pub config: ConfigToml,
3568 permissions: PermissionsToml,
3569 original_raw: Option<String>,
3572}
3573
3574impl ConfigStore {
3575 pub fn load(path: Option<PathBuf>) -> Result<Self> {
3576 let path = resolve_config_path(path)?;
3577 let (config, original_raw) = if checked_path_exists(&path)? {
3578 let raw = read_checked_config_file(&path)?;
3579 let mut parsed: ConfigToml = toml::from_str(&raw).map_err(|_| {
3580 anyhow::anyhow!(
3581 "failed to parse config at {}; file contents were omitted",
3582 quote_os_path(&path)
3583 )
3584 })?;
3585 let raw_document: toml::Value = toml::from_str(&raw).map_err(|_| {
3586 anyhow::anyhow!(
3587 "failed to parse config at {}; file contents were omitted",
3588 quote_os_path(&path)
3589 )
3590 })?;
3591 if let Some(provider_id) = raw_document.get("provider").and_then(toml::Value::as_str) {
3592 parsed
3593 .bind_persisted_provider_id(provider_id)
3594 .with_context(|| {
3595 format!("failed to parse config at {}", quote_os_path(&path))
3596 })?;
3597 }
3598 (parsed, Some(raw))
3599 } else {
3600 (ConfigToml::default(), None)
3601 };
3602 let permissions = load_sibling_permissions(&path)?;
3603
3604 Ok(Self {
3605 path,
3606 config,
3607 permissions,
3608 original_raw,
3609 })
3610 }
3611
3612 pub fn rendered_body(&self) -> Result<String> {
3618 let mut serialized =
3619 toml::to_string_pretty(&self.config).context("failed to serialize config")?;
3620 if let Some(provider_id) = self.config.named_custom_provider_id() {
3621 let mut document = serialized
3622 .parse::<toml_edit::DocumentMut>()
3623 .context("failed to edit serialized config")?;
3624 document["provider"] = toml_edit::value(provider_id);
3625 serialized = document.to_string();
3626 }
3627 if let Some(ref original_raw) = self.original_raw {
3628 merge_and_preserve_comments(&serialized, original_raw).with_context(|| {
3629 format!(
3630 "cannot safely preserve config at {}; reload it and retry instead of replacing an unmergeable snapshot",
3631 quote_os_path(&self.path)
3632 )
3633 })
3634 } else {
3635 Ok(serialized)
3636 }
3637 }
3638
3639 pub fn save(&mut self) -> Result<()> {
3640 let path = normalize_config_file_path(self.path.clone())?;
3641 let body = self.rendered_body()?;
3642 replace_config_document_if_unchanged(&path, self.original_raw.as_deref(), &body)?;
3643 self.original_raw = Some(body);
3644 Ok(())
3645 }
3646
3647 pub fn reload(&mut self) -> Result<()> {
3651 *self = Self::load(Some(self.path.clone()))?;
3652 Ok(())
3653 }
3654
3655 #[must_use]
3656 pub fn path(&self) -> &Path {
3657 &self.path
3658 }
3659
3660 #[must_use]
3661 pub fn permissions(&self) -> &PermissionsToml {
3662 &self.permissions
3663 }
3664
3665 #[must_use]
3666 pub fn permissions_path(&self) -> PathBuf {
3667 checked_permissions_path_for_config_path(&self.path)
3668 .expect("ConfigStore path is validated before construction")
3669 }
3670
3671 #[must_use]
3672 pub fn exec_policy_engine(&self) -> ExecPolicyEngine {
3673 if self.permissions.is_empty() {
3674 ExecPolicyEngine::new(Vec::new(), Vec::new())
3675 } else {
3676 ExecPolicyEngine::with_rulesets(vec![self.permissions.ruleset()])
3677 }
3678 }
3679
3680 pub fn append_ask_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
3687 if rules.is_empty() {
3688 return Ok(0);
3689 }
3690
3691 let path = checked_permissions_path_for_config_path(&self.path)?;
3692 let raw = if checked_path_exists(&path)? {
3693 read_checked_permissions_file(&path)?
3694 } else {
3695 String::new()
3696 };
3697 let mut permissions = if raw.trim().is_empty() {
3698 PermissionsToml::default()
3699 } else {
3700 toml::from_str(&raw).map_err(|_| {
3701 anyhow::anyhow!(
3702 "failed to parse permissions at {}; file contents were omitted",
3703 quote_os_path(&path)
3704 )
3705 })?
3706 };
3707 let mut document = if raw.trim().is_empty() {
3708 toml_edit::DocumentMut::new()
3709 } else {
3710 raw.parse::<toml_edit::DocumentMut>().map_err(|_| {
3711 anyhow::anyhow!(
3712 "failed to edit permissions at {}; file contents were omitted",
3713 quote_os_path(&path)
3714 )
3715 })?
3716 };
3717
3718 if !document.contains_key("rules") {
3719 document["rules"] = toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
3720 }
3721 let rules_item = document
3722 .get_mut("rules")
3723 .expect("rules entry was inserted above");
3724
3725 let mut added = 0;
3726 for rule in rules {
3727 if permissions.rules.contains(rule) {
3728 continue;
3729 }
3730 append_ask_rule(rules_item, rule)?;
3731 permissions.rules.push(rule.clone());
3732 added += 1;
3733 }
3734 if added == 0 {
3735 self.permissions = permissions;
3736 return Ok(0);
3737 }
3738
3739 let body = document.to_string();
3740 let persisted: PermissionsToml = toml::from_str(&body).map_err(|_| {
3741 anyhow::anyhow!(
3742 "generated invalid permissions document for {}; file contents were omitted",
3743 quote_os_path(&path)
3744 )
3745 })?;
3746 write_permissions_atomic(&path, body.as_bytes())?;
3747 self.permissions = persisted;
3748 Ok(added)
3749 }
3750}
3751
3752fn config_backup_file_name(path: &Path) -> OsString {
3753 let mut file_name = path
3754 .file_name()
3755 .map(OsString::from)
3756 .unwrap_or_else(|| OsString::from(CONFIG_FILE_NAME));
3757 file_name.push(".bak");
3758 file_name
3759}
3760
3761fn config_sibling_path_unchecked(config_path: &Path, file_name: &OsStr) -> PathBuf {
3762 config_path
3763 .parent()
3764 .unwrap_or_else(|| Path::new("."))
3765 .join(file_name)
3766}
3767
3768fn checked_config_sibling_path(config_path: &Path, file_name: &OsStr) -> Result<PathBuf> {
3769 let config_path = normalize_config_file_path(config_path.to_path_buf())?;
3770 let parent = config_path
3771 .parent()
3772 .context("config path must include a parent directory")?;
3773 let path = parent.join(file_name);
3774 reject_path_symlink(&path)?;
3775 Ok(path)
3776}
3777
3778#[cfg(test)]
3779fn config_backup_path(path: &Path) -> PathBuf {
3780 config_sibling_path_unchecked(path, &config_backup_file_name(path))
3781}
3782
3783fn checked_config_backup_path(path: &Path) -> Result<PathBuf> {
3784 checked_config_sibling_path(path, &config_backup_file_name(path))
3785}
3786
3787pub fn scrub_plaintext_api_keys_from_config_backup(path: &Path) -> Result<()> {
3794 let backup = checked_config_backup_path(path)?;
3795 if !backup.exists() {
3796 return Ok(());
3797 }
3798
3799 let raw = read_checked_toml_file(&backup, "config backup")?;
3800 let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
3801 format!(
3802 "failed to scrub plaintext API keys from config backup {}",
3803 backup.display()
3804 )
3805 })?;
3806 if scrubbed != raw {
3807 persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
3808 format!(
3809 "failed to write credential-free config backup {}",
3810 backup.display()
3811 )
3812 })?;
3813 }
3814 Ok(())
3815}
3816
3817fn write_one_time_config_backup(path: &Path) -> Result<()> {
3818 let backup = checked_config_backup_path(path)?;
3819 if backup.exists() {
3820 return scrub_plaintext_api_keys_from_config_backup(path);
3821 }
3822
3823 let raw = read_checked_config_file(path)?;
3824 let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
3825 format!(
3826 "failed to scrub plaintext API keys while creating config backup {}",
3827 backup.display()
3828 )
3829 })?;
3830 persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
3831 format!(
3832 "failed to create credential-free config backup {} from {}",
3833 backup.display(),
3834 path.display()
3835 )
3836 })?;
3837 Ok(())
3838}
3839
3840fn config_toml_without_plaintext_api_keys(raw: &str) -> Result<String> {
3841 let mut document = raw
3842 .parse::<toml_edit::DocumentMut>()
3843 .map_err(|_| {
3844 anyhow::anyhow!(
3845 "failed to parse config TOML while removing plaintext API keys; file contents were omitted"
3846 )
3847 })?;
3848 remove_plaintext_api_keys_recursive(document.as_table_mut());
3849 Ok(document.to_string())
3850}
3851
3852fn remove_plaintext_api_keys_recursive(table: &mut dyn toml_edit::TableLike) {
3853 table.remove("api_key");
3854 for (_, item) in table.iter_mut() {
3855 if let toml_edit::Item::ArrayOfTables(tables) = item {
3856 for nested in tables.iter_mut() {
3857 remove_plaintext_api_keys_recursive(nested);
3858 }
3859 } else if let Some(nested) = item.as_table_like_mut() {
3860 remove_plaintext_api_keys_recursive(nested);
3861 }
3862 }
3863}
3864
3865pub fn merge_and_preserve_comments(serialized: &str, original_raw: &str) -> Result<String> {
3873 let original = original_raw
3874 .parse::<toml_edit::DocumentMut>()
3875 .map_err(|_| {
3876 anyhow::anyhow!(
3877 "failed to parse original config for comment merge; file contents were omitted"
3878 )
3879 })?;
3880
3881 let mut new_doc = serialized.parse::<toml_edit::DocumentMut>().map_err(|_| {
3882 anyhow::anyhow!(
3883 "failed to parse serialized config for comment merge; file contents were omitted"
3884 )
3885 })?;
3886
3887 new_doc.set_trailing(original.trailing().clone());
3890
3891 *new_doc.as_table_mut().decor_mut() = original.as_table().decor().clone();
3894
3895 merge_decor_table(new_doc.as_table_mut(), original.as_table());
3896
3897 Ok(new_doc.to_string())
3898}
3899
3900fn merge_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
3903 let keys: Vec<String> = source.iter().map(|(k, _)| k.to_owned()).collect();
3906 for key in &keys {
3907 let Some((source_key, source_item)) = source.get_key_value(key) else {
3908 continue;
3909 };
3910 let Some((mut target_key_mut, target_item)) = target.get_key_value_mut(key) else {
3911 continue;
3912 };
3913
3914 *target_key_mut.leaf_decor_mut() = source_key.leaf_decor().clone();
3916
3917 copy_item_decor(target_item, source_item);
3918
3919 if let (Some(tt), Some(st)) = (target_item.as_table_mut(), source_item.as_table()) {
3920 merge_decor_table(tt, st);
3921 }
3922
3923 if let (Some(ta), Some(sa)) = (
3924 target_item.as_array_of_tables_mut(),
3925 source_item.as_array_of_tables(),
3926 ) {
3927 for (i, source_table) in sa.iter().enumerate() {
3928 if let Some(target_table) = ta.get_mut(i) {
3929 copy_item_decor_table(target_table, source_table);
3930 merge_decor_table(target_table, source_table);
3931 }
3932 }
3933 }
3934 }
3935}
3936
3937fn copy_item_decor(target: &mut toml_edit::Item, source: &toml_edit::Item) {
3941 match (target, source) {
3942 (toml_edit::Item::Table(tt), toml_edit::Item::Table(st)) => {
3943 *tt.decor_mut() = st.decor().clone();
3944 }
3945 (toml_edit::Item::Value(tv), toml_edit::Item::Value(sv)) => {
3946 *tv.decor_mut() = sv.decor().clone();
3947 }
3948 _ => {}
3949 }
3950}
3951
3952fn copy_item_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
3953 *target.decor_mut() = source.decor().clone();
3954}
3955
3956pub fn default_secrets() -> &'static Secrets {
3961 static SECRETS: OnceLock<Secrets> = OnceLock::new();
3962 SECRETS.get_or_init(|| {
3963 #[cfg(test)]
3968 {
3969 Secrets::new(std::sync::Arc::new(
3970 codewhale_secrets::InMemoryKeyringStore::new(),
3971 ))
3972 }
3973 #[cfg(not(test))]
3974 {
3975 Secrets::auto_detect()
3976 }
3977 })
3978}
3979
3980pub const CODEWHALE_APP_DIR: &str = ".codewhale";
3989
3990pub const LEGACY_APP_DIR: &str = ".deepseek";
3992
3993pub fn codewhale_home() -> Result<PathBuf> {
3998 if let Some(path) = codewhale_home_env_override() {
3999 return Ok(path);
4000 }
4001 let home = effective_home_dir().context("failed to resolve home directory")?;
4002 Ok(home.join(CODEWHALE_APP_DIR))
4003}
4004
4005fn codewhale_home_env_override() -> Option<PathBuf> {
4006 let val = std::env::var("CODEWHALE_HOME").ok()?;
4007 let trimmed = val.trim();
4008 if trimmed.is_empty() {
4009 None
4010 } else {
4011 Some(PathBuf::from(trimmed))
4012 }
4013}
4014
4015pub fn codewhale_home_is_explicit() -> bool {
4020 codewhale_home_env_override().is_some()
4021}
4022
4023pub fn legacy_deepseek_home() -> Result<PathBuf> {
4027 let home = effective_home_dir().context("failed to resolve home directory")?;
4028 Ok(home.join(LEGACY_APP_DIR))
4029}
4030
4031fn effective_home_dir() -> Option<PathBuf> {
4032 std::env::var_os("HOME")
4033 .filter(|value| !value.is_empty())
4034 .map(PathBuf::from)
4035 .or_else(dirs::home_dir)
4036}
4037
4038fn ensure_safe_state_subdir(subdir: &str) -> Result<()> {
4046 if subdir.is_empty() {
4047 bail!("state subdir must not be empty");
4048 }
4049 let path = std::path::Path::new(subdir);
4050 if path.is_absolute() {
4051 bail!("state subdir must not be an absolute path: {subdir}");
4052 }
4053 if path.components().any(|c| {
4054 matches!(
4055 c,
4056 std::path::Component::RootDir | std::path::Component::Prefix(_)
4057 )
4058 }) {
4059 bail!("state subdir must not contain a root or prefix: {subdir}");
4060 }
4061 if path
4062 .components()
4063 .any(|c| matches!(c, std::path::Component::ParentDir))
4064 {
4065 bail!("state subdir must not contain parent-dir (..) components: {subdir}");
4066 }
4067 Ok(())
4068}
4069
4070pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
4077 ensure_safe_state_subdir(subdir)?;
4078 let explicit_codewhale_home = codewhale_home_env_override().is_some();
4079 let primary = codewhale_home()?.join(subdir);
4080 if explicit_codewhale_home || primary.exists() {
4081 return Ok(primary);
4082 }
4083 let legacy = legacy_deepseek_home()?.join(subdir);
4084 if legacy.exists() {
4085 return Ok(legacy);
4086 }
4087 Ok(primary)
4089}
4090
4091pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
4101 let (dir, migration) = ensure_state_dir_with_migration(subdir)?;
4102 if let Some(migration) = migration {
4103 eprintln!("{}", migration.user_notice());
4104 }
4105 Ok(dir)
4106}
4107
4108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4109pub enum StateMigrationKind {
4110 Relocated,
4111 Copied,
4112}
4113
4114#[derive(Debug, Clone, PartialEq, Eq)]
4115pub struct StateMigration {
4116 pub subdir: String,
4117 pub legacy_path: PathBuf,
4118 pub primary_path: PathBuf,
4119 pub kind: StateMigrationKind,
4120}
4121
4122impl StateMigration {
4123 pub fn user_notice(&self) -> String {
4124 let action = match self.kind {
4125 StateMigrationKind::Relocated => "relocated",
4126 StateMigrationKind::Copied => "copied",
4127 };
4128 let legacy_detail = match self.kind {
4129 StateMigrationKind::Relocated => {
4130 "The legacy .deepseek copy for this state path was removed by the move."
4131 }
4132 StateMigrationKind::Copied => {
4133 "The legacy .deepseek copy was left in place because a direct move failed."
4134 }
4135 };
4136
4137 format!(
4138 "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.",
4139 self.legacy_path.display(),
4140 self.primary_path.display(),
4141 )
4142 }
4143}
4144
4145pub fn ensure_state_dir_with_migration(subdir: &str) -> Result<(PathBuf, Option<StateMigration>)> {
4149 ensure_safe_state_subdir(subdir)?;
4150 let explicit_codewhale_home = codewhale_home_env_override().is_some();
4151 let dir = codewhale_home()?.join(subdir);
4152 let migration = if !explicit_codewhale_home {
4153 migrate_legacy_state_dir(&dir, subdir)?
4154 } else {
4155 None
4156 };
4157 std::fs::create_dir_all(&dir)
4158 .with_context(|| format!("failed to create {}/", dir.display()))?;
4159 Ok((dir, migration))
4160}
4161
4162fn migrate_legacy_state_dir(primary: &Path, subdir: &str) -> Result<Option<StateMigration>> {
4167 if primary.exists() || subdir == "." || subdir.is_empty() {
4168 return Ok(None);
4169 }
4170 let legacy = match legacy_deepseek_home() {
4171 Ok(home) => home.join(subdir),
4172 Err(_) => return Ok(None),
4173 };
4174 if !legacy.exists() {
4175 return Ok(None);
4176 }
4177 if let Some(parent) = primary.parent()
4179 && let Err(err) = std::fs::create_dir_all(parent)
4180 {
4181 tracing::warn!(
4182 target: "config::migration",
4183 "Could not create {} for state migration ({}); writing to primary anyway",
4184 parent.display(),
4185 err
4186 );
4187 }
4188 match std::fs::rename(&legacy, primary) {
4189 Ok(()) => {
4190 tracing::info!(
4191 target: "config::migration",
4192 "Migrated legacy state directory {} -> {} (relocated). The .deepseek copy was removed.",
4193 legacy.display(),
4194 primary.display()
4195 );
4196 return Ok(Some(StateMigration {
4197 subdir: subdir.to_string(),
4198 legacy_path: legacy,
4199 primary_path: primary.to_path_buf(),
4200 kind: StateMigrationKind::Relocated,
4201 }));
4202 }
4203 Err(err) => {
4204 match copy_dir_recursive(&legacy, primary) {
4209 Ok(()) => {
4210 tracing::info!(
4211 target: "config::migration",
4212 "Migrated legacy state directory {} -> {} (copied; rename failed: {err}). \
4213 The legacy .deepseek copy was left in place.",
4214 legacy.display(),
4215 primary.display()
4216 );
4217 return Ok(Some(StateMigration {
4218 subdir: subdir.to_string(),
4219 legacy_path: legacy,
4220 primary_path: primary.to_path_buf(),
4221 kind: StateMigrationKind::Copied,
4222 }));
4223 }
4224 Err(copy_err) => {
4225 tracing::warn!(
4226 target: "config::migration",
4227 "Could not migrate legacy state {} -> {} (rename: {err}; copy: {copy_err}). \
4228 New data is written to the primary path; the legacy tree remains untouched.",
4229 legacy.display(),
4230 primary.display()
4231 );
4232 }
4233 }
4234 }
4235 }
4236 Ok(None)
4237}
4238
4239fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
4242 std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
4243 for entry in
4244 std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
4245 {
4246 let entry = entry.with_context(|| format!("failed to read entry in {}", src.display()))?;
4247 let path = entry.path();
4248 let target = dst.join(entry.file_name());
4249 let file_type = entry
4250 .file_type()
4251 .with_context(|| format!("failed to read file type for {}", path.display()))?;
4252 if file_type.is_dir() {
4253 copy_dir_recursive(&path, &target)?;
4254 } else if file_type.is_file() {
4255 std::fs::copy(&path, &target).with_context(|| {
4256 format!("failed to copy {} -> {}", path.display(), target.display())
4257 })?;
4258 }
4259 }
4260 Ok(())
4261}
4262
4263pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> Result<(bool, PathBuf)> {
4270 ensure_safe_state_subdir(subdir)?;
4271 let workspace = normalize_project_workspace(workspace)?;
4272 let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
4273 if primary.exists() {
4274 return Ok((true, primary));
4275 }
4276 let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
4277 Ok((false, legacy))
4278}
4279
4280pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
4283 ensure_safe_state_subdir(subdir)?;
4284 let workspace = normalize_project_workspace(workspace)?;
4285 let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
4286 std::fs::create_dir_all(&dir)
4287 .with_context(|| format!("failed to create {}/", dir.display()))?;
4288 Ok(dir)
4289}
4290
4291pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
4292 if let Some(path) = explicit {
4293 return normalize_config_file_path(path);
4294 }
4295 if let Ok(path) = std::env::var("CODEWHALE_CONFIG_PATH") {
4296 if let Some(path) = config_path_from_env_value(&path)? {
4297 return Ok(path);
4298 }
4299 return default_config_path();
4300 }
4301 if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
4302 if let Some(path) = config_path_from_env_value(&path)? {
4303 return Ok(path);
4304 }
4305 return default_config_path();
4306 }
4307 default_config_path()
4308}
4309
4310fn config_path_from_env_value(path: &str) -> Result<Option<PathBuf>> {
4311 let trimmed = path.trim();
4312 if trimmed.is_empty() {
4313 Ok(None)
4314 } else {
4315 normalize_config_file_path(PathBuf::from(trimmed)).map(Some)
4316 }
4317}
4318
4319#[must_use]
4320pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
4321 config_sibling_path_unchecked(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
4322}
4323
4324fn checked_permissions_path_for_config_path(config_path: &Path) -> Result<PathBuf> {
4325 checked_config_sibling_path(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
4326}
4327
4328pub fn resolve_permissions_path(config_path: Option<PathBuf>) -> Result<PathBuf> {
4329 checked_permissions_path_for_config_path(&resolve_config_path(config_path)?)
4330}
4331
4332pub fn read_permissions_file(path: &Path) -> Result<String> {
4335 read_checked_permissions_file(path)
4336}
4337
4338fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
4339 let permissions_path = checked_permissions_path_for_config_path(config_path)?;
4340 if !checked_path_exists(&permissions_path)? {
4341 return Ok(PermissionsToml::default());
4342 }
4343
4344 let raw = read_checked_permissions_file(&permissions_path)?;
4345 toml::from_str(&raw).map_err(|_| {
4346 anyhow::anyhow!(
4347 "failed to parse permissions at {}; file contents were omitted",
4348 quote_os_path(&permissions_path)
4349 )
4350 })
4351}
4352
4353fn append_ask_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
4354 match item {
4355 toml_edit::Item::ArrayOfTables(rules) => {
4356 rules.push(ask_rule_table(rule));
4357 Ok(())
4358 }
4359 toml_edit::Item::Value(value) => {
4360 let Some(rules) = value.as_array_mut() else {
4361 bail!("`rules` in permissions.toml must be an array");
4362 };
4363 rules.push(toml_edit::Value::InlineTable(ask_rule_inline_table(rule)));
4364 Ok(())
4365 }
4366 _ => bail!("`rules` in permissions.toml must be an array"),
4367 }
4368}
4369
4370fn ask_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
4371 let mut table = toml_edit::Table::new();
4372 table["tool"] = toml_edit::value(rule.tool.clone());
4373 if let Some(command) = rule.command.as_deref() {
4374 table["command"] = toml_edit::value(command);
4375 }
4376 if let Some(path) = rule.path.as_deref() {
4377 table["path"] = toml_edit::value(path);
4378 }
4379 table
4380}
4381
4382fn ask_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
4383 let mut table = toml_edit::InlineTable::new();
4384 table.insert("tool", toml_edit::Value::from(rule.tool.clone()));
4385 if let Some(command) = rule.command.as_deref() {
4386 table.insert("command", toml_edit::Value::from(command));
4387 }
4388 if let Some(path) = rule.path.as_deref() {
4389 table.insert("path", toml_edit::Value::from(path));
4390 }
4391 table
4392}
4393
4394fn write_permissions_atomic(path: &Path, body: &[u8]) -> Result<()> {
4395 let parent = path.parent().with_context(|| {
4396 format!(
4397 "permissions path has no parent directory: {}",
4398 path.display()
4399 )
4400 })?;
4401 fs::create_dir_all(parent).with_context(|| {
4402 format!(
4403 "failed to create permissions directory {}",
4404 parent.display()
4405 )
4406 })?;
4407
4408 let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
4409 format!(
4410 "failed to create temporary permissions file in {}",
4411 parent.display()
4412 )
4413 })?;
4414 #[cfg(unix)]
4415 temporary
4416 .as_file()
4417 .set_permissions(fs::Permissions::from_mode(0o600))
4418 .with_context(|| {
4419 format!(
4420 "failed to secure temporary permissions file for {}",
4421 path.display()
4422 )
4423 })?;
4424 temporary
4425 .write_all(body)
4426 .with_context(|| format!("failed to write permissions at {}", path.display()))?;
4427 temporary
4428 .as_file()
4429 .sync_all()
4430 .with_context(|| format!("failed to sync permissions at {}", path.display()))?;
4431 temporary
4432 .persist(path)
4433 .map_err(|error| error.error)
4434 .with_context(|| format!("failed to replace permissions at {}", path.display()))?;
4435 Ok(())
4436}
4437
4438pub fn default_config_path() -> Result<PathBuf> {
4439 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
4442 if codewhale_home_is_explicit() || primary.exists() {
4443 return Ok(primary);
4444 }
4445 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
4446 if legacy.exists() {
4447 return Ok(legacy);
4448 }
4449 Ok(primary)
4451}
4452
4453#[derive(Debug, Clone, PartialEq, Eq)]
4454pub struct ConfigMigration {
4455 pub legacy_path: PathBuf,
4456 pub primary_path: PathBuf,
4457}
4458
4459impl ConfigMigration {
4460 pub fn user_notice(&self) -> String {
4461 format!(
4462 "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.",
4463 self.legacy_path.display(),
4464 self.primary_path.display()
4465 )
4466 }
4467}
4468
4469pub fn migrate_config_if_needed() -> Result<Option<ConfigMigration>> {
4474 if codewhale_home_is_explicit() {
4475 return Ok(None);
4476 }
4477 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
4478 if primary.exists() {
4479 return Ok(None);
4480 }
4481 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
4482 if !legacy.exists() {
4483 return Ok(None);
4484 }
4485 if let Some(parent) = primary.parent() {
4487 std::fs::create_dir_all(parent).context("failed to create codewhale config directory")?;
4488 }
4489 std::fs::copy(&legacy, &primary)
4490 .context("failed to migrate config from deepseek to codewhale home")?;
4491 tracing::info!(
4492 "Migrated config from {} to {}",
4493 legacy.display(),
4494 primary.display()
4495 );
4496 Ok(Some(ConfigMigration {
4497 legacy_path: legacy,
4498 primary_path: primary,
4499 }))
4500}
4501
4502fn parse_bool(raw: &str) -> Result<bool> {
4503 match raw.trim().to_ascii_lowercase().as_str() {
4504 "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
4505 "0" | "false" | "no" | "off" | "disabled" => Ok(false),
4506 _ => bail!("invalid boolean '{raw}'"),
4507 }
4508}
4509
4510fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
4511 let mut headers = BTreeMap::new();
4512 for pair in raw.trim().split(',') {
4513 let pair = pair.trim();
4514 if pair.is_empty() {
4515 continue;
4516 }
4517 let Some((name, value)) = pair.split_once('=') else {
4518 bail!("invalid header pair '{pair}', expected name=value");
4519 };
4520 let name = name.trim();
4521 let value = value.trim();
4522 if name.is_empty() {
4523 bail!("header name cannot be empty");
4524 }
4525 if value.is_empty() {
4526 continue;
4527 }
4528 headers.insert(name.to_string(), value.to_string());
4529 }
4530 Ok(headers)
4531}
4532
4533fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
4534 if headers.is_empty() {
4535 return None;
4536 }
4537 Some(
4538 headers
4539 .iter()
4540 .map(|(name, value)| format!("{name}={value}"))
4541 .collect::<Vec<_>>()
4542 .join(","),
4543 )
4544}
4545
4546fn serialize_http_headers_for_display(headers: &BTreeMap<String, String>) -> Option<String> {
4547 if headers.is_empty() {
4548 return None;
4549 }
4550 Some(
4551 headers
4552 .iter()
4553 .map(|(name, value)| {
4554 let display_value = if is_sensitive_config_key(name) {
4555 redact_secret(value)
4556 } else {
4557 value.clone()
4558 };
4559 format!("{name}={display_value}")
4560 })
4561 .collect::<Vec<_>>()
4562 .join(","),
4563 )
4564}
4565
4566fn redact_secret(secret: &str) -> String {
4567 let chars: Vec<char> = secret.chars().collect();
4568 if chars.len() <= 16 {
4569 return "********".to_string();
4570 }
4571 let prefix: String = chars.iter().take(4).collect();
4572 let suffix: String = chars
4573 .iter()
4574 .rev()
4575 .take(4)
4576 .collect::<Vec<_>>()
4577 .into_iter()
4578 .rev()
4579 .collect();
4580 format!("{prefix}***{suffix}")
4581}
4582
4583#[must_use]
4584pub fn is_sensitive_config_key(key: &str) -> bool {
4585 let Some(segment) = key.rsplit('.').next() else {
4586 return false;
4587 };
4588 let normalized = segment
4589 .trim()
4590 .trim_matches('"')
4591 .replace('-', "_")
4592 .to_ascii_lowercase();
4593
4594 matches!(
4595 normalized.as_str(),
4596 "api_key"
4597 | "apikey"
4598 | "api_keys"
4599 | "authorization"
4600 | "bearer"
4601 | "client_secret"
4602 | "credential"
4603 | "credentials"
4604 | "id_token"
4605 | "password"
4606 | "passwords"
4607 | "passwd"
4608 | "proxy_authorization"
4609 | "refresh_token"
4610 | "secret"
4611 | "secrets"
4612 | "token"
4613 | "tokens"
4614 ) || normalized.ends_with("_api_key")
4615 || normalized.ends_with("_authorization")
4616 || normalized.ends_with("_password")
4617 || normalized.ends_with("_secret")
4618 || normalized.ends_with("_token")
4619}
4620
4621fn redact_toml_value_for_display(key: &str, value: &toml::Value) -> String {
4622 redact_toml_value_for_display_inner(key, false, value).to_string()
4623}
4624
4625fn toml_value_as_u64(value: &toml::Value) -> Option<u64> {
4626 match value {
4627 toml::Value::Integer(value) => u64::try_from(*value).ok(),
4628 toml::Value::String(value) => value.trim().parse().ok(),
4629 _ => None,
4630 }
4631}
4632
4633fn redact_toml_value_for_display_inner(
4634 key: &str,
4635 sensitive_ancestor: bool,
4636 value: &toml::Value,
4637) -> toml::Value {
4638 let sensitive = sensitive_ancestor || is_sensitive_config_key(key);
4639 match value {
4640 toml::Value::String(value) if sensitive => toml::Value::String(redact_secret(value)),
4641 toml::Value::Array(values) => toml::Value::Array(
4642 values
4643 .iter()
4644 .map(|value| redact_toml_value_for_display_inner(key, sensitive, value))
4645 .collect(),
4646 ),
4647 toml::Value::Table(table) => {
4648 let mut redacted = toml::map::Map::new();
4649 for (child_key, child_value) in table {
4650 let path = if key.is_empty() {
4651 child_key.clone()
4652 } else {
4653 format!("{key}.{child_key}")
4654 };
4655 redacted.insert(
4656 child_key.clone(),
4657 redact_toml_value_for_display_inner(&path, sensitive, child_value),
4658 );
4659 }
4660 toml::Value::Table(redacted)
4661 }
4662 _ if sensitive => toml::Value::String("********".to_string()),
4663 _ => value.clone(),
4664 }
4665}
4666
4667fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
4668 if path.as_os_str().is_empty() {
4669 bail!("config path cannot be empty");
4670 }
4671 if path
4672 .components()
4673 .any(|component| matches!(component, Component::ParentDir))
4674 {
4675 bail!("config path cannot contain '..' components");
4676 }
4677 if path.file_name().is_none() {
4678 bail!("config path must include a file name");
4679 }
4680 let absolute = if path.is_absolute() {
4681 path
4682 } else {
4683 std::env::current_dir()
4684 .context("failed to resolve current directory for config path")?
4685 .join(path)
4686 };
4687 let file_name = absolute
4688 .file_name()
4689 .map(OsString::from)
4690 .context("config path must include a file name")?;
4691 let parent = absolute
4692 .parent()
4693 .context("config path must include a parent directory")?;
4694 let parent = match parent.canonicalize() {
4695 Ok(parent) => parent,
4696 Err(err) if err.kind() == std::io::ErrorKind::NotFound => parent.to_path_buf(),
4697 Err(err) => {
4698 return Err(err).with_context(|| {
4699 format!("failed to resolve config directory {}", parent.display())
4700 });
4701 }
4702 };
4703 let normalized = parent.join(file_name);
4704 reject_path_symlink(&normalized)?;
4705 Ok(normalized)
4706}
4707
4708fn normalize_project_workspace(workspace: &Path) -> Result<PathBuf> {
4709 if workspace.as_os_str().is_empty() {
4710 bail!("project workspace path cannot be empty");
4711 }
4712 if workspace
4713 .components()
4714 .any(|component| matches!(component, Component::ParentDir))
4715 {
4716 bail!("project workspace path cannot contain '..' components");
4717 }
4718 let absolute = if workspace.is_absolute() {
4719 workspace.to_path_buf()
4720 } else {
4721 std::env::current_dir()
4722 .context("failed to resolve current directory for project workspace")?
4723 .join(workspace)
4724 };
4725 match absolute.canonicalize() {
4726 Ok(path) => Ok(path),
4727 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
4728 Ok(normalize_path_components(&absolute))
4729 }
4730 Err(err) => Err(err).with_context(|| {
4731 format!(
4732 "failed to resolve project workspace {}",
4733 workspace.display()
4734 )
4735 }),
4736 }
4737}
4738
4739fn normalize_path_components(path: &Path) -> PathBuf {
4740 let mut normalized = PathBuf::new();
4741 for component in path.components() {
4742 match component {
4743 Component::Prefix(_) | Component::RootDir => normalized.push(component.as_os_str()),
4744 Component::CurDir => {}
4745 Component::ParentDir => {
4746 normalized.pop();
4747 }
4748 Component::Normal(part) => normalized.push(part),
4749 }
4750 }
4751 if normalized.as_os_str().is_empty() {
4752 PathBuf::from(".")
4753 } else {
4754 normalized
4755 }
4756}
4757
4758fn checked_path_exists(path: &Path) -> Result<bool> {
4759 let path = normalize_config_file_path(path.to_path_buf())?;
4760 path.try_exists()
4761 .with_context(|| format!("failed to inspect config path {}", path.display()))
4762}
4763
4764fn read_checked_config_file(path: &Path) -> Result<String> {
4765 read_checked_toml_file(path, "config")
4766}
4767
4768fn read_checked_permissions_file(path: &Path) -> Result<String> {
4769 read_checked_toml_file(path, "permissions")
4770}
4771
4772fn read_checked_toml_file(path: &Path, label: &str) -> Result<String> {
4773 let path = normalize_config_file_path(path.to_path_buf())?;
4774 read_string_no_follow(&path)
4775 .with_context(|| format!("failed to read {label} at {}", path.display()))
4776}
4777
4778#[cfg(unix)]
4779fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
4780 let mut file = fs::OpenOptions::new()
4781 .read(true)
4782 .custom_flags(libc::O_NOFOLLOW)
4783 .open(path)?;
4784 let mut raw = String::new();
4785 file.read_to_string(&mut raw)?;
4786 Ok(raw)
4787}
4788
4789#[cfg(not(unix))]
4790fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
4791 fs::read_to_string(path)
4792}
4793
4794fn reject_path_symlink(path: &Path) -> Result<()> {
4795 let Ok(metadata) = fs::symlink_metadata(path) else {
4796 return Ok(());
4797 };
4798 if metadata.file_type().is_symlink() {
4799 bail!("config path must not be a symlink: {}", path.display());
4800 }
4801 Ok(())
4802}
4803
4804#[derive(Debug, Clone, Default)]
4805struct EnvRuntimeOverrides {
4806 provider: Option<ProviderKind>,
4807 provider_source: Option<&'static str>,
4808 model: Option<String>,
4809 volcengine_model: Option<String>,
4810 wanjie_ark_model: Option<String>,
4811 openrouter_model: Option<String>,
4812 moonshot_model: Option<String>,
4813 xiaomi_mimo_model: Option<String>,
4814 xiaomi_mimo_mode: Option<String>,
4815 novita_model: Option<String>,
4816 fireworks_model: Option<String>,
4817 arcee_model: Option<String>,
4818 output_mode: Option<String>,
4819 auth_mode: Option<String>,
4820 log_level: Option<String>,
4821 telemetry: Option<bool>,
4822 approval_policy: Option<String>,
4823 sandbox_mode: Option<String>,
4824 yolo: Option<bool>,
4825 verbosity: Option<String>,
4826 http_headers: Option<BTreeMap<String, String>>,
4827 deepseek_base_url: Option<String>,
4828 deepseek_anthropic_base_url: Option<String>,
4829 nvidia_base_url: Option<String>,
4830 openai_base_url: Option<String>,
4831 atlascloud_base_url: Option<String>,
4832 volcengine_base_url: Option<String>,
4833 wanjie_ark_base_url: Option<String>,
4834 openrouter_base_url: Option<String>,
4835 xiaomi_mimo_base_url: Option<String>,
4836 novita_base_url: Option<String>,
4837 fireworks_base_url: Option<String>,
4838 siliconflow_base_url: Option<String>,
4839 siliconflow_model: Option<String>,
4840 arcee_base_url: Option<String>,
4841 moonshot_base_url: Option<String>,
4842 sglang_base_url: Option<String>,
4843 vllm_base_url: Option<String>,
4844 ollama_base_url: Option<String>,
4845 huggingface_base_url: Option<String>,
4846 huggingface_model: Option<String>,
4847 together_base_url: Option<String>,
4848 together_model: Option<String>,
4849 qianfan_base_url: Option<String>,
4850 qianfan_model: Option<String>,
4851 openai_codex_base_url: Option<String>,
4852 openai_codex_model: Option<String>,
4853 anthropic_base_url: Option<String>,
4854 anthropic_model: Option<String>,
4855 openmodel_base_url: Option<String>,
4856 openmodel_model: Option<String>,
4857 zai_base_url: Option<String>,
4858 zai_model: Option<String>,
4859 stepfun_base_url: Option<String>,
4860 stepfun_model: Option<String>,
4861 minimax_base_url: Option<String>,
4862 minimax_anthropic_base_url: Option<String>,
4863 minimax_model: Option<String>,
4864 deepinfra_base_url: Option<String>,
4865 deepinfra_model: Option<String>,
4866 sakana_base_url: Option<String>,
4867 sakana_model: Option<String>,
4868 longcat_base_url: Option<String>,
4869 longcat_model: Option<String>,
4870 opencode_go_base_url: Option<String>,
4871 opencode_go_model: Option<String>,
4872 meta_base_url: Option<String>,
4873 meta_model: Option<String>,
4874 xai_base_url: Option<String>,
4875 xai_model: Option<String>,
4876 telecomjs_base_url: Option<String>,
4877 telecomjs_model: Option<String>,
4878}
4879
4880impl EnvRuntimeOverrides {
4881 fn load() -> Self {
4882 let (provider, provider_source) = Self::load_provider();
4883 Self {
4884 provider,
4885 provider_source,
4886 model: std::env::var("CODEWHALE_MODEL")
4887 .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
4888 .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
4889 .ok()
4890 .filter(|v| !v.trim().is_empty()),
4891 volcengine_model: std::env::var("VOLCENGINE_MODEL")
4892 .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
4893 .ok()
4894 .filter(|v| !v.trim().is_empty()),
4895 wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
4896 .or_else(|_| std::env::var("WANJIE_MODEL"))
4897 .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
4898 .ok()
4899 .filter(|v| !v.trim().is_empty()),
4900 openrouter_model: std::env::var("OPENROUTER_MODEL")
4901 .ok()
4902 .filter(|v| !v.trim().is_empty()),
4903 moonshot_model: std::env::var("MOONSHOT_MODEL")
4904 .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
4905 .or_else(|_| std::env::var("KIMI_MODEL"))
4906 .ok()
4907 .filter(|v| !v.trim().is_empty()),
4908 xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
4909 .or_else(|_| std::env::var("MIMO_MODEL"))
4910 .ok()
4911 .filter(|v| !v.trim().is_empty()),
4912 xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
4913 .or_else(|_| std::env::var("MIMO_MODE"))
4914 .ok()
4915 .filter(|v| !v.trim().is_empty()),
4916 novita_model: std::env::var("NOVITA_MODEL")
4917 .ok()
4918 .filter(|v| !v.trim().is_empty()),
4919 fireworks_model: std::env::var("FIREWORKS_MODEL")
4920 .ok()
4921 .filter(|v| !v.trim().is_empty()),
4922 arcee_model: std::env::var("ARCEE_MODEL")
4923 .ok()
4924 .filter(|v| !v.trim().is_empty()),
4925 verbosity: std::env::var("CODEWHALE_VERBOSITY")
4926 .or_else(|_| std::env::var("DEEPSEEK_VERBOSITY"))
4927 .ok(),
4928 output_mode: std::env::var("CODEWHALE_OUTPUT_MODE")
4929 .or_else(|_| std::env::var("DEEPSEEK_OUTPUT_MODE"))
4930 .ok(),
4931 auth_mode: std::env::var("CODEWHALE_AUTH_MODE")
4932 .or_else(|_| std::env::var("DEEPSEEK_AUTH_MODE"))
4933 .ok(),
4934 log_level: std::env::var("CODEWHALE_LOG_LEVEL")
4935 .or_else(|_| std::env::var("DEEPSEEK_LOG_LEVEL"))
4936 .ok(),
4937 telemetry: std::env::var("CODEWHALE_TELEMETRY")
4938 .or_else(|_| std::env::var("DEEPSEEK_TELEMETRY"))
4939 .ok()
4940 .and_then(|v| match parse_bool(&v) {
4941 Ok(b) => Some(b),
4942 Err(_) => {
4943 tracing::warn!("Invalid CODEWHALE_TELEMETRY/DEEPSEEK_TELEMETRY value '{v}', expected true/false");
4944 None
4945 }
4946 }),
4947 approval_policy: std::env::var("CODEWHALE_APPROVAL_POLICY")
4948 .or_else(|_| std::env::var("DEEPSEEK_APPROVAL_POLICY"))
4949 .ok(),
4950 sandbox_mode: std::env::var("CODEWHALE_SANDBOX_MODE")
4951 .or_else(|_| std::env::var("DEEPSEEK_SANDBOX_MODE"))
4952 .ok(),
4953 yolo: std::env::var("CODEWHALE_YOLO")
4954 .or_else(|_| std::env::var("DEEPSEEK_YOLO"))
4955 .ok()
4956 .and_then(|v| match parse_bool(&v) {
4957 Ok(b) => Some(b),
4958 Err(_) => {
4959 tracing::warn!("Invalid CODEWHALE_YOLO/DEEPSEEK_YOLO value '{v}', expected true/false");
4960 None
4961 }
4962 }),
4963 http_headers: std::env::var("CODEWHALE_HTTP_HEADERS")
4964 .or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
4965 .ok()
4966 .and_then(|value| match parse_http_headers(&value) {
4967 Ok(h) => Some(h),
4968 Err(_) => {
4969 tracing::warn!("Invalid CODEWHALE_HTTP_HEADERS/DEEPSEEK_HTTP_HEADERS value, expected format: header1=val1,header2=val2");
4970 None
4971 }
4972 })
4973 .filter(|headers| !headers.is_empty()),
4974 deepseek_base_url: std::env::var("CODEWHALE_BASE_URL")
4975 .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
4976 .ok()
4977 .filter(|v| !v.trim().is_empty()),
4978 deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
4979 .or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
4980 .ok()
4981 .filter(|v| !v.trim().is_empty()),
4982 nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
4983 .or_else(|_| std::env::var("NIM_BASE_URL"))
4984 .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
4985 .ok()
4986 .filter(|v| !v.trim().is_empty()),
4987 openai_base_url: std::env::var("OPENAI_BASE_URL")
4988 .ok()
4989 .filter(|v| !v.trim().is_empty()),
4990 atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
4991 .ok()
4992 .filter(|v| !v.trim().is_empty()),
4993 volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
4994 .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
4995 .or_else(|_| std::env::var("ARK_BASE_URL"))
4996 .ok()
4997 .filter(|v| !v.trim().is_empty()),
4998 wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
4999 .or_else(|_| std::env::var("WANJIE_BASE_URL"))
5000 .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
5001 .ok()
5002 .filter(|v| !v.trim().is_empty()),
5003 openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
5004 .ok()
5005 .filter(|v| !v.trim().is_empty()),
5006 xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
5007 .or_else(|_| std::env::var("MIMO_BASE_URL"))
5008 .ok()
5009 .filter(|v| !v.trim().is_empty()),
5010 novita_base_url: std::env::var("NOVITA_BASE_URL")
5011 .ok()
5012 .filter(|v| !v.trim().is_empty()),
5013 fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
5014 .ok()
5015 .filter(|v| !v.trim().is_empty()),
5016 siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
5017 .ok()
5018 .filter(|v| !v.trim().is_empty()),
5019 siliconflow_model: std::env::var("SILICONFLOW_MODEL")
5020 .ok()
5021 .filter(|v| !v.trim().is_empty()),
5022 arcee_base_url: std::env::var("ARCEE_BASE_URL")
5023 .ok()
5024 .filter(|v| !v.trim().is_empty()),
5025 moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
5026 .or_else(|_| std::env::var("KIMI_BASE_URL"))
5027 .ok()
5028 .filter(|v| !v.trim().is_empty()),
5029 sglang_base_url: std::env::var("SGLANG_BASE_URL")
5030 .ok()
5031 .filter(|v| !v.trim().is_empty()),
5032 vllm_base_url: std::env::var("VLLM_BASE_URL")
5033 .ok()
5034 .filter(|v| !v.trim().is_empty()),
5035 ollama_base_url: std::env::var("OLLAMA_BASE_URL")
5036 .ok()
5037 .filter(|v| !v.trim().is_empty()),
5038 huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
5039 .or_else(|_| std::env::var("HF_BASE_URL"))
5040 .ok()
5041 .filter(|v| !v.trim().is_empty()),
5042 huggingface_model: std::env::var("HUGGINGFACE_MODEL")
5043 .or_else(|_| std::env::var("HF_MODEL"))
5044 .ok()
5045 .filter(|v| !v.trim().is_empty()),
5046 together_base_url: std::env::var("TOGETHER_BASE_URL")
5047 .ok()
5048 .filter(|v| !v.trim().is_empty()),
5049 together_model: std::env::var("TOGETHER_MODEL")
5050 .ok()
5051 .filter(|v| !v.trim().is_empty()),
5052 qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
5053 .ok()
5054 .filter(|v| !v.trim().is_empty())
5055 .or_else(|| {
5056 std::env::var("BAIDU_QIANFAN_BASE_URL")
5057 .ok()
5058 .filter(|v| !v.trim().is_empty())
5059 }),
5060 qianfan_model: std::env::var("QIANFAN_MODEL")
5061 .ok()
5062 .filter(|v| !v.trim().is_empty())
5063 .or_else(|| {
5064 std::env::var("BAIDU_QIANFAN_MODEL")
5065 .ok()
5066 .filter(|v| !v.trim().is_empty())
5067 }),
5068 openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
5069 .or_else(|_| std::env::var("CODEX_BASE_URL"))
5070 .ok()
5071 .filter(|v| !v.trim().is_empty()),
5072 openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
5073 .or_else(|_| std::env::var("CODEX_MODEL"))
5074 .ok()
5075 .filter(|v| !v.trim().is_empty()),
5076 anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
5077 .ok()
5078 .filter(|v| !v.trim().is_empty()),
5079 anthropic_model: std::env::var("ANTHROPIC_MODEL")
5080 .ok()
5081 .filter(|v| !v.trim().is_empty()),
5082 openmodel_base_url: std::env::var("OPENMODEL_BASE_URL")
5083 .ok()
5084 .filter(|v| !v.trim().is_empty()),
5085 openmodel_model: std::env::var("OPENMODEL_MODEL")
5086 .ok()
5087 .filter(|v| !v.trim().is_empty()),
5088 zai_base_url: std::env::var("ZAI_BASE_URL")
5089 .or_else(|_| std::env::var("Z_AI_BASE_URL"))
5090 .or_else(|_| std::env::var("ZHIPU_BASE_URL"))
5091 .or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
5092 .or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
5093 .ok()
5094 .filter(|v| !v.trim().is_empty()),
5095 zai_model: std::env::var("ZAI_MODEL")
5096 .or_else(|_| std::env::var("Z_AI_MODEL"))
5097 .or_else(|_| std::env::var("ZHIPU_MODEL"))
5098 .or_else(|_| std::env::var("ZHIPUAI_MODEL"))
5099 .or_else(|_| std::env::var("BIGMODEL_MODEL"))
5100 .or_else(|_| std::env::var("GLM_MODEL"))
5101 .ok()
5102 .filter(|v| !v.trim().is_empty()),
5103 stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
5104 .or_else(|_| std::env::var("STEP_BASE_URL"))
5105 .ok()
5106 .filter(|v| !v.trim().is_empty()),
5107 stepfun_model: std::env::var("STEPFUN_MODEL")
5108 .or_else(|_| std::env::var("STEP_MODEL"))
5109 .ok()
5110 .filter(|v| !v.trim().is_empty()),
5111 minimax_base_url: std::env::var("MINIMAX_BASE_URL")
5112 .ok()
5113 .filter(|v| !v.trim().is_empty()),
5114 minimax_anthropic_base_url: std::env::var("MINIMAX_ANTHROPIC_BASE_URL")
5115 .ok()
5116 .filter(|v| !v.trim().is_empty()),
5117 minimax_model: std::env::var("MINIMAX_MODEL")
5118 .ok()
5119 .filter(|v| !v.trim().is_empty()),
5120 deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
5121 .ok()
5122 .filter(|v| !v.trim().is_empty()),
5123 deepinfra_model: std::env::var("DEEPINFRA_MODEL")
5124 .ok()
5125 .filter(|v| !v.trim().is_empty()),
5126 sakana_base_url: std::env::var("SAKANA_BASE_URL")
5127 .ok()
5128 .filter(|v| !v.trim().is_empty()),
5129 sakana_model: std::env::var("SAKANA_MODEL")
5130 .ok()
5131 .filter(|v| !v.trim().is_empty()),
5132 longcat_base_url: std::env::var("LONGCAT_BASE_URL")
5133 .ok()
5134 .filter(|v| !v.trim().is_empty()),
5135 longcat_model: std::env::var("LONGCAT_MODEL")
5136 .ok()
5137 .filter(|v| !v.trim().is_empty()),
5138 opencode_go_base_url: std::env::var("OPENCODE_GO_BASE_URL")
5139 .ok()
5140 .filter(|v| !v.trim().is_empty()),
5141 opencode_go_model: std::env::var("OPENCODE_GO_MODEL")
5142 .ok()
5143 .filter(|v| !v.trim().is_empty()),
5144 meta_base_url: std::env::var("META_MODEL_API_BASE_URL")
5145 .ok()
5146 .filter(|v| !v.trim().is_empty())
5147 .or_else(|| {
5148 std::env::var("MODEL_API_BASE_URL")
5149 .ok()
5150 .filter(|v| !v.trim().is_empty())
5151 }),
5152 meta_model: std::env::var("META_MODEL_API_MODEL")
5153 .ok()
5154 .filter(|v| !v.trim().is_empty())
5155 .or_else(|| {
5156 std::env::var("MODEL_API_MODEL")
5157 .ok()
5158 .filter(|v| !v.trim().is_empty())
5159 }),
5160 xai_base_url: std::env::var("XAI_BASE_URL")
5161 .ok()
5162 .filter(|v| !v.trim().is_empty()),
5163 xai_model: std::env::var("XAI_MODEL")
5164 .ok()
5165 .filter(|v| !v.trim().is_empty()),
5166 telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL")
5167 .ok()
5168 .filter(|v| !v.trim().is_empty()),
5169 telecomjs_model: std::env::var("TELECOMJS_MODEL")
5170 .ok()
5171 .filter(|v| !v.trim().is_empty()),
5172 }
5173 }
5174
5175 fn load_provider() -> (Option<ProviderKind>, Option<&'static str>) {
5176 if let Ok(value) = std::env::var("CODEWHALE_PROVIDER") {
5177 let parsed = ProviderKind::parse(&value);
5178 return (parsed, parsed.map(|_| "CODEWHALE_PROVIDER"));
5179 }
5180
5181 if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
5182 let parsed = ProviderKind::parse(&value);
5183 return (parsed, parsed.map(|_| "DEEPSEEK_PROVIDER"));
5184 }
5185
5186 (None, None)
5187 }
5188
5189 fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
5190 match provider {
5193 ProviderKind::Deepseek => self.deepseek_base_url.clone(),
5194 ProviderKind::DeepseekAnthropic => self.deepseek_anthropic_base_url.clone(),
5195 ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
5196 ProviderKind::Openai => self.openai_base_url.clone(),
5197 ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
5198 ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
5199 ProviderKind::Volcengine => self.volcengine_base_url.clone(),
5200 ProviderKind::Openrouter => self.openrouter_base_url.clone(),
5201 ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
5202 ProviderKind::Novita => self.novita_base_url.clone(),
5203 ProviderKind::Fireworks => self.fireworks_base_url.clone(),
5204 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
5205 self.siliconflow_base_url.clone()
5206 }
5207 ProviderKind::Arcee => self.arcee_base_url.clone(),
5208 ProviderKind::Moonshot => self.moonshot_base_url.clone(),
5209 ProviderKind::Sglang => self.sglang_base_url.clone(),
5210 ProviderKind::Vllm => self.vllm_base_url.clone(),
5211 ProviderKind::Ollama => self.ollama_base_url.clone(),
5212 ProviderKind::Huggingface => self.huggingface_base_url.clone(),
5213 ProviderKind::Together => self.together_base_url.clone(),
5214 ProviderKind::Qianfan => self.qianfan_base_url.clone(),
5215 ProviderKind::OpenaiCodex => self.openai_codex_base_url.clone(),
5216 ProviderKind::Anthropic => self.anthropic_base_url.clone(),
5217 ProviderKind::Openmodel => self.openmodel_base_url.clone(),
5218 ProviderKind::Zai => self.zai_base_url.clone(),
5219 ProviderKind::Stepfun => self.stepfun_base_url.clone(),
5220 ProviderKind::Minimax => self.minimax_base_url.clone(),
5221 ProviderKind::MinimaxAnthropic => self.minimax_anthropic_base_url.clone(),
5222 ProviderKind::Deepinfra => self.deepinfra_base_url.clone(),
5223 ProviderKind::Sakana => self.sakana_base_url.clone(),
5224 ProviderKind::LongCat => self.longcat_base_url.clone(),
5225 ProviderKind::OpencodeGo => self.opencode_go_base_url.clone(),
5226 ProviderKind::Meta => self.meta_base_url.clone(),
5227 ProviderKind::Xai => self.xai_base_url.clone(),
5228 ProviderKind::Telecomjs => self.telecomjs_base_url.clone(),
5229 ProviderKind::Custom => None,
5232 }
5233 }
5234
5235 fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
5236 let model = match provider {
5237 ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
5238 ProviderKind::Volcengine => self.volcengine_model.clone(),
5239 ProviderKind::Openrouter => self.openrouter_model.clone(),
5240 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
5241 self.siliconflow_model.clone()
5242 }
5243 ProviderKind::Arcee => self.arcee_model.clone(),
5244 ProviderKind::Moonshot => self.moonshot_model.clone(),
5245 ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
5246 ProviderKind::Novita => self.novita_model.clone(),
5247 ProviderKind::Fireworks => self.fireworks_model.clone(),
5248 ProviderKind::Huggingface => self.huggingface_model.clone(),
5249 ProviderKind::Together => self.together_model.clone(),
5250 ProviderKind::Qianfan => self.qianfan_model.clone(),
5251 ProviderKind::OpenaiCodex => self.openai_codex_model.clone(),
5252 ProviderKind::Anthropic => self.anthropic_model.clone(),
5253 ProviderKind::Openmodel => self.openmodel_model.clone(),
5254 ProviderKind::Zai => self.zai_model.clone(),
5255 ProviderKind::Stepfun => self.stepfun_model.clone(),
5256 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => self.minimax_model.clone(),
5257 ProviderKind::Deepinfra => self.deepinfra_model.clone(),
5258 ProviderKind::Sakana => self.sakana_model.clone(),
5259 ProviderKind::LongCat => self.longcat_model.clone(),
5260 ProviderKind::OpencodeGo => self.opencode_go_model.clone(),
5261 ProviderKind::Meta => self.meta_model.clone(),
5262 ProviderKind::Xai => self.xai_model.clone(),
5263 ProviderKind::Telecomjs => self.telecomjs_model.clone(),
5264 _ => None,
5265 }?;
5266
5267 if provider_preserves_custom_base_url_model(provider, base_url) {
5268 Some(model.trim().to_string())
5269 } else {
5270 Some(normalize_model_for_provider(provider, &model))
5271 }
5272 }
5273}
5274
5275#[cfg(test)]
5276mod tests;