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