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
53#[derive(Debug, Clone, Serialize, Deserialize, Default)]
54pub struct ProviderConfigToml {
55 pub api_key: Option<String>,
56 pub base_url: Option<String>,
57 pub model: Option<String>,
58 #[serde(
59 default,
60 alias = "contextWindow",
61 alias = "context_window_tokens",
62 alias = "contextWindowTokens",
63 alias = "context_length",
64 alias = "contextLength"
65 )]
66 pub context_window: Option<u32>,
67 pub mode: Option<String>,
68 pub auth_mode: Option<String>,
69 pub insecure_skip_tls_verify: Option<bool>,
70 #[serde(default)]
71 pub http_headers: BTreeMap<String, String>,
72 pub path_suffix: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub auth: Option<ProviderAuthSourceToml>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, Default)]
78pub struct ProvidersToml {
79 #[serde(default)]
80 pub deepseek: ProviderConfigToml,
81 #[serde(
82 default,
83 alias = "deepseek-anthropic",
84 alias = "deepseekAnthropic",
85 alias = "deepseek-claude",
86 alias = "deepseek_claude"
87 )]
88 pub deepseek_anthropic: ProviderConfigToml,
89 #[serde(default)]
90 pub nvidia_nim: ProviderConfigToml,
91 #[serde(default)]
92 pub openai: ProviderConfigToml,
93 #[serde(default)]
94 pub atlascloud: ProviderConfigToml,
95 #[serde(default)]
96 pub wanjie_ark: ProviderConfigToml,
97 #[serde(default)]
98 pub volcengine: ProviderConfigToml,
99 #[serde(default)]
100 pub openrouter: ProviderConfigToml,
101 #[serde(default, alias = "xiaomi", alias = "mimo", alias = "xiaomimimo")]
102 pub xiaomi_mimo: ProviderConfigToml,
103 #[serde(default)]
104 pub novita: ProviderConfigToml,
105 #[serde(default)]
106 pub fireworks: ProviderConfigToml,
107 #[serde(default)]
108 pub siliconflow: ProviderConfigToml,
109 #[serde(default, alias = "siliconflow-CN", alias = "siliconflow-cn")]
110 pub siliconflow_cn: ProviderConfigToml,
111 #[serde(default)]
112 pub arcee: ProviderConfigToml,
113 #[serde(default)]
114 pub moonshot: ProviderConfigToml,
115 #[serde(default)]
116 pub sglang: ProviderConfigToml,
117 #[serde(default)]
118 pub vllm: ProviderConfigToml,
119 #[serde(default)]
120 pub ollama: ProviderConfigToml,
121 #[serde(default)]
122 pub huggingface: ProviderConfigToml,
123 #[serde(default)]
124 pub together: ProviderConfigToml,
125 #[serde(
126 default,
127 alias = "baidu-qianfan",
128 alias = "baidu_qianfan",
129 alias = "baidu"
130 )]
131 pub qianfan: ProviderConfigToml,
132 #[serde(
133 default,
134 alias = "openai-codex",
135 alias = "openai_codex",
136 alias = "codex",
137 alias = "chatgpt",
138 alias = "chatgpt-codex"
139 )]
140 pub openai_codex: ProviderConfigToml,
141 #[serde(default)]
142 pub anthropic: ProviderConfigToml,
143 #[serde(default, alias = "open-model", alias = "open_model")]
144 pub openmodel: ProviderConfigToml,
145 #[serde(
146 default,
147 alias = "z-ai",
148 alias = "z_ai",
149 alias = "z.ai",
150 alias = "zhipu",
151 alias = "zhipuai",
152 alias = "bigmodel",
153 alias = "big-model"
154 )]
155 pub zai: ProviderConfigToml,
156 #[serde(
157 default,
158 alias = "step-fun",
159 alias = "step_fun",
160 alias = "stepfun",
161 alias = "stepflash",
162 alias = "step-flash",
163 alias = "step_flash"
164 )]
165 pub stepfun: ProviderConfigToml,
166 #[serde(default, alias = "mini-max", alias = "mini_max", alias = "minimax")]
167 pub minimax: ProviderConfigToml,
168 #[serde(default, alias = "deep-infra", alias = "deep_infra")]
169 pub deepinfra: ProviderConfigToml,
170 #[serde(default, alias = "sakana-ai", alias = "sakana_ai", alias = "fugu")]
171 pub sakana: ProviderConfigToml,
172 #[serde(
173 default,
174 alias = "long-cat",
175 alias = "meituan-longcat",
176 alias = "meituan"
177 )]
178 pub longcat: ProviderConfigToml,
179 #[serde(default)]
185 pub custom: ProviderConfigToml,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
194#[serde(deny_unknown_fields)]
195pub struct PermissionsToml {
196 #[serde(default, skip_serializing_if = "Vec::is_empty")]
197 pub rules: Vec<ToolAskRule>,
198}
199
200impl PermissionsToml {
201 #[must_use]
202 pub fn is_empty(&self) -> bool {
203 self.rules.is_empty()
204 }
205
206 #[must_use]
207 pub fn ruleset(&self) -> Ruleset {
208 use codewhale_execpolicy::PermissionAction;
209 let mut denied = Vec::new();
210 let mut trusted = Vec::new();
211 let mut ask_rules = Vec::new();
212
213 for rule in &self.rules {
214 match rule.action {
215 PermissionAction::Deny => {
216 if let Some(cmd) = &rule.command {
219 denied.push(cmd.clone());
220 }
221 ask_rules.push(rule.clone());
223 }
224 PermissionAction::Allow => {
225 if let Some(cmd) = &rule.command {
229 trusted.push(cmd.clone());
230 }
231 ask_rules.push(rule.clone());
233 }
234 PermissionAction::Ask => {
235 ask_rules.push(rule.clone());
236 }
237 }
238 }
239
240 Ruleset::user(trusted, denied).with_ask_rules(ask_rules)
241 }
242}
243
244impl ProvidersToml {
245 #[must_use]
246 pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
247 match provider {
248 ProviderKind::Deepseek => &self.deepseek,
249 ProviderKind::DeepseekAnthropic => &self.deepseek_anthropic,
250 ProviderKind::NvidiaNim => &self.nvidia_nim,
251 ProviderKind::Openai => &self.openai,
252 ProviderKind::Atlascloud => &self.atlascloud,
253 ProviderKind::WanjieArk => &self.wanjie_ark,
254 ProviderKind::Volcengine => &self.volcengine,
255 ProviderKind::Openrouter => &self.openrouter,
256 ProviderKind::XiaomiMimo => &self.xiaomi_mimo,
257 ProviderKind::Novita => &self.novita,
258 ProviderKind::Fireworks => &self.fireworks,
259 ProviderKind::Siliconflow => &self.siliconflow,
260 ProviderKind::SiliconflowCN => &self.siliconflow_cn,
261 ProviderKind::Arcee => &self.arcee,
262 ProviderKind::Moonshot => &self.moonshot,
263 ProviderKind::Sglang => &self.sglang,
264 ProviderKind::Vllm => &self.vllm,
265 ProviderKind::Ollama => &self.ollama,
266 ProviderKind::Huggingface => &self.huggingface,
267 ProviderKind::Together => &self.together,
268 ProviderKind::Qianfan => &self.qianfan,
269 ProviderKind::OpenaiCodex => &self.openai_codex,
270 ProviderKind::Anthropic => &self.anthropic,
271 ProviderKind::Openmodel => &self.openmodel,
272 ProviderKind::Zai => &self.zai,
273 ProviderKind::Stepfun => &self.stepfun,
274 ProviderKind::Minimax => &self.minimax,
275 ProviderKind::Deepinfra => &self.deepinfra,
276 ProviderKind::Sakana => &self.sakana,
277 ProviderKind::LongCat => &self.longcat,
278 ProviderKind::Custom => &self.custom,
279 }
280 }
281
282 pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
283 match provider {
284 ProviderKind::Deepseek => &mut self.deepseek,
285 ProviderKind::DeepseekAnthropic => &mut self.deepseek_anthropic,
286 ProviderKind::NvidiaNim => &mut self.nvidia_nim,
287 ProviderKind::Openai => &mut self.openai,
288 ProviderKind::Atlascloud => &mut self.atlascloud,
289 ProviderKind::WanjieArk => &mut self.wanjie_ark,
290 ProviderKind::Volcengine => &mut self.volcengine,
291 ProviderKind::Openrouter => &mut self.openrouter,
292 ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo,
293 ProviderKind::Novita => &mut self.novita,
294 ProviderKind::Fireworks => &mut self.fireworks,
295 ProviderKind::Siliconflow => &mut self.siliconflow,
296 ProviderKind::SiliconflowCN => &mut self.siliconflow_cn,
297 ProviderKind::Arcee => &mut self.arcee,
298 ProviderKind::Moonshot => &mut self.moonshot,
299 ProviderKind::Sglang => &mut self.sglang,
300 ProviderKind::Vllm => &mut self.vllm,
301 ProviderKind::Ollama => &mut self.ollama,
302 ProviderKind::Huggingface => &mut self.huggingface,
303 ProviderKind::Together => &mut self.together,
304 ProviderKind::Qianfan => &mut self.qianfan,
305 ProviderKind::OpenaiCodex => &mut self.openai_codex,
306 ProviderKind::Anthropic => &mut self.anthropic,
307 ProviderKind::Openmodel => &mut self.openmodel,
308 ProviderKind::Zai => &mut self.zai,
309 ProviderKind::Stepfun => &mut self.stepfun,
310 ProviderKind::Minimax => &mut self.minimax,
311 ProviderKind::Deepinfra => &mut self.deepinfra,
312 ProviderKind::Sakana => &mut self.sakana,
313 ProviderKind::LongCat => &mut self.longcat,
314 ProviderKind::Custom => &mut self.custom,
315 }
316 }
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize, Default)]
320pub struct ConfigToml {
321 pub api_key: Option<String>,
324 pub base_url: Option<String>,
326 #[serde(default)]
328 pub http_headers: BTreeMap<String, String>,
329 pub default_text_model: Option<String>,
331 #[serde(default)]
332 pub provider: ProviderKind,
333 pub model: Option<String>,
334 pub auth_mode: Option<String>,
335 pub output_mode: Option<String>,
336 pub verbosity: Option<String>,
337 pub log_level: Option<String>,
338 pub telemetry: Option<bool>,
339 pub approval_policy: Option<String>,
340 pub sandbox_mode: Option<String>,
341 #[serde(default)]
343 pub tools: Option<ToolsToml>,
344 #[serde(default)]
345 pub providers: ProvidersToml,
346 #[serde(default, skip_serializing_if = "Vec::is_empty")]
350 pub fallback_providers: Vec<ProviderKind>,
351 #[serde(default)]
354 pub network: Option<NetworkPolicyToml>,
355 #[serde(default)]
358 pub verifier: Option<VerifierConfigToml>,
359 #[serde(default)]
363 pub skills: Option<SkillsToml>,
364 #[serde(default)]
367 pub snapshots: Option<SnapshotsToml>,
368 #[serde(default)]
371 pub lsp: Option<LspConfigToml>,
372 #[serde(default)]
375 pub harness_profiles: Vec<HarnessProfile>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub hotbar: Option<Vec<HotbarBindingToml>>,
380 #[serde(default)]
383 pub hook_sinks: Option<HookSinksToml>,
384 #[serde(default)]
387 pub fleet: Option<FleetConfigToml>,
388 #[serde(flatten)]
389 pub extras: BTreeMap<String, toml::Value>,
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq)]
393enum ProviderConfigField {
394 ApiKey,
395 BaseUrl,
396 Model,
397 ContextWindow,
398 Mode,
399 AuthMode,
400 InsecureSkipTlsVerify,
401 HttpHeaders,
402 PathSuffix,
403}
404
405impl ProviderConfigField {
406 fn parse(key: &str) -> Option<Self> {
407 Some(match key {
408 "api_key" => Self::ApiKey,
409 "base_url" => Self::BaseUrl,
410 "model" => Self::Model,
411 "context_window" | "context_window_tokens" => Self::ContextWindow,
412 "mode" => Self::Mode,
413 "auth_mode" => Self::AuthMode,
414 "insecure_skip_tls_verify" => Self::InsecureSkipTlsVerify,
415 "http_headers" => Self::HttpHeaders,
416 "path_suffix" => Self::PathSuffix,
417 _ => return None,
418 })
419 }
420
421 fn key(self) -> &'static str {
422 match self {
423 Self::ApiKey => "api_key",
424 Self::BaseUrl => "base_url",
425 Self::Model => "model",
426 Self::ContextWindow => "context_window",
427 Self::Mode => "mode",
428 Self::AuthMode => "auth_mode",
429 Self::InsecureSkipTlsVerify => "insecure_skip_tls_verify",
430 Self::HttpHeaders => "http_headers",
431 Self::PathSuffix => "path_suffix",
432 }
433 }
434}
435
436fn parse_provider_config_key(key: &str) -> Option<(ProviderKind, ProviderConfigField)> {
437 let suffix = key.strip_prefix("providers.")?;
438 let (provider_key, field_key) = suffix.split_once('.')?;
439 let field = ProviderConfigField::parse(field_key)?;
440 let provider = ProviderKind::ALL
441 .iter()
442 .copied()
443 .find(|kind| kind.provider().provider_config_key() == provider_key)?;
444 Some((provider, field))
445}
446
447fn provider_config_key(provider: ProviderKind, field: ProviderConfigField) -> String {
448 format!(
449 "providers.{}.{}",
450 provider.provider().provider_config_key(),
451 field.key()
452 )
453}
454
455fn get_provider_config_value(
456 config: &ProviderConfigToml,
457 field: ProviderConfigField,
458) -> Option<String> {
459 match field {
460 ProviderConfigField::ApiKey => config.api_key.clone(),
461 ProviderConfigField::BaseUrl => config.base_url.clone(),
462 ProviderConfigField::Model => config.model.clone(),
463 ProviderConfigField::ContextWindow => config.context_window.map(|value| value.to_string()),
464 ProviderConfigField::Mode => config.mode.clone(),
465 ProviderConfigField::AuthMode => config.auth_mode.clone(),
466 ProviderConfigField::InsecureSkipTlsVerify => config
467 .insecure_skip_tls_verify
468 .map(|value| value.to_string()),
469 ProviderConfigField::HttpHeaders => serialize_http_headers(&config.http_headers),
470 ProviderConfigField::PathSuffix => config.path_suffix.clone(),
471 }
472}
473
474fn get_provider_config_display_value(
475 config: &ProviderConfigToml,
476 field: ProviderConfigField,
477) -> Option<String> {
478 match field {
479 ProviderConfigField::ApiKey => config.api_key.as_deref().map(redact_secret),
480 ProviderConfigField::HttpHeaders => {
481 serialize_http_headers_for_display(&config.http_headers)
482 }
483 _ => get_provider_config_value(config, field),
484 }
485}
486
487fn parse_context_window(value: &str) -> Result<u32> {
488 let parsed = value.trim().parse::<u32>().with_context(|| {
489 format!("invalid context_window '{value}': expected a positive token count")
490 })?;
491 if parsed == 0 {
492 bail!("context_window must be greater than 0");
493 }
494 Ok(parsed)
495}
496
497fn set_provider_config_value(
498 config: &mut ConfigToml,
499 provider: ProviderKind,
500 field: ProviderConfigField,
501 value: &str,
502) -> Result<()> {
503 match field {
504 ProviderConfigField::ApiKey => {
505 let value = value.to_string();
506 config.providers.for_provider_mut(provider).api_key = Some(value.clone());
507 if provider == ProviderKind::Deepseek {
508 config.api_key = Some(value);
509 }
510 }
511 ProviderConfigField::BaseUrl => {
512 let value = value.to_string();
513 config.providers.for_provider_mut(provider).base_url = Some(value.clone());
514 if provider == ProviderKind::Deepseek {
515 config.base_url = Some(value);
516 }
517 }
518 ProviderConfigField::Model => {
519 let value = value.to_string();
520 config.providers.for_provider_mut(provider).model = Some(value.clone());
521 if provider == ProviderKind::Deepseek {
522 config.default_text_model = Some(value);
523 }
524 }
525 ProviderConfigField::ContextWindow => {
526 config.providers.for_provider_mut(provider).context_window =
527 Some(parse_context_window(value)?);
528 }
529 ProviderConfigField::Mode => {
530 config.providers.for_provider_mut(provider).mode = Some(value.to_string());
531 }
532 ProviderConfigField::AuthMode => {
533 config.providers.for_provider_mut(provider).auth_mode = Some(value.to_string());
534 }
535 ProviderConfigField::InsecureSkipTlsVerify => {
536 config
537 .providers
538 .for_provider_mut(provider)
539 .insecure_skip_tls_verify = Some(parse_bool(value)?);
540 }
541 ProviderConfigField::HttpHeaders => {
542 let headers = parse_http_headers(value)?;
543 config.providers.for_provider_mut(provider).http_headers = headers.clone();
544 if provider == ProviderKind::Deepseek {
545 config.http_headers = headers;
546 }
547 }
548 ProviderConfigField::PathSuffix => {
549 config.providers.for_provider_mut(provider).path_suffix = Some(value.to_string());
550 }
551 }
552 Ok(())
553}
554
555fn unset_provider_config_value(
556 config: &mut ConfigToml,
557 provider: ProviderKind,
558 field: ProviderConfigField,
559) {
560 match field {
561 ProviderConfigField::ApiKey => {
562 config.providers.for_provider_mut(provider).api_key = None;
563 if provider == ProviderKind::Deepseek {
564 config.api_key = None;
565 }
566 }
567 ProviderConfigField::BaseUrl => {
568 config.providers.for_provider_mut(provider).base_url = None;
569 if provider == ProviderKind::Deepseek {
570 config.base_url = None;
571 }
572 }
573 ProviderConfigField::Model => {
574 config.providers.for_provider_mut(provider).model = None;
575 if provider == ProviderKind::Deepseek {
576 config.default_text_model = None;
577 }
578 }
579 ProviderConfigField::ContextWindow => {
580 config.providers.for_provider_mut(provider).context_window = None;
581 }
582 ProviderConfigField::Mode => {
583 config.providers.for_provider_mut(provider).mode = None;
584 }
585 ProviderConfigField::AuthMode => {
586 config.providers.for_provider_mut(provider).auth_mode = None;
587 }
588 ProviderConfigField::InsecureSkipTlsVerify => {
589 config
590 .providers
591 .for_provider_mut(provider)
592 .insecure_skip_tls_verify = None;
593 }
594 ProviderConfigField::HttpHeaders => {
595 config
596 .providers
597 .for_provider_mut(provider)
598 .http_headers
599 .clear();
600 if provider == ProviderKind::Deepseek {
601 config.http_headers.clear();
602 }
603 }
604 ProviderConfigField::PathSuffix => {
605 config.providers.for_provider_mut(provider).path_suffix = None;
606 }
607 }
608}
609
610fn insert_provider_config_values(
611 out: &mut BTreeMap<String, String>,
612 provider: ProviderKind,
613 config: &ProviderConfigToml,
614) {
615 if let Some(v) = config.api_key.as_ref() {
616 out.insert(
617 provider_config_key(provider, ProviderConfigField::ApiKey),
618 redact_secret(v),
619 );
620 }
621 if let Some(v) = config.base_url.as_ref() {
622 out.insert(
623 provider_config_key(provider, ProviderConfigField::BaseUrl),
624 v.clone(),
625 );
626 }
627 if let Some(v) = config.model.as_ref() {
628 out.insert(
629 provider_config_key(provider, ProviderConfigField::Model),
630 v.clone(),
631 );
632 }
633 if let Some(v) = config.context_window {
634 out.insert(
635 provider_config_key(provider, ProviderConfigField::ContextWindow),
636 v.to_string(),
637 );
638 }
639 if let Some(v) = config.mode.as_ref() {
640 out.insert(
641 provider_config_key(provider, ProviderConfigField::Mode),
642 v.clone(),
643 );
644 }
645 if let Some(v) = config.auth_mode.as_ref() {
646 out.insert(
647 provider_config_key(provider, ProviderConfigField::AuthMode),
648 v.clone(),
649 );
650 }
651 if let Some(v) = config.insecure_skip_tls_verify {
652 out.insert(
653 provider_config_key(provider, ProviderConfigField::InsecureSkipTlsVerify),
654 v.to_string(),
655 );
656 }
657 if let Some(v) = serialize_http_headers_for_display(&config.http_headers) {
658 out.insert(
659 provider_config_key(provider, ProviderConfigField::HttpHeaders),
660 v,
661 );
662 }
663 if let Some(v) = config.path_suffix.as_ref() {
664 out.insert(
665 provider_config_key(provider, ProviderConfigField::PathSuffix),
666 v.clone(),
667 );
668 }
669}
670
671impl ConfigToml {
672 #[must_use]
678 pub fn resolve_harness_profile(
679 &self,
680 provider_route: &str,
681 model: &str,
682 ) -> Option<&HarnessProfile> {
683 self.harness_profiles
684 .iter()
685 .chain(built_in_harness_profiles().iter())
686 .find(|profile| profile.matches_route(provider_route, model))
687 }
688
689 #[must_use]
695 pub fn resolve_hotbar_bindings(&self, known_action_ids: &[&str]) -> HotbarConfigResolution {
696 resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids)
697 }
698}
699
700#[derive(Debug, Clone, PartialEq, Eq)]
705pub struct ProviderChain {
706 providers: Vec<ProviderKind>,
707 position: usize,
708}
709
710pub const HOTBAR_SLOT_COUNT: u8 = 8;
711
712pub const DEFAULT_HOTBAR_ACTIONS: [&str; HOTBAR_SLOT_COUNT as usize] = [
713 "voice.toggle",
714 "session.compact",
715 "mode.plan",
716 "mode.agent",
717 "mode.yolo",
718 "palette.open",
719 "sidebar.toggle",
720 "trust.toggle",
721];
722
723#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
725#[serde(deny_unknown_fields)]
726pub struct HotbarBindingToml {
727 pub slot: u8,
728 pub action: String,
729 #[serde(default)]
730 pub label: Option<String>,
731}
732
733#[derive(Debug, Clone, PartialEq, Eq)]
735pub struct HotbarBinding {
736 pub slot: u8,
737 pub action: String,
738 pub label: Option<String>,
739}
740
741#[derive(Debug, Clone, PartialEq, Eq)]
744pub enum HotbarConfigWarning {
745 SlotOutOfRange {
746 slot: u8,
747 action: String,
748 },
749 DuplicateSlot {
750 slot: u8,
751 previous_action: String,
752 replacement_action: String,
753 },
754 UnknownAction {
755 slot: u8,
756 action: String,
757 },
758}
759
760impl fmt::Display for HotbarConfigWarning {
761 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
762 match self {
763 Self::SlotOutOfRange { slot, action } => write!(
764 f,
765 "hotbar slot {slot} for action '{action}' is outside 1-{HOTBAR_SLOT_COUNT}; skipped"
766 ),
767 Self::DuplicateSlot {
768 slot,
769 previous_action,
770 replacement_action,
771 } => write!(
772 f,
773 "hotbar slot {slot} was bound to '{previous_action}' more than once; using '{replacement_action}'"
774 ),
775 Self::UnknownAction { slot, action } => write!(
776 f,
777 "hotbar slot {slot} references unknown action '{action}'; keeping binding"
778 ),
779 }
780 }
781}
782
783#[derive(Debug, Clone, PartialEq, Eq)]
784pub struct HotbarConfigResolution {
785 pub bindings: Vec<HotbarBinding>,
786 pub warnings: Vec<HotbarConfigWarning>,
787}
788
789#[must_use]
790pub fn default_hotbar_bindings() -> Vec<HotbarBinding> {
791 DEFAULT_HOTBAR_ACTIONS
792 .iter()
793 .enumerate()
794 .map(|(idx, action)| HotbarBinding {
795 slot: u8::try_from(idx + 1).expect("default hotbar slot fits in u8"),
796 action: (*action).to_string(),
797 label: None,
798 })
799 .collect()
800}
801
802#[must_use]
808pub fn default_hotbar_bindings_toml() -> Vec<HotbarBindingToml> {
809 default_hotbar_bindings()
810 .into_iter()
811 .map(|binding| HotbarBindingToml {
812 slot: binding.slot,
813 action: binding.action,
814 label: binding.label,
815 })
816 .collect()
817}
818
819#[must_use]
820pub fn resolve_hotbar_bindings(
821 configured: Option<&[HotbarBindingToml]>,
822 known_action_ids: &[&str],
823) -> HotbarConfigResolution {
824 let known = known_action_ids.iter().copied().collect::<BTreeSet<&str>>();
825 let mut warnings = Vec::new();
826
827 let source = match configured {
828 Some(bindings) => bindings
829 .iter()
830 .map(|binding| HotbarBinding {
831 slot: binding.slot,
832 action: binding.action.clone(),
833 label: binding.label.clone(),
834 })
835 .collect::<Vec<_>>(),
836 None => Vec::new(),
840 };
841
842 let mut by_slot: BTreeMap<u8, HotbarBinding> = BTreeMap::new();
843 for binding in source {
844 if !(1..=HOTBAR_SLOT_COUNT).contains(&binding.slot) {
845 warnings.push(HotbarConfigWarning::SlotOutOfRange {
846 slot: binding.slot,
847 action: binding.action,
848 });
849 continue;
850 }
851 if !known.is_empty() && !known.contains(binding.action.as_str()) {
852 warnings.push(HotbarConfigWarning::UnknownAction {
853 slot: binding.slot,
854 action: binding.action.clone(),
855 });
856 }
857 if let Some(previous) = by_slot.insert(binding.slot, binding.clone()) {
858 warnings.push(HotbarConfigWarning::DuplicateSlot {
859 slot: binding.slot,
860 previous_action: previous.action,
861 replacement_action: binding.action,
862 });
863 }
864 }
865
866 HotbarConfigResolution {
867 bindings: by_slot.into_values().collect(),
868 warnings,
869 }
870}
871
872impl ProviderChain {
873 #[must_use]
874 pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self {
875 let mut providers = vec![active];
876 for fallback in fallbacks {
877 if *fallback != active && !providers.contains(fallback) {
878 providers.push(*fallback);
879 }
880 }
881 Self {
882 providers,
883 position: 0,
884 }
885 }
886
887 #[must_use]
888 pub fn providers(&self) -> &[ProviderKind] {
889 &self.providers
890 }
891
892 #[must_use]
893 pub fn position(&self) -> usize {
894 self.position
895 }
896
897 #[must_use]
898 pub fn current(&self) -> ProviderKind {
899 self.providers
900 .get(self.position)
901 .copied()
902 .unwrap_or(self.providers[0])
903 }
904
905 #[must_use]
906 pub fn has_next(&self) -> bool {
907 self.position + 1 < self.providers.len()
908 }
909
910 pub fn advance(&mut self) -> Option<ProviderKind> {
911 if !self.has_next() {
912 return None;
913 }
914 self.position += 1;
915 Some(self.current())
916 }
917
918 pub fn reset(&mut self) {
919 self.position = 0;
920 }
921
922 #[must_use]
923 pub fn is_fallback_active(&self) -> bool {
924 self.position > 0
925 }
926
927 #[must_use]
929 pub fn remaining(&self) -> usize {
930 self.providers.len() - self.position
931 }
932}
933
934#[derive(Debug, Clone, Serialize, Deserialize, Default)]
936pub struct HookSinksToml {
937 #[serde(default)]
942 pub unix_socket_path: Option<PathBuf>,
943}
944
945#[derive(Debug, Clone, Serialize, Deserialize, Default)]
948pub struct SkillsToml {
949 #[serde(default)]
952 pub registry_url: Option<String>,
953 #[serde(default)]
956 pub max_install_size_bytes: Option<u64>,
957}
958
959#[derive(Debug, Clone, Serialize, Deserialize, Default)]
961pub struct ToolsToml {
962 #[serde(default)]
964 pub always_load: Vec<String>,
965}
966
967#[derive(Debug, Clone, Serialize, Deserialize)]
970pub struct SnapshotsToml {
971 #[serde(default = "default_snapshots_enabled")]
972 pub enabled: bool,
973 #[serde(default = "default_snapshot_max_age_days")]
974 pub max_age_days: u64,
975}
976
977fn default_snapshots_enabled() -> bool {
978 true
979}
980
981fn default_snapshot_max_age_days() -> u64 {
982 7
983}
984
985impl Default for SnapshotsToml {
986 fn default() -> Self {
987 Self {
988 enabled: default_snapshots_enabled(),
989 max_age_days: default_snapshot_max_age_days(),
990 }
991 }
992}
993
994#[derive(Debug, Clone, Serialize, Deserialize)]
997pub struct FleetConfigToml {
998 #[serde(default = "default_fleet_trust_level_str")]
1001 pub default_trust_level: String,
1002 #[serde(default = "default_fleet_require_identity")]
1005 pub require_identity_verification: bool,
1006 #[serde(default = "default_fleet_max_trust_level_str")]
1009 pub max_trust_level: String,
1010 #[serde(default)]
1017 pub roles: BTreeMap<String, FleetRolePreset>,
1018 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1022 pub profiles: BTreeMap<String, FleetProfile>,
1023 #[serde(default)]
1025 pub exec: FleetExecConfig,
1026}
1027
1028pub const DEFAULT_SPAWN_DEPTH: u32 = 3;
1043pub const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 900;
1044pub const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1;
1045pub const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600;
1046
1047pub const MAX_SPAWN_DEPTH_CEILING: u32 = 8;
1053
1054#[derive(Debug, Clone, Serialize, Deserialize)]
1059pub struct FleetExecConfig {
1060 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1062 pub allowed_tools: Vec<String>,
1063 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1065 pub disallowed_tools: Vec<String>,
1066 #[serde(default = "default_fleet_max_turns")]
1069 pub max_turns: u32,
1070 #[serde(default = "default_fleet_max_spawn_depth")]
1076 pub max_spawn_depth: u32,
1077 #[serde(default, skip_serializing_if = "String::is_empty")]
1080 pub append_system_prompt: String,
1081 #[serde(default = "default_fleet_output_format")]
1084 pub output_format: String,
1085}
1086
1087fn default_fleet_max_turns() -> u32 {
1088 u32::MAX
1089}
1090
1091fn default_fleet_max_spawn_depth() -> u32 {
1092 DEFAULT_SPAWN_DEPTH
1093}
1094
1095fn default_fleet_output_format() -> String {
1096 "text".to_string()
1097}
1098
1099impl Default for FleetExecConfig {
1100 fn default() -> Self {
1101 Self {
1102 allowed_tools: Vec::new(),
1103 disallowed_tools: Vec::new(),
1104 max_turns: default_fleet_max_turns(),
1105 max_spawn_depth: default_fleet_max_spawn_depth(),
1106 append_system_prompt: String::new(),
1107 output_format: default_fleet_output_format(),
1108 }
1109 }
1110}
1111
1112#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1118pub struct FleetProfile {
1119 #[serde(default)]
1121 pub slot: FleetSlot,
1122 #[serde(default)]
1124 pub role: FleetRole,
1125 #[serde(default)]
1127 pub loadout: FleetLoadout,
1128 #[serde(default, skip_serializing_if = "Option::is_none")]
1133 pub model: Option<String>,
1134 #[serde(default)]
1136 pub permissions: FleetProfilePermissions,
1137 #[serde(default)]
1139 pub delegation: FleetDelegationHints,
1140}
1141
1142#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
1147pub struct FleetRole {
1148 pub name: String,
1150 #[serde(default, skip_serializing_if = "Option::is_none")]
1152 pub description: Option<String>,
1153 #[serde(default, skip_serializing_if = "Option::is_none")]
1155 pub instructions: Option<String>,
1156}
1157
1158impl Default for FleetRole {
1159 fn default() -> Self {
1160 Self {
1161 name: "general".to_string(),
1162 description: None,
1163 instructions: None,
1164 }
1165 }
1166}
1167
1168impl<'de> Deserialize<'de> for FleetRole {
1169 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1170 where
1171 D: serde::Deserializer<'de>,
1172 {
1173 #[derive(Deserialize)]
1174 #[serde(untagged)]
1175 enum FleetRoleWire {
1176 Name(String),
1177 Full {
1178 #[serde(default)]
1179 name: Option<String>,
1180 #[serde(default)]
1181 description: Option<String>,
1182 #[serde(default)]
1183 instructions: Option<String>,
1184 },
1185 }
1186
1187 match FleetRoleWire::deserialize(deserializer)? {
1188 FleetRoleWire::Name(name) => Ok(Self {
1189 name,
1190 ..Self::default()
1191 }),
1192 FleetRoleWire::Full {
1193 name,
1194 description,
1195 instructions,
1196 } => Ok(Self {
1197 name: name.unwrap_or_else(|| Self::default().name),
1198 description,
1199 instructions,
1200 }),
1201 }
1202 }
1203}
1204
1205#[derive(Debug, Clone, PartialEq, Eq, Default)]
1207pub enum FleetSlot {
1208 Manager,
1209 Scout,
1210 Implementer,
1211 Reviewer,
1212 Verifier,
1213 Operator,
1214 Summarizer,
1215 #[default]
1216 General,
1217 Custom(String),
1218}
1219
1220impl FleetSlot {
1221 #[must_use]
1222 pub fn as_str(&self) -> &str {
1223 match self {
1224 Self::Manager => "manager",
1225 Self::Scout => "scout",
1226 Self::Implementer => "implementer",
1227 Self::Reviewer => "reviewer",
1228 Self::Verifier => "verifier",
1229 Self::Operator => "operator",
1230 Self::Summarizer => "summarizer",
1231 Self::General => "general",
1232 Self::Custom(value) => value.as_str(),
1233 }
1234 }
1235
1236 #[must_use]
1237 pub fn from_name(value: &str) -> Self {
1238 match value.trim() {
1239 "manager" | "coordinator" => Self::Manager,
1240 "scout" | "research" | "research-worker" => Self::Scout,
1241 "implementer" | "builder" => Self::Implementer,
1242 "reviewer" => Self::Reviewer,
1243 "verifier" | "tester" => Self::Verifier,
1244 "operator" | "incident" | "incident-worker" => Self::Operator,
1245 "summarizer" | "reducer" => Self::Summarizer,
1246 "general" | "" => Self::General,
1247 other => Self::Custom(other.to_string()),
1251 }
1252 }
1253}
1254
1255impl Serialize for FleetSlot {
1256 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1257 where
1258 S: serde::Serializer,
1259 {
1260 serializer.serialize_str(self.as_str())
1261 }
1262}
1263
1264impl<'de> Deserialize<'de> for FleetSlot {
1265 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1266 where
1267 D: serde::Deserializer<'de>,
1268 {
1269 let value = String::deserialize(deserializer)?;
1270 Ok(Self::from_name(&value))
1271 }
1272}
1273
1274#[derive(Debug, Clone, PartialEq, Eq, Default)]
1276pub enum FleetLoadout {
1277 #[default]
1279 Inherit,
1280 Fast,
1282 Custom(String),
1286}
1287
1288impl FleetLoadout {
1289 #[must_use]
1290 pub fn as_str(&self) -> &str {
1291 match self {
1292 Self::Inherit => "inherit",
1293 Self::Fast => "fast",
1294 Self::Custom(value) => value.as_str(),
1295 }
1296 }
1297
1298 #[must_use]
1299 pub fn from_name(value: &str) -> Self {
1300 match value.trim() {
1301 "inherit" | "default" | "auto" | "" => Self::Inherit,
1302 "fast" => Self::Fast,
1303 other => Self::Custom(other.to_string()),
1307 }
1308 }
1309}
1310
1311impl Serialize for FleetLoadout {
1312 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1313 where
1314 S: serde::Serializer,
1315 {
1316 serializer.serialize_str(self.as_str())
1317 }
1318}
1319
1320impl<'de> Deserialize<'de> for FleetLoadout {
1321 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1322 where
1323 D: serde::Deserializer<'de>,
1324 {
1325 let value = String::deserialize(deserializer)?;
1326 Ok(Self::from_name(&value))
1327 }
1328}
1329
1330#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1332pub struct FleetProfilePermissions {
1333 #[serde(default)]
1335 pub allow_shell: bool,
1336 #[serde(default)]
1338 pub trust: bool,
1339 #[serde(default = "default_fleet_profile_approval_required")]
1341 pub approval_required: bool,
1342}
1343
1344fn default_fleet_profile_approval_required() -> bool {
1345 true
1346}
1347
1348impl Default for FleetProfilePermissions {
1349 fn default() -> Self {
1350 Self {
1351 allow_shell: false,
1352 trust: false,
1353 approval_required: true,
1354 }
1355 }
1356}
1357
1358#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1360pub struct FleetDelegationHints {
1361 #[serde(default, skip_serializing_if = "Option::is_none")]
1364 pub max_spawn_depth: Option<u32>,
1365 #[serde(
1367 default,
1368 alias = "concurrency",
1369 skip_serializing_if = "Option::is_none"
1370 )]
1371 pub max_concurrency: Option<usize>,
1372}
1373
1374#[derive(Debug, Clone, Serialize, Deserialize)]
1383pub struct FleetRolePreset {
1384 #[serde(skip_serializing_if = "Option::is_none")]
1386 pub description: Option<String>,
1387 #[serde(skip_serializing_if = "Option::is_none")]
1389 pub tool_profile: Option<String>,
1390 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1392 pub tools: Vec<String>,
1393 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1395 pub capabilities: Vec<String>,
1396 #[serde(skip_serializing_if = "Option::is_none")]
1398 pub timeout_seconds: Option<u64>,
1399 #[serde(skip_serializing_if = "Option::is_none")]
1401 pub trust_level: Option<String>,
1402}
1403
1404fn default_fleet_trust_level_str() -> String {
1405 "sandbox".to_string()
1406}
1407
1408fn default_fleet_require_identity() -> bool {
1409 true
1410}
1411
1412fn default_fleet_max_trust_level_str() -> String {
1413 "operator".to_string()
1414}
1415
1416impl Default for FleetConfigToml {
1417 fn default() -> Self {
1418 Self {
1419 default_trust_level: default_fleet_trust_level_str(),
1420 require_identity_verification: default_fleet_require_identity(),
1421 max_trust_level: default_fleet_max_trust_level_str(),
1422 roles: BTreeMap::new(),
1423 profiles: BTreeMap::new(),
1424 exec: FleetExecConfig::default(),
1425 }
1426 }
1427}
1428
1429impl FleetConfigToml {
1430 #[must_use]
1433 pub fn resolve_role(&self, name: &str) -> Option<FleetRolePreset> {
1434 self.roles
1435 .get(name)
1436 .cloned()
1437 .or_else(|| built_in_role_presets().get(name).cloned())
1438 }
1439}
1440
1441#[must_use]
1443pub fn built_in_role_presets() -> BTreeMap<String, FleetRolePreset> {
1444 [
1445 (
1446 "smoke-runner".to_string(),
1447 FleetRolePreset {
1448 description: Some("Lightweight read-only smoke check worker".to_string()),
1449 tool_profile: Some("read-only".to_string()),
1450 tools: vec![],
1451 capabilities: vec![],
1452 timeout_seconds: Some(300),
1453 trust_level: Some("local".to_string()),
1454 },
1455 ),
1456 (
1457 "reviewer".to_string(),
1458 FleetRolePreset {
1459 description: Some("Read-only code and documentation review".to_string()),
1460 tool_profile: Some("read-only".to_string()),
1461 tools: vec![],
1462 capabilities: vec![],
1463 timeout_seconds: Some(600),
1464 trust_level: None,
1465 },
1466 ),
1467 (
1468 "builder".to_string(),
1469 FleetRolePreset {
1470 description: Some(
1471 "Read-write builder with compilation and test access".to_string(),
1472 ),
1473 tool_profile: Some("read-write".to_string()),
1474 tools: vec![],
1475 capabilities: vec![],
1476 timeout_seconds: Some(1800),
1477 trust_level: Some("local".to_string()),
1478 },
1479 ),
1480 (
1481 "read-only".to_string(),
1482 FleetRolePreset {
1483 description: Some(
1484 "Minimal read-only observer with no writes or secrets".to_string(),
1485 ),
1486 tool_profile: Some("read-only".to_string()),
1487 tools: vec![],
1488 capabilities: vec![],
1489 timeout_seconds: Some(300),
1490 trust_level: Some("sandbox".to_string()),
1491 },
1492 ),
1493 ]
1494 .into()
1495}
1496
1497#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1503#[serde(rename_all = "snake_case")]
1504pub enum VerifierVerdictPolicy {
1505 #[default]
1506 Hunt,
1507}
1508
1509#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1511pub struct VerifierConfigToml {
1512 #[serde(default)]
1516 pub enabled: bool,
1517 #[serde(default)]
1519 pub verdict_policy: VerifierVerdictPolicy,
1520}
1521
1522impl Default for VerifierConfigToml {
1523 fn default() -> Self {
1524 Self {
1525 enabled: false,
1526 verdict_policy: VerifierVerdictPolicy::Hunt,
1527 }
1528 }
1529}
1530
1531#[derive(Debug, Clone, Serialize, Deserialize)]
1534pub struct NetworkPolicyToml {
1535 #[serde(default = "default_network_decision")]
1538 pub default: String,
1539 #[serde(default)]
1542 pub allow: Vec<String>,
1543 #[serde(default)]
1545 pub deny: Vec<String>,
1546 #[serde(default)]
1549 pub proxy: Vec<String>,
1550 #[serde(default = "default_network_audit")]
1552 pub audit: bool,
1553}
1554
1555fn default_network_decision() -> String {
1556 "prompt".to_string()
1557}
1558
1559fn default_network_audit() -> bool {
1560 true
1561}
1562
1563impl Default for NetworkPolicyToml {
1564 fn default() -> Self {
1565 Self {
1566 default: default_network_decision(),
1567 allow: Vec::new(),
1568 deny: Vec::new(),
1569 proxy: Vec::new(),
1570 audit: default_network_audit(),
1571 }
1572 }
1573}
1574
1575#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1578pub struct CustomLspDef {
1579 pub language_id: String,
1581 pub command: String,
1583 #[serde(default)]
1585 pub args: Vec<String>,
1586}
1587
1588#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1592pub struct LspConfigToml {
1593 pub enabled: Option<bool>,
1595 pub poll_after_edit_ms: Option<u64>,
1597 pub max_diagnostics_per_file: Option<usize>,
1599 pub include_warnings: Option<bool>,
1601 pub servers: Option<BTreeMap<String, Vec<String>>>,
1603 pub custom: Option<BTreeMap<String, CustomLspDef>>,
1606}
1607
1608impl ConfigToml {
1609 pub fn merge_project_overrides(&mut self, project: ConfigToml) {
1618 if project.default_text_model.is_some() {
1619 self.default_text_model = project.default_text_model;
1620 }
1621 if project.model.is_some() {
1622 self.model = project.model;
1623 }
1624 if project.output_mode.is_some() {
1625 self.output_mode = project.output_mode;
1626 }
1627 if project.verbosity.is_some() {
1628 self.verbosity = project.verbosity;
1629 }
1630 if project.log_level.is_some() {
1631 self.log_level = project.log_level;
1632 }
1633 if let Some(policy) = project.approval_policy
1634 && project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy)
1635 {
1636 self.approval_policy = Some(policy);
1637 }
1638 if let Some(mode) = project.sandbox_mode
1639 && project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode)
1640 {
1641 self.sandbox_mode = Some(mode);
1642 }
1643 if project.tools.is_some() {
1644 self.tools = project.tools;
1645 }
1646 for provider in ProviderKind::ALL {
1647 merge_project_provider_config(
1648 self.providers.for_provider_mut(provider),
1649 project.providers.for_provider(provider),
1650 );
1651 }
1652 }
1653
1654 #[must_use]
1655 pub fn get_value(&self, key: &str) -> Option<String> {
1656 if let Some((provider, field)) = parse_provider_config_key(key) {
1657 return get_provider_config_value(self.providers.for_provider(provider), field);
1658 }
1659
1660 match key {
1661 "provider" => Some(self.provider.as_str().to_string()),
1662 "stream_chunk_timeout_secs" | "tui.stream_chunk_timeout_secs" => {
1663 Some(self.stream_chunk_timeout_secs().to_string())
1664 }
1665 "api_key" => self.api_key.clone(),
1666 "base_url" => self.base_url.clone(),
1667 "http_headers" => serialize_http_headers(&self.http_headers),
1668 "default_text_model" => self.default_text_model.clone(),
1669 "model" => self.model.clone(),
1670 "auth.mode" => self.auth_mode.clone(),
1671 "output_mode" => self.output_mode.clone(),
1672 "verbosity" => self.verbosity.clone(),
1673 "log_level" => self.log_level.clone(),
1674 "telemetry" => self.telemetry.map(|v| v.to_string()),
1675 "approval_policy" => self.approval_policy.clone(),
1676 "sandbox_mode" => self.sandbox_mode.clone(),
1677 "tools.always_load" => self.tools.as_ref().map(|tools| tools.always_load.join(",")),
1678 "hook_sinks.unix_socket_path" => self
1679 .hook_sinks
1680 .as_ref()
1681 .and_then(|sinks| sinks.unix_socket_path.as_ref())
1682 .map(|path| path.display().to_string()),
1683 _ => self.extras.get(key).map(toml::Value::to_string),
1684 }
1685 }
1686
1687 #[must_use]
1688 pub fn get_display_value(&self, key: &str) -> Option<String> {
1689 if let Some((provider, field)) = parse_provider_config_key(key) {
1690 return get_provider_config_display_value(self.providers.for_provider(provider), field);
1691 }
1692
1693 if key == "http_headers" {
1694 return serialize_http_headers_for_display(&self.http_headers);
1695 }
1696
1697 if let Some(value) = self.extras.get(key) {
1698 return Some(redact_toml_value_for_display(key, value));
1699 }
1700
1701 self.get_value(key).map(|value| {
1702 if is_sensitive_config_key(key) {
1703 redact_secret(&value)
1704 } else {
1705 value
1706 }
1707 })
1708 }
1709
1710 #[must_use]
1711 pub fn stream_chunk_timeout_secs(&self) -> u64 {
1712 let raw = self
1713 .extras
1714 .get("tui")
1715 .and_then(toml::Value::as_table)
1716 .and_then(|table| table.get("stream_chunk_timeout_secs"))
1717 .and_then(toml_value_as_u64)
1718 .or_else(|| {
1719 self.extras
1720 .get("tui.stream_chunk_timeout_secs")
1721 .and_then(toml_value_as_u64)
1722 })
1723 .or_else(|| {
1724 self.extras
1725 .get("stream_chunk_timeout_secs")
1726 .and_then(toml_value_as_u64)
1727 })
1728 .unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS);
1729 if raw == 0 {
1730 DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
1731 } else {
1732 raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS)
1733 }
1734 }
1735
1736 pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
1737 if let Some((provider, field)) = parse_provider_config_key(key) {
1738 return set_provider_config_value(self, provider, field, value);
1739 }
1740
1741 match key {
1742 "provider" => {
1743 self.provider = ProviderKind::parse(value).with_context(|| {
1744 format!(
1745 "unknown provider '{value}': expected {}",
1746 ProviderKind::names_hint()
1747 )
1748 })?;
1749 }
1750 "api_key" => self.api_key = Some(value.to_string()),
1751 "base_url" => self.base_url = Some(value.to_string()),
1752 "http_headers" => self.http_headers = parse_http_headers(value)?,
1753 "default_text_model" => self.default_text_model = Some(value.to_string()),
1754 "model" => self.model = Some(value.to_string()),
1755 "auth.mode" => self.auth_mode = Some(value.to_string()),
1756 "output_mode" => self.output_mode = Some(value.to_string()),
1757 "verbosity" => self.verbosity = Some(value.to_string()),
1758 "log_level" => self.log_level = Some(value.to_string()),
1759 "telemetry" => {
1760 self.telemetry = Some(parse_bool(value)?);
1761 }
1762 "approval_policy" => self.approval_policy = Some(value.to_string()),
1763 "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
1764 "hook_sinks.unix_socket_path" => {
1765 self.hook_sinks
1766 .get_or_insert_with(HookSinksToml::default)
1767 .unix_socket_path = Some(PathBuf::from(value));
1768 }
1769 _ => {
1770 self.extras
1771 .insert(key.to_string(), toml::Value::String(value.to_string()));
1772 }
1773 }
1774 Ok(())
1775 }
1776
1777 pub fn unset_value(&mut self, key: &str) -> Result<()> {
1778 if let Some((provider, field)) = parse_provider_config_key(key) {
1779 unset_provider_config_value(self, provider, field);
1780 return Ok(());
1781 }
1782
1783 match key {
1784 "provider" => self.provider = ProviderKind::Deepseek,
1785 "api_key" => self.api_key = None,
1786 "base_url" => self.base_url = None,
1787 "http_headers" => self.http_headers.clear(),
1788 "default_text_model" => self.default_text_model = None,
1789 "model" => self.model = None,
1790 "auth.mode" => self.auth_mode = None,
1791 "output_mode" => self.output_mode = None,
1792 "verbosity" => self.verbosity = None,
1793 "log_level" => self.log_level = None,
1794 "telemetry" => self.telemetry = None,
1795 "approval_policy" => self.approval_policy = None,
1796 "sandbox_mode" => self.sandbox_mode = None,
1797 "hook_sinks.unix_socket_path" => {
1798 if let Some(sinks) = self.hook_sinks.as_mut() {
1799 sinks.unix_socket_path = None;
1800 }
1801 }
1802 _ => {
1803 self.extras.remove(key);
1804 }
1805 }
1806 Ok(())
1807 }
1808
1809 #[must_use]
1810 pub fn list_values(&self) -> BTreeMap<String, String> {
1811 let mut out = BTreeMap::new();
1812 out.insert("provider".to_string(), self.provider.as_str().to_string());
1813
1814 if let Some(v) = self.api_key.as_ref() {
1815 out.insert("api_key".to_string(), redact_secret(v));
1816 }
1817 if let Some(v) = self.base_url.as_ref() {
1818 out.insert("base_url".to_string(), v.clone());
1819 }
1820 if let Some(v) = serialize_http_headers_for_display(&self.http_headers) {
1821 out.insert("http_headers".to_string(), v);
1822 }
1823 if let Some(v) = self.default_text_model.as_ref() {
1824 out.insert("default_text_model".to_string(), v.clone());
1825 }
1826 if let Some(v) = self.model.as_ref() {
1827 out.insert("model".to_string(), v.clone());
1828 }
1829 if let Some(v) = self.auth_mode.as_ref() {
1830 out.insert("auth.mode".to_string(), v.clone());
1831 }
1832 if let Some(v) = self.output_mode.as_ref() {
1833 out.insert("output_mode".to_string(), v.clone());
1834 }
1835 if let Some(v) = self.verbosity.as_ref() {
1836 out.insert("verbosity".to_string(), v.clone());
1837 }
1838 if let Some(v) = self.log_level.as_ref() {
1839 out.insert("log_level".to_string(), v.clone());
1840 }
1841 if let Some(v) = self.telemetry {
1842 out.insert("telemetry".to_string(), v.to_string());
1843 }
1844 if let Some(v) = self.approval_policy.as_ref() {
1845 out.insert("approval_policy".to_string(), v.clone());
1846 }
1847 if let Some(v) = self.sandbox_mode.as_ref() {
1848 out.insert("sandbox_mode".to_string(), v.clone());
1849 }
1850 if let Some(v) = self
1851 .hook_sinks
1852 .as_ref()
1853 .and_then(|sinks| sinks.unix_socket_path.as_ref())
1854 {
1855 out.insert(
1856 "hook_sinks.unix_socket_path".to_string(),
1857 v.display().to_string(),
1858 );
1859 }
1860
1861 for provider in ProviderKind::ALL {
1862 insert_provider_config_values(
1863 &mut out,
1864 provider,
1865 self.providers.for_provider(provider),
1866 );
1867 }
1868
1869 for (k, v) in &self.extras {
1870 out.insert(k.clone(), redact_toml_value_for_display(k, v));
1871 }
1872 out
1873 }
1874
1875 #[must_use]
1882 pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
1883 let no_keyring = Secrets::new(std::sync::Arc::new(
1884 codewhale_secrets::InMemoryKeyringStore::new(),
1885 ));
1886 self.resolve_runtime_options_with_secrets(cli, &no_keyring)
1887 }
1888
1889 #[must_use]
1893 pub fn resolve_runtime_options_with_secrets(
1894 &self,
1895 cli: &CliRuntimeOverrides,
1896 secrets: &Secrets,
1897 ) -> ResolvedRuntimeOptions {
1898 let env = EnvRuntimeOverrides::load();
1899 let (provider, provider_source) = if let Some(provider) = cli.provider {
1900 (provider, ProviderSource::Cli)
1901 } else if let Some(provider) = env.provider {
1902 (
1903 provider,
1904 ProviderSource::Env(env.provider_source.unwrap_or("CODEWHALE_PROVIDER")),
1905 )
1906 } else {
1907 (self.provider, ProviderSource::Config)
1908 };
1909
1910 let mut provider_cfg = self.providers.for_provider(provider).clone();
1911 if provider == ProviderKind::SiliconflowCN {
1912 let fb = &self.providers.siliconflow;
1913 if provider_cfg.api_key.is_none() {
1914 provider_cfg.api_key = fb.api_key.clone();
1915 }
1916 if provider_cfg.base_url.is_none() {
1917 provider_cfg.base_url = fb.base_url.clone();
1918 }
1919 if provider_cfg.model.is_none() {
1920 provider_cfg.model = fb.model.clone();
1921 }
1922 }
1923 let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
1924 .then(|| self.api_key.clone())
1925 .flatten();
1926 let root_deepseek_base_url = (provider == ProviderKind::Deepseek)
1927 .then(|| self.base_url.clone())
1928 .flatten();
1929 let root_deepseek_model = (provider == ProviderKind::Deepseek)
1930 .then(|| self.default_text_model.clone())
1931 .flatten();
1932 let auth_mode = cli
1933 .auth_mode
1934 .clone()
1935 .or_else(|| env.auth_mode.clone())
1936 .or_else(|| provider_cfg.auth_mode.clone())
1937 .or_else(|| self.auth_mode.clone());
1938 let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
1939 let configured_base_url = cli
1940 .base_url
1941 .clone()
1942 .or_else(|| env.base_url_for(provider))
1943 .or_else(|| provider_cfg.base_url.clone())
1944 .or(root_deepseek_base_url);
1945 let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo {
1946 env.xiaomi_mimo_mode
1947 .clone()
1948 .or_else(|| provider_cfg.mode.clone())
1949 } else {
1950 None
1951 };
1952 let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo {
1953 xiaomi_mimo_env_api_key_for_runtime(
1954 xiaomi_mimo_mode.as_deref(),
1955 configured_base_url.as_deref(),
1956 )
1957 } else {
1958 None
1959 };
1960 let explicit_api_key_for_endpoint = cli
1961 .api_key
1962 .as_deref()
1963 .or(from_file.as_deref())
1964 .or(xiaomi_mimo_env_api_key.as_deref());
1965 let base_url = if provider == ProviderKind::XiaomiMimo {
1966 resolve_xiaomi_mimo_base_url(
1967 configured_base_url,
1968 explicit_api_key_for_endpoint,
1969 xiaomi_mimo_mode.as_deref(),
1970 )
1971 } else {
1972 configured_base_url.unwrap_or_else(|| match provider {
1973 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
1974 ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string(),
1975 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
1976 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
1977 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
1978 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
1979 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
1980 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
1981 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
1982 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
1983 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
1984 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
1985 ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL.to_string(),
1986 ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL.to_string(),
1987 ProviderKind::Moonshot => {
1988 if auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) {
1989 DEFAULT_KIMI_CODE_BASE_URL.to_string()
1990 } else {
1991 DEFAULT_MOONSHOT_BASE_URL.to_string()
1992 }
1993 }
1994 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
1995 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
1996 ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
1997 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(),
1998 ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL.to_string(),
1999 ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL.to_string(),
2000 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
2001 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(),
2002 ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL.to_string(),
2003 ProviderKind::Zai => DEFAULT_ZAI_BASE_URL.to_string(),
2004 ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL.to_string(),
2005 ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL.to_string(),
2006 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL.to_string(),
2007 ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL.to_string(),
2008 ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL.to_string(),
2009 ProviderKind::Custom => provider.provider().default_base_url().to_string(),
2013 })
2014 };
2015 let uses_kimi_oauth = provider == ProviderKind::Moonshot
2021 && auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth);
2022 let (api_key, api_key_source) = if let Some(value) = cli.api_key.clone() {
2023 (Some(value), Some(RuntimeApiKeySource::Cli))
2024 } else if uses_kimi_oauth {
2025 (None, None)
2026 } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) {
2027 (Some(value), Some(RuntimeApiKeySource::ConfigFile))
2028 } else if let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty()) {
2029 (Some(value), Some(RuntimeApiKeySource::Env))
2030 } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
2031 match env_api_key_for_provider(provider) {
2032 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
2033 None => (None, None),
2034 }
2035 } else {
2036 match secrets.resolve_with_source(provider.as_str()) {
2037 Some((value, source)) => {
2038 let source = match source {
2039 SecretSource::Keyring => RuntimeApiKeySource::Keyring,
2040 SecretSource::Env => RuntimeApiKeySource::Env,
2041 };
2042 (Some(value), Some(source))
2043 }
2044 None => match env_api_key_for_provider(provider) {
2045 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
2046 None => (None, None),
2047 },
2048 }
2049 };
2050
2051 let env_provider_model = env.model_for(provider, &base_url);
2052 let explicit_model = cli.model.is_some()
2053 || env.model.is_some()
2054 || env_provider_model.is_some()
2055 || provider_cfg.model.is_some()
2056 || root_deepseek_model.is_some()
2057 || self.model.is_some();
2058 let model = cli
2059 .model
2060 .clone()
2061 .or_else(|| env.model.clone())
2062 .or(env_provider_model)
2063 .or_else(|| provider_cfg.model.clone())
2064 .or(root_deepseek_model)
2065 .or_else(|| self.model.clone())
2066 .unwrap_or_else(|| {
2067 if provider == ProviderKind::Moonshot
2068 && (auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth)
2069 || moonshot_base_url_uses_kimi_code(&base_url))
2070 {
2071 DEFAULT_KIMI_CODE_MODEL.to_string()
2072 } else {
2073 default_model_for_provider(provider).to_string()
2074 }
2075 });
2076 let model =
2077 if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
2078 model.trim().to_string()
2079 } else {
2080 normalize_model_for_provider(provider, &model)
2081 };
2082
2083 let mut http_headers = self.http_headers.clone();
2084 http_headers.extend(provider_cfg.http_headers.clone());
2085 if let Some(env_headers) = env.http_headers {
2086 http_headers.extend(env_headers);
2087 }
2088 http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
2089
2090 let output_mode = cli
2091 .output_mode
2092 .clone()
2093 .or_else(|| env.output_mode.clone())
2094 .or_else(|| self.output_mode.clone());
2095 let log_level = cli
2096 .log_level
2097 .clone()
2098 .or_else(|| env.log_level.clone())
2099 .or_else(|| self.log_level.clone());
2100 let telemetry = cli
2101 .telemetry
2102 .or(env.telemetry)
2103 .or(self.telemetry)
2104 .unwrap_or(false);
2105 let approval_policy = cli
2106 .approval_policy
2107 .clone()
2108 .or_else(|| env.approval_policy.clone())
2109 .or_else(|| self.approval_policy.clone());
2110 let sandbox_mode = cli
2111 .sandbox_mode
2112 .clone()
2113 .or_else(|| env.sandbox_mode.clone())
2114 .or_else(|| self.sandbox_mode.clone());
2115 let yolo = cli.yolo.or(env.yolo);
2116 let verbosity = cli
2117 .verbosity
2118 .clone()
2119 .or_else(|| env.verbosity.clone())
2120 .or_else(|| self.verbosity.clone());
2121
2122 ResolvedRuntimeOptions {
2123 provider,
2124 provider_source,
2125 model,
2126 api_key,
2127 api_key_source,
2128 base_url,
2129 auth_mode,
2130 insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false),
2131 output_mode,
2132 log_level,
2133 telemetry,
2134 approval_policy,
2135 sandbox_mode,
2136 yolo,
2137 verbosity,
2138 http_headers,
2139 }
2140 }
2141}
2142
2143fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
2144 if source.model.is_some() {
2145 target.model = source.model.clone();
2146 }
2147}
2148
2149#[must_use]
2150pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
2151 let Some(project_rank) = approval_policy_rank(project) else {
2152 return false;
2153 };
2154 match current.and_then(approval_policy_rank) {
2155 Some(current_rank) => project_rank >= current_rank,
2156 None => project_rank >= 2,
2157 }
2158}
2159
2160#[must_use]
2161pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
2162 let normalized_project = project.trim().to_ascii_lowercase();
2163 if normalized_project == "external-sandbox" {
2164 return current
2165 .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
2166 .unwrap_or(false);
2167 }
2168
2169 let Some(project_rank) = sandbox_mode_rank(project) else {
2170 return false;
2171 };
2172 match current.and_then(sandbox_mode_rank) {
2173 Some(current_rank) => project_rank >= current_rank,
2174 None => project_rank >= 2,
2175 }
2176}
2177
2178fn approval_policy_rank(value: &str) -> Option<u8> {
2179 match value.trim().to_ascii_lowercase().as_str() {
2180 "auto" => Some(0),
2181 "suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
2182 "never" | "deny" | "denied" => Some(2),
2183 _ => None,
2184 }
2185}
2186
2187fn sandbox_mode_rank(value: &str) -> Option<u8> {
2188 match value.trim().to_ascii_lowercase().as_str() {
2189 "danger-full-access" => Some(0),
2190 "external-sandbox" => Some(0),
2191 "workspace-write" => Some(1),
2192 "read-only" => Some(2),
2193 _ => None,
2194 }
2195}
2196
2197pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
2203 for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
2204 let path = workspace.join(dir).join(CONFIG_FILE_NAME);
2205 if !project_config_candidate_exists(&path) {
2206 continue;
2207 }
2208 let raw = match read_checked_config_file(&path) {
2209 Ok(raw) => raw,
2210 Err(e) => {
2211 tracing::warn!("Failed to read project config {}: {e:#}", path.display());
2212 return None;
2213 }
2214 };
2215 match toml::from_str(&raw) {
2216 Ok(config) => return Some(config),
2217 Err(e) => {
2218 tracing::warn!("Failed to parse project config {}: {e}", path.display());
2219 return None;
2220 }
2221 }
2222 }
2223 None
2224}
2225
2226fn project_config_candidate_exists(path: &Path) -> bool {
2227 fs::symlink_metadata(path).is_ok_and(|metadata| {
2228 let file_type = metadata.file_type();
2229 file_type.is_file() || file_type.is_symlink()
2230 })
2231}
2232
2233fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
2234 if matches!(provider, ProviderKind::XiaomiMimo)
2235 && let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
2236 {
2237 return canonical.to_string();
2238 }
2239 if matches!(provider, ProviderKind::Minimax)
2240 && let Some(canonical) = canonical_minimax_model_id(model)
2241 {
2242 return canonical.to_string();
2243 }
2244 if matches!(provider, ProviderKind::Zai)
2245 && let Some(canonical) = canonical_zai_model_id(model)
2246 {
2247 return canonical.to_string();
2248 }
2249
2250 if matches!(
2251 provider,
2252 ProviderKind::Atlascloud
2253 | ProviderKind::WanjieArk
2254 | ProviderKind::Volcengine
2255 | ProviderKind::XiaomiMimo
2256 | ProviderKind::Zai
2257 | ProviderKind::Stepfun
2258 | ProviderKind::Minimax
2259 | ProviderKind::Qianfan
2260 | ProviderKind::Ollama
2261 ) {
2262 return model.to_string();
2263 }
2264
2265 let normalized = model.trim().to_ascii_lowercase();
2266 if provider == ProviderKind::Openrouter
2267 && let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
2268 {
2269 return canonical.to_string();
2270 }
2271 match (provider, normalized.as_str()) {
2272 (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
2273 DEFAULT_NVIDIA_NIM_MODEL.to_string()
2274 }
2275 (
2276 ProviderKind::NvidiaNim,
2277 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2278 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2279 ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
2280 (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
2281 DEFAULT_OPENROUTER_MODEL.to_string()
2282 }
2283 (
2284 ProviderKind::Openrouter,
2285 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2286 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2287 ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
2288 (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
2289 DEFAULT_NOVITA_MODEL.to_string()
2290 }
2291 (
2292 ProviderKind::Novita,
2293 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2294 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2295 ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
2296 (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
2297 DEFAULT_FIREWORKS_MODEL.to_string()
2298 }
2299 (
2300 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2301 "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
2302 ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
2303 (
2304 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2305 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
2306 ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
2307 (
2308 ProviderKind::Arcee,
2309 "trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking",
2310 ) => DEFAULT_ARCEE_MODEL.to_string(),
2311 (ProviderKind::Arcee, "trinity-mini" | "arcee-trinity-mini") => {
2312 ARCEE_TRINITY_MINI_MODEL.to_string()
2313 }
2314 (ProviderKind::Arcee, "arcee-trinity-large-preview") => {
2315 ARCEE_TRINITY_LARGE_PREVIEW_MODEL.to_string()
2316 }
2317 (
2318 ProviderKind::Moonshot,
2319 "kimi"
2320 | "kimi-k2"
2321 | "kimi-k2.7"
2322 | "kimi-k2-7"
2323 | "kimi-k2.7-code"
2324 | "kimi-k2-7-code"
2325 | "kimi-code"
2326 | "moonshot-kimi-k2.7-code",
2327 ) => DEFAULT_MOONSHOT_MODEL.to_string(),
2328 (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
2329 MOONSHOT_KIMI_K2_6_MODEL.to_string()
2330 }
2331 (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
2332 DEFAULT_SGLANG_MODEL.to_string()
2333 }
2334 (
2335 ProviderKind::Sglang,
2336 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2337 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2338 ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
2339 (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
2340 DEFAULT_VLLM_MODEL.to_string()
2341 }
2342 (
2343 ProviderKind::Vllm,
2344 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2345 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2346 ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
2347 (ProviderKind::Huggingface, "deepseek-v4-pro" | "deepseek-v4pro") => {
2348 DEFAULT_HUGGINGFACE_MODEL.to_string()
2349 }
2350 (
2351 ProviderKind::Huggingface,
2352 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2353 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2354 ) => DEFAULT_HUGGINGFACE_FLASH_MODEL.to_string(),
2355 (ProviderKind::Together, "deepseek-v4-pro" | "deepseek-v4pro") => {
2356 DEFAULT_TOGETHER_MODEL.to_string()
2357 }
2358 (
2359 ProviderKind::Together,
2360 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2361 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2362 ) => DEFAULT_TOGETHER_FLASH_MODEL.to_string(),
2363 (ProviderKind::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
2364 DEFAULT_DEEPINFRA_MODEL.to_string()
2365 }
2366 (
2367 ProviderKind::Deepinfra,
2368 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2369 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2370 ) => DEFAULT_DEEPINFRA_FLASH_MODEL.to_string(),
2371 _ => model.to_string(),
2372 }
2373}
2374
2375fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> {
2376 let normalized = model.trim().to_ascii_lowercase();
2377 let normalized = normalized.replace(['_', ' '], "-");
2378 match normalized.as_str() {
2379 "mimo"
2380 | DEFAULT_XIAOMI_MIMO_MODEL
2381 | "mimo-v2-5-pro"
2382 | "xiaomi-mimo-v2.5-pro"
2383 | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL),
2384 XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL
2385 | "mimo-v2-5-pro-ultraspeed"
2386 | "xiaomi-mimo-v2.5-pro-ultraspeed"
2387 | "xiaomi-mimo-v2-5-pro-ultraspeed"
2388 | "ultraspeed"
2389 | "pro-ultraspeed" => Some(XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL),
2390 "omni"
2391 | "mimo-omni"
2392 | "v2.5-omni"
2393 | "v25-omni"
2394 | "mimo-v2.5"
2395 | "mimo-v25"
2396 | "mimo-v2-5"
2397 | "mimo-v2.5-omni"
2398 | "mimo-v25-omni"
2399 | "mimo-v2-5-omni"
2400 | "xiaomi-mimo-v2.5"
2401 | "xiaomi-mimo-v2-5"
2402 | "xiaomi-mimo-v2.5-omni"
2403 | "xiaomi-mimo-v2-5-omni" => Some(XIAOMI_MIMO_V2_5_OMNI_MODEL),
2404 "asr" | "mimo-asr" | "mimo-v2.5-asr" | "speech-to-text" | "transcribe" => {
2405 Some(XIAOMI_MIMO_ASR_MODEL)
2406 }
2407 "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => {
2408 Some(XIAOMI_MIMO_TTS_MODEL)
2409 }
2410 "mimo-tts-voicedesign"
2411 | "mimo-voice-design"
2412 | "mimo-v25-tts-voicedesign"
2413 | "mimo-v2.5-tts-voicedesign"
2414 | "voicedesign"
2415 | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL),
2416 "mimo-tts-voiceclone"
2417 | "mimo-voice-clone"
2418 | "mimo-v25-tts-voiceclone"
2419 | "mimo-v2.5-tts-voiceclone"
2420 | "voiceclone"
2421 | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL),
2422 "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL),
2423 _ => None,
2424 }
2425}
2426
2427fn canonical_minimax_model_id(model: &str) -> Option<&'static str> {
2428 let normalized = model.trim().to_ascii_lowercase();
2429 let normalized = normalized.replace(['_', ' '], "-");
2430 match normalized.as_str() {
2431 "minimax" | "minimax-m3" | "minimax-m-3" | "minimax-m-3-thinking" => {
2432 Some(DEFAULT_MINIMAX_MODEL)
2433 }
2434 "minimax-m2.7" | "minimax-m2-7" | "minimax-m-2.7" | "minimax-m-2-7" => {
2435 Some(MINIMAX_M2_7_MODEL)
2436 }
2437 "minimax-m2.7-highspeed"
2438 | "minimax-m2-7-highspeed"
2439 | "minimax-m-2.7-highspeed"
2440 | "minimax-m-2-7-highspeed" => Some(MINIMAX_M2_7_HIGHSPEED_MODEL),
2441 "minimax-m2.5" | "minimax-m2-5" | "minimax-m-2.5" | "minimax-m-2-5" => {
2442 Some(MINIMAX_M2_5_MODEL)
2443 }
2444 "minimax-m2.5-highspeed"
2445 | "minimax-m2-5-highspeed"
2446 | "minimax-m-2.5-highspeed"
2447 | "minimax-m-2-5-highspeed" => Some(MINIMAX_M2_5_HIGHSPEED_MODEL),
2448 "minimax-m2.1" | "minimax-m2-1" | "minimax-m-2.1" | "minimax-m-2-1" => {
2449 Some(MINIMAX_M2_1_MODEL)
2450 }
2451 "minimax-m2.1-highspeed"
2452 | "minimax-m2-1-highspeed"
2453 | "minimax-m-2.1-highspeed"
2454 | "minimax-m-2-1-highspeed" => Some(MINIMAX_M2_1_HIGHSPEED_MODEL),
2455 "minimax-m2" | "minimax-m-2" => Some(MINIMAX_M2_MODEL),
2456 _ => None,
2457 }
2458}
2459
2460fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
2461 let normalized = model.trim().to_ascii_lowercase();
2462 let normalized = normalized.replace(['_', ' '], "-");
2463 match normalized.as_str() {
2464 "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
2465 "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(DEFAULT_ZAI_MODEL),
2466 "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
2467 _ => None,
2468 }
2469}
2470
2471fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
2472 let normalized = model.trim().to_ascii_lowercase();
2473 let normalized = normalized.replace(['_', ' '], "-");
2474 match normalized.as_str() {
2475 OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
2476 | "trinity"
2477 | "trinity-large-thinking"
2478 | "arcee-trinity"
2479 | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
2480 OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
2481 Some(OPENROUTER_GEMMA_4_31B_MODEL)
2482 }
2483 OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
2484 Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
2485 }
2486 OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
2487 Some(OPENROUTER_GLM_5_1_MODEL)
2488 }
2489 OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
2490 Some(OPENROUTER_GLM_5_2_MODEL)
2491 }
2492 OPENROUTER_KIMI_K2_7_CODE_MODEL
2493 | "kimi"
2494 | "kimi-k2"
2495 | "kimi-k2.7"
2496 | "kimi-k2-7"
2497 | "kimi-k2.7-code"
2498 | "kimi-k2-7-code"
2499 | "kimi-code"
2500 | "moonshot-kimi-k2.7-code"
2501 | "openrouter-kimi-k2.7-code" => Some(OPENROUTER_KIMI_K2_7_CODE_MODEL),
2502 OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
2503 Some(OPENROUTER_KIMI_K2_6_MODEL)
2504 }
2505 OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => {
2506 Some(OPENROUTER_MINIMAX_M3_MODEL)
2507 }
2508 OPENROUTER_MINIMAX_M2_7_MODEL
2509 | "minimax-2.7"
2510 | "minimax-2-7"
2511 | "minimax-m2.7"
2512 | "minimax-m2-7"
2513 | "minimax-m-2.7"
2514 | "minimax-m-2-7" => Some(OPENROUTER_MINIMAX_M2_7_MODEL),
2515 OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
2516 | "nemotron-3-nano-omni"
2517 | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
2518 OPENROUTER_QWEN_3_6_35B_A3B_MODEL
2519 | "qwen3.6-35b-a3b"
2520 | "qwen-3.6-35b-a3b"
2521 | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
2522 OPENROUTER_QWEN_3_6_FLASH_MODEL | "qwen3.6-flash" | "qwen-3.6-flash" => {
2523 Some(OPENROUTER_QWEN_3_6_FLASH_MODEL)
2524 }
2525 OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL
2526 | "qwen3.6-max-preview"
2527 | "qwen-3.6-max-preview"
2528 | "qwen-max-preview" => Some(OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
2529 OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
2530 Some(OPENROUTER_QWEN_3_6_27B_MODEL)
2531 }
2532 OPENROUTER_QWEN_3_6_PLUS_MODEL | "qwen3.6-plus" | "qwen-3.6-plus" => {
2533 Some(OPENROUTER_QWEN_3_6_PLUS_MODEL)
2534 }
2535 OPENROUTER_QWEN_3_7_MAX_MODEL | "qwen3.7-max" | "qwen-3.7-max" => {
2536 Some(OPENROUTER_QWEN_3_7_MAX_MODEL)
2537 }
2538 OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
2539 Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
2540 }
2541 OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
2542 | "mimo-v2.5-pro"
2543 | "mimo-v2-5-pro"
2544 | "xiaomi-mimo-v2.5-pro"
2545 | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
2546 OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
2547 | "mimo-v2.5"
2548 | "mimo-v2-5"
2549 | "xiaomi-mimo-v2.5"
2550 | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
2551 _ => None,
2552 }
2553}
2554
2555fn default_model_for_provider(provider: ProviderKind) -> &'static str {
2556 match provider {
2557 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
2558 ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_MODEL,
2559 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
2560 ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
2561 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
2562 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
2563 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
2564 ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
2565 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
2566 ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
2567 ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
2568 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_MODEL,
2569 ProviderKind::Arcee => DEFAULT_ARCEE_MODEL,
2570 ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
2571 ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
2572 ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
2573 ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
2574 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
2575 ProviderKind::Together => DEFAULT_TOGETHER_MODEL,
2576 ProviderKind::Qianfan => DEFAULT_QIANFAN_MODEL,
2577 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL,
2578 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_MODEL,
2579 ProviderKind::Openmodel => DEFAULT_OPENMODEL_MODEL,
2580 ProviderKind::Zai => DEFAULT_ZAI_MODEL,
2581 ProviderKind::Stepfun => DEFAULT_STEPFUN_MODEL,
2582 ProviderKind::Minimax => DEFAULT_MINIMAX_MODEL,
2583 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
2584 ProviderKind::Sakana => DEFAULT_SAKANA_MODEL,
2585 ProviderKind::LongCat => DEFAULT_LONGCAT_MODEL,
2586 ProviderKind::Custom => provider.provider().default_model(),
2588 }
2589}
2590
2591fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
2592 match provider {
2593 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
2594 ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
2595 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
2596 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
2597 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
2598 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
2599 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
2600 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
2601 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
2602 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
2603 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
2604 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
2605 ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL,
2606 ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL,
2607 ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
2608 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
2609 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
2610 ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
2611 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
2612 ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL,
2613 ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL,
2614 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
2615 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
2616 ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL,
2617 ProviderKind::Zai => DEFAULT_ZAI_BASE_URL,
2618 ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL,
2619 ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL,
2620 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
2621 ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL,
2622 ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL,
2623 ProviderKind::Custom => provider.provider().default_base_url(),
2625 }
2626}
2627
2628fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
2629 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
2630 normalized == DEFAULT_KIMI_CODE_BASE_URL
2631 || normalized == "https://api.kimi.com/coding"
2632 || normalized.starts_with("https://api.kimi.com/coding/")
2633}
2634
2635fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
2636 let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
2637 if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
2638 return None;
2639 }
2640 Some(match normalized.as_str() {
2641 "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => {
2642 DEFAULT_XIAOMI_MIMO_BASE_URL
2643 }
2644 "token-plan-cn"
2645 | "token-plan-china"
2646 | "token-plan-mainland"
2647 | "token-plan-mainland-china"
2648 | "cn"
2649 | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
2650 "token-plan-sgp"
2651 | "token-plan-sg"
2652 | "token-plan-singapore"
2653 | "sgp"
2654 | "sg"
2655 | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
2656 "token-plan-ams"
2657 | "token-plan-eu"
2658 | "token-plan-europe"
2659 | "token-plan-amsterdam"
2660 | "ams"
2661 | "eu"
2662 | "europe"
2663 | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
2664 _ => DEFAULT_XIAOMI_MIMO_BASE_URL,
2665 })
2666}
2667
2668fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
2669 matches!(
2670 normalized_mode,
2671 "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
2672 )
2673}
2674
2675fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
2676 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
2677 normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
2678 || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL
2679 || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
2680}
2681
2682fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option<String> {
2683 candidates.iter().find_map(|name| {
2684 std::env::var(name)
2685 .ok()
2686 .filter(|value| !value.trim().is_empty())
2687 })
2688}
2689
2690fn xiaomi_mimo_env_api_key_for_runtime(
2691 mode: Option<&str>,
2692 base_url: Option<&str>,
2693) -> Option<String> {
2694 const TOKEN_PLAN_ENV_VARS: &[&str] =
2695 &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"];
2696 const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"];
2697
2698 let normalized_mode =
2699 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
2700 let standard_selected = normalized_mode
2701 .as_deref()
2702 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint)
2703 || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go);
2704 if standard_selected {
2705 return xiaomi_mimo_env_var(STANDARD_ENV_VARS);
2706 }
2707
2708 let token_plan_selected = normalized_mode
2709 .as_deref()
2710 .and_then(xiaomi_mimo_base_url_for_mode)
2711 .is_some()
2712 || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan);
2713 if token_plan_selected {
2714 return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS);
2715 }
2716
2717 xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS))
2718}
2719
2720fn resolve_xiaomi_mimo_base_url(
2721 configured: Option<String>,
2722 api_key: Option<&str>,
2723 mode: Option<&str>,
2724) -> String {
2725 let normalized_mode =
2726 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
2727 let uses_standard_mode = normalized_mode
2728 .as_deref()
2729 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint);
2730 let mode_base_url = normalized_mode
2731 .as_deref()
2732 .and_then(xiaomi_mimo_base_url_for_mode);
2733 let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key);
2734 match configured {
2735 Some(base_url) if uses_standard_mode => base_url,
2736 Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => {
2737 mode_base_url
2738 .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL)
2739 .to_string()
2740 }
2741 Some(base_url) => base_url,
2742 None => {
2743 if let Some(base_url) = mode_base_url {
2744 base_url.to_string()
2745 } else if uses_standard_mode {
2746 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
2747 } else if uses_token_plan || api_key.is_none() {
2748 DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()
2749 } else {
2750 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
2751 }
2752 }
2753 }
2754}
2755
2756fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
2757 api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
2758}
2759
2760fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool {
2761 matches!(
2762 base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
2763 "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
2764 )
2765}
2766
2767fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
2768 if provider.is_siliconflow() && siliconflow_base_url_is_official(base_url) {
2769 return false;
2770 }
2771 if provider == ProviderKind::XiaomiMimo
2772 && (xiaomi_mimo_base_url_uses_token_plan(base_url)
2773 || xiaomi_mimo_base_url_is_pay_as_you_go(base_url))
2774 {
2775 return false;
2776 }
2777 let actual = base_url.trim_end_matches('/');
2778 let default = default_base_url_for_provider(provider).trim_end_matches('/');
2779 actual != default
2780}
2781
2782fn siliconflow_base_url_is_official(base_url: &str) -> bool {
2783 matches!(
2784 base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
2785 "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
2786 )
2787}
2788
2789fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
2790 base_url_is_custom_for_provider(provider, base_url)
2791}
2792
2793fn should_skip_secret_store_for_provider(
2794 provider: ProviderKind,
2795 base_url: &str,
2796 auth_mode: Option<&str>,
2797) -> bool {
2798 if auth_mode_requires_api_key(auth_mode) {
2799 return false;
2800 }
2801 if auth_mode_disables_api_key(auth_mode) {
2802 return true;
2803 }
2804
2805 matches!(
2806 provider,
2807 ProviderKind::Sglang | ProviderKind::Vllm | ProviderKind::Ollama
2808 ) || base_url_uses_local_host(base_url)
2809}
2810
2811fn env_api_key_for_provider(provider: ProviderKind) -> Option<String> {
2812 if provider == ProviderKind::Huggingface {
2813 return std::env::var("HUGGINGFACE_API_KEY")
2814 .ok()
2815 .filter(|value| !value.trim().is_empty())
2816 .or_else(|| {
2817 std::env::var("HF_TOKEN")
2818 .ok()
2819 .filter(|value| !value.trim().is_empty())
2820 });
2821 }
2822
2823 codewhale_secrets::env_for(provider.as_str())
2824}
2825
2826fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
2827 matches!(
2828 auth_mode
2829 .map(str::trim)
2830 .filter(|value| !value.is_empty())
2831 .map(|value| value.to_ascii_lowercase()),
2832 Some(value)
2833 if matches!(
2834 value.as_str(),
2835 "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
2836 )
2837 )
2838}
2839
2840fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
2841 matches!(
2842 auth_mode
2843 .map(str::trim)
2844 .filter(|value| !value.is_empty())
2845 .map(|value| value.to_ascii_lowercase()),
2846 Some(value)
2847 if matches!(
2848 value.as_str(),
2849 "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
2850 )
2851 )
2852}
2853
2854fn auth_mode_uses_kimi_oauth(auth_mode: &str) -> bool {
2855 matches!(
2856 auth_mode
2857 .trim()
2858 .to_ascii_lowercase()
2859 .replace('-', "_")
2860 .as_str(),
2861 "kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
2862 )
2863}
2864
2865fn base_url_uses_local_host(base_url: &str) -> bool {
2866 let Some(host) = base_url_host(base_url) else {
2867 return false;
2868 };
2869 let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
2870 if matches!(host.as_str(), "localhost" | "0.0.0.0") {
2871 return true;
2872 }
2873 host.parse::<std::net::IpAddr>()
2874 .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
2875}
2876
2877fn base_url_host(base_url: &str) -> Option<&str> {
2878 let without_scheme = base_url
2879 .split_once("://")
2880 .map_or(base_url, |(_, rest)| rest);
2881 let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
2882 if let Some(rest) = authority.strip_prefix('[') {
2883 return rest.split_once(']').map(|(host, _)| host);
2884 }
2885 authority.split(':').next().filter(|host| !host.is_empty())
2886}
2887
2888#[derive(Debug, Clone, Default)]
2889pub struct CliRuntimeOverrides {
2890 pub provider: Option<ProviderKind>,
2891 pub model: Option<String>,
2892 pub api_key: Option<String>,
2893 pub base_url: Option<String>,
2894 pub auth_mode: Option<String>,
2895 pub output_mode: Option<String>,
2896 pub log_level: Option<String>,
2897 pub telemetry: Option<bool>,
2898 pub approval_policy: Option<String>,
2899 pub sandbox_mode: Option<String>,
2900 pub yolo: Option<bool>,
2901 pub verbosity: Option<String>,
2902}
2903
2904#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2905pub enum RuntimeApiKeySource {
2906 Cli,
2907 ConfigFile,
2908 Keyring,
2909 Env,
2910}
2911
2912impl RuntimeApiKeySource {
2913 #[must_use]
2914 pub fn as_env_value(self) -> &'static str {
2915 match self {
2916 Self::Cli => "cli",
2917 Self::ConfigFile => "config",
2918 Self::Keyring => "keyring",
2919 Self::Env => "env",
2920 }
2921 }
2922}
2923
2924#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2925pub enum ProviderSource {
2926 Cli,
2927 Env(&'static str),
2928 Config,
2929}
2930
2931#[derive(Debug, Clone)]
2932pub struct ResolvedRuntimeOptions {
2933 pub provider: ProviderKind,
2934 pub provider_source: ProviderSource,
2935 pub model: String,
2936 pub api_key: Option<String>,
2937 pub api_key_source: Option<RuntimeApiKeySource>,
2938 pub base_url: String,
2939 pub auth_mode: Option<String>,
2940 pub insecure_skip_tls_verify: bool,
2941 pub output_mode: Option<String>,
2942 pub log_level: Option<String>,
2943 pub telemetry: bool,
2944 pub approval_policy: Option<String>,
2945 pub sandbox_mode: Option<String>,
2946 pub yolo: Option<bool>,
2947 pub verbosity: Option<String>,
2948 pub http_headers: BTreeMap<String, String>,
2949}
2950
2951#[derive(Debug, Clone)]
2952pub struct ConfigStore {
2953 path: PathBuf,
2954 pub config: ConfigToml,
2955 permissions: PermissionsToml,
2956 original_raw: Option<String>,
2959}
2960
2961impl ConfigStore {
2962 pub fn load(path: Option<PathBuf>) -> Result<Self> {
2963 let path = resolve_config_path(path)?;
2964 let (config, original_raw) = if checked_path_exists(&path)? {
2965 let raw = read_checked_config_file(&path)?;
2966 let parsed: ConfigToml = toml::from_str(&raw)
2967 .with_context(|| format!("failed to parse config at {}", path.display()))?;
2968 (parsed, Some(raw))
2969 } else {
2970 (ConfigToml::default(), None)
2971 };
2972 let permissions = load_sibling_permissions(&path)?;
2973
2974 Ok(Self {
2975 path,
2976 config,
2977 permissions,
2978 original_raw,
2979 })
2980 }
2981
2982 pub fn rendered_body(&self) -> Result<String> {
2988 let serialized =
2989 toml::to_string_pretty(&self.config).context("failed to serialize config")?;
2990 if let Some(ref original_raw) = self.original_raw {
2991 Ok(
2992 merge_and_preserve_comments(&serialized, original_raw).unwrap_or_else(|e| {
2993 tracing::warn!("failed to merge config comments, saving without them: {e:#}");
2994 serialized
2995 }),
2996 )
2997 } else {
2998 Ok(serialized)
2999 }
3000 }
3001
3002 pub fn save(&self) -> Result<()> {
3003 let path = normalize_config_file_path(self.path.clone())?;
3004 if let Some(parent) = path.parent() {
3005 fs::create_dir_all(parent).with_context(|| {
3006 format!("failed to create config directory {}", parent.display())
3007 })?;
3008 }
3009 let body = self.rendered_body()?;
3010 if checked_path_exists(&path)? {
3011 let existing = read_checked_config_file(&path)?;
3012 if existing == body {
3013 return Ok(());
3014 }
3015 write_one_time_config_backup(&path)?;
3016 }
3017 #[cfg(unix)]
3018 {
3019 let mut file = fs::OpenOptions::new()
3020 .write(true)
3021 .create(true)
3022 .truncate(true)
3023 .mode(0o600)
3024 .open(&path)
3025 .with_context(|| format!("failed to write config at {}", path.display()))?;
3026 file.write_all(body.as_bytes())
3027 .with_context(|| format!("failed to write config at {}", path.display()))?;
3028 file.set_permissions(fs::Permissions::from_mode(0o600))
3029 .with_context(|| {
3030 format!("failed to set config permissions at {}", path.display())
3031 })?;
3032 }
3033 #[cfg(not(unix))]
3034 {
3035 fs::write(&path, body)
3036 .with_context(|| format!("failed to write config at {}", path.display()))?;
3037 }
3038 Ok(())
3039 }
3040
3041 #[must_use]
3042 pub fn path(&self) -> &Path {
3043 &self.path
3044 }
3045
3046 #[must_use]
3047 pub fn permissions(&self) -> &PermissionsToml {
3048 &self.permissions
3049 }
3050
3051 #[must_use]
3052 pub fn permissions_path(&self) -> PathBuf {
3053 checked_permissions_path_for_config_path(&self.path)
3054 .expect("ConfigStore path is validated before construction")
3055 }
3056
3057 #[must_use]
3058 pub fn exec_policy_engine(&self) -> ExecPolicyEngine {
3059 if self.permissions.is_empty() {
3060 ExecPolicyEngine::new(Vec::new(), Vec::new())
3061 } else {
3062 ExecPolicyEngine::with_rulesets(vec![self.permissions.ruleset()])
3063 }
3064 }
3065
3066 pub fn append_ask_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
3073 if rules.is_empty() {
3074 return Ok(0);
3075 }
3076
3077 let path = checked_permissions_path_for_config_path(&self.path)?;
3078 let raw = if checked_path_exists(&path)? {
3079 read_checked_permissions_file(&path)?
3080 } else {
3081 String::new()
3082 };
3083 let mut permissions = if raw.trim().is_empty() {
3084 PermissionsToml::default()
3085 } else {
3086 toml::from_str(&raw)
3087 .with_context(|| format!("failed to parse permissions at {}", path.display()))?
3088 };
3089 let mut document = if raw.trim().is_empty() {
3090 toml_edit::DocumentMut::new()
3091 } else {
3092 raw.parse::<toml_edit::DocumentMut>()
3093 .with_context(|| format!("failed to edit permissions at {}", path.display()))?
3094 };
3095
3096 if !document.contains_key("rules") {
3097 document["rules"] = toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
3098 }
3099 let rules_item = document
3100 .get_mut("rules")
3101 .expect("rules entry was inserted above");
3102
3103 let mut added = 0;
3104 for rule in rules {
3105 if permissions.rules.contains(rule) {
3106 continue;
3107 }
3108 append_ask_rule(rules_item, rule)?;
3109 permissions.rules.push(rule.clone());
3110 added += 1;
3111 }
3112 if added == 0 {
3113 self.permissions = permissions;
3114 return Ok(0);
3115 }
3116
3117 let body = document.to_string();
3118 let persisted: PermissionsToml = toml::from_str(&body).with_context(|| {
3119 format!(
3120 "generated invalid permissions document for {}",
3121 path.display()
3122 )
3123 })?;
3124 write_permissions_atomic(&path, body.as_bytes())?;
3125 self.permissions = persisted;
3126 Ok(added)
3127 }
3128}
3129
3130fn config_backup_file_name(path: &Path) -> OsString {
3131 let mut file_name = path
3132 .file_name()
3133 .map(OsString::from)
3134 .unwrap_or_else(|| OsString::from(CONFIG_FILE_NAME));
3135 file_name.push(".bak");
3136 file_name
3137}
3138
3139fn config_sibling_path_unchecked(config_path: &Path, file_name: &OsStr) -> PathBuf {
3140 config_path
3141 .parent()
3142 .unwrap_or_else(|| Path::new("."))
3143 .join(file_name)
3144}
3145
3146fn checked_config_sibling_path(config_path: &Path, file_name: &OsStr) -> Result<PathBuf> {
3147 let config_path = normalize_config_file_path(config_path.to_path_buf())?;
3148 let parent = config_path
3149 .parent()
3150 .context("config path must include a parent directory")?;
3151 let path = parent.join(file_name);
3152 reject_path_symlink(&path)?;
3153 Ok(path)
3154}
3155
3156#[cfg(test)]
3157fn config_backup_path(path: &Path) -> PathBuf {
3158 config_sibling_path_unchecked(path, &config_backup_file_name(path))
3159}
3160
3161fn checked_config_backup_path(path: &Path) -> Result<PathBuf> {
3162 checked_config_sibling_path(path, &config_backup_file_name(path))
3163}
3164
3165fn write_one_time_config_backup(path: &Path) -> Result<()> {
3166 let backup = checked_config_backup_path(path)?;
3167 if backup.exists() {
3168 return Ok(());
3169 }
3170 fs::copy(path, &backup).with_context(|| {
3171 format!(
3172 "failed to create config backup {} from {}",
3173 backup.display(),
3174 path.display()
3175 )
3176 })?;
3177 #[cfg(unix)]
3178 {
3179 fs::set_permissions(&backup, fs::Permissions::from_mode(0o600)).with_context(|| {
3180 format!(
3181 "failed to set config backup permissions at {}",
3182 backup.display()
3183 )
3184 })?;
3185 }
3186 Ok(())
3187}
3188
3189pub fn merge_and_preserve_comments(serialized: &str, original_raw: &str) -> Result<String> {
3197 let original = original_raw
3198 .parse::<toml_edit::DocumentMut>()
3199 .context("failed to parse original config for comment merge")?;
3200
3201 let mut new_doc = serialized
3202 .parse::<toml_edit::DocumentMut>()
3203 .context("failed to parse serialized config for comment merge")?;
3204
3205 new_doc.set_trailing(original.trailing().clone());
3208
3209 *new_doc.as_table_mut().decor_mut() = original.as_table().decor().clone();
3212
3213 merge_decor_table(new_doc.as_table_mut(), original.as_table());
3214
3215 Ok(new_doc.to_string())
3216}
3217
3218fn merge_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
3221 let keys: Vec<String> = source.iter().map(|(k, _)| k.to_owned()).collect();
3224 for key in &keys {
3225 let Some((source_key, source_item)) = source.get_key_value(key) else {
3226 continue;
3227 };
3228 let Some((mut target_key_mut, target_item)) = target.get_key_value_mut(key) else {
3229 continue;
3230 };
3231
3232 *target_key_mut.leaf_decor_mut() = source_key.leaf_decor().clone();
3234
3235 copy_item_decor(target_item, source_item);
3236
3237 if let (Some(tt), Some(st)) = (target_item.as_table_mut(), source_item.as_table()) {
3238 merge_decor_table(tt, st);
3239 }
3240
3241 if let (Some(ta), Some(sa)) = (
3242 target_item.as_array_of_tables_mut(),
3243 source_item.as_array_of_tables(),
3244 ) {
3245 for (i, source_table) in sa.iter().enumerate() {
3246 if let Some(target_table) = ta.get_mut(i) {
3247 copy_item_decor_table(target_table, source_table);
3248 merge_decor_table(target_table, source_table);
3249 }
3250 }
3251 }
3252 }
3253}
3254
3255fn copy_item_decor(target: &mut toml_edit::Item, source: &toml_edit::Item) {
3259 match (target, source) {
3260 (toml_edit::Item::Table(tt), toml_edit::Item::Table(st)) => {
3261 *tt.decor_mut() = st.decor().clone();
3262 }
3263 (toml_edit::Item::Value(tv), toml_edit::Item::Value(sv)) => {
3264 *tv.decor_mut() = sv.decor().clone();
3265 }
3266 _ => {}
3267 }
3268}
3269
3270fn copy_item_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
3271 *target.decor_mut() = source.decor().clone();
3272}
3273
3274pub fn default_secrets() -> &'static Secrets {
3279 static SECRETS: OnceLock<Secrets> = OnceLock::new();
3280 SECRETS.get_or_init(|| {
3281 #[cfg(test)]
3286 {
3287 Secrets::new(std::sync::Arc::new(
3288 codewhale_secrets::InMemoryKeyringStore::new(),
3289 ))
3290 }
3291 #[cfg(not(test))]
3292 {
3293 Secrets::auto_detect()
3294 }
3295 })
3296}
3297
3298pub const CODEWHALE_APP_DIR: &str = ".codewhale";
3307
3308pub const LEGACY_APP_DIR: &str = ".deepseek";
3310
3311pub fn codewhale_home() -> Result<PathBuf> {
3316 if let Some(path) = codewhale_home_env_override() {
3317 return Ok(path);
3318 }
3319 let home = effective_home_dir().context("failed to resolve home directory")?;
3320 Ok(home.join(CODEWHALE_APP_DIR))
3321}
3322
3323fn codewhale_home_env_override() -> Option<PathBuf> {
3324 let val = std::env::var("CODEWHALE_HOME").ok()?;
3325 let trimmed = val.trim();
3326 if trimmed.is_empty() {
3327 None
3328 } else {
3329 Some(PathBuf::from(trimmed))
3330 }
3331}
3332
3333pub fn codewhale_home_is_explicit() -> bool {
3338 codewhale_home_env_override().is_some()
3339}
3340
3341pub fn legacy_deepseek_home() -> Result<PathBuf> {
3345 let home = effective_home_dir().context("failed to resolve home directory")?;
3346 Ok(home.join(LEGACY_APP_DIR))
3347}
3348
3349fn effective_home_dir() -> Option<PathBuf> {
3350 std::env::var_os("HOME")
3351 .filter(|value| !value.is_empty())
3352 .map(PathBuf::from)
3353 .or_else(dirs::home_dir)
3354}
3355
3356fn ensure_safe_state_subdir(subdir: &str) -> Result<()> {
3364 if subdir.is_empty() {
3365 bail!("state subdir must not be empty");
3366 }
3367 let path = std::path::Path::new(subdir);
3368 if path.is_absolute() {
3369 bail!("state subdir must not be an absolute path: {subdir}");
3370 }
3371 if path.components().any(|c| {
3372 matches!(
3373 c,
3374 std::path::Component::RootDir | std::path::Component::Prefix(_)
3375 )
3376 }) {
3377 bail!("state subdir must not contain a root or prefix: {subdir}");
3378 }
3379 if path
3380 .components()
3381 .any(|c| matches!(c, std::path::Component::ParentDir))
3382 {
3383 bail!("state subdir must not contain parent-dir (..) components: {subdir}");
3384 }
3385 Ok(())
3386}
3387
3388pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
3395 ensure_safe_state_subdir(subdir)?;
3396 let explicit_codewhale_home = codewhale_home_env_override().is_some();
3397 let primary = codewhale_home()?.join(subdir);
3398 if explicit_codewhale_home || primary.exists() {
3399 return Ok(primary);
3400 }
3401 let legacy = legacy_deepseek_home()?.join(subdir);
3402 if legacy.exists() {
3403 return Ok(legacy);
3404 }
3405 Ok(primary)
3407}
3408
3409pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
3419 let (dir, migration) = ensure_state_dir_with_migration(subdir)?;
3420 if let Some(migration) = migration {
3421 eprintln!("{}", migration.user_notice());
3422 }
3423 Ok(dir)
3424}
3425
3426#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3427pub enum StateMigrationKind {
3428 Relocated,
3429 Copied,
3430}
3431
3432#[derive(Debug, Clone, PartialEq, Eq)]
3433pub struct StateMigration {
3434 pub subdir: String,
3435 pub legacy_path: PathBuf,
3436 pub primary_path: PathBuf,
3437 pub kind: StateMigrationKind,
3438}
3439
3440impl StateMigration {
3441 pub fn user_notice(&self) -> String {
3442 let action = match self.kind {
3443 StateMigrationKind::Relocated => "relocated",
3444 StateMigrationKind::Copied => "copied",
3445 };
3446 let legacy_detail = match self.kind {
3447 StateMigrationKind::Relocated => {
3448 "The legacy .deepseek copy for this state path was removed by the move."
3449 }
3450 StateMigrationKind::Copied => {
3451 "The legacy .deepseek copy was left in place because a direct move failed."
3452 }
3453 };
3454
3455 format!(
3456 "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.",
3457 self.legacy_path.display(),
3458 self.primary_path.display(),
3459 )
3460 }
3461}
3462
3463pub fn ensure_state_dir_with_migration(subdir: &str) -> Result<(PathBuf, Option<StateMigration>)> {
3467 ensure_safe_state_subdir(subdir)?;
3468 let explicit_codewhale_home = codewhale_home_env_override().is_some();
3469 let dir = codewhale_home()?.join(subdir);
3470 let migration = if !explicit_codewhale_home {
3471 migrate_legacy_state_dir(&dir, subdir)?
3472 } else {
3473 None
3474 };
3475 std::fs::create_dir_all(&dir)
3476 .with_context(|| format!("failed to create {}/", dir.display()))?;
3477 Ok((dir, migration))
3478}
3479
3480fn migrate_legacy_state_dir(primary: &Path, subdir: &str) -> Result<Option<StateMigration>> {
3485 if primary.exists() || subdir == "." || subdir.is_empty() {
3486 return Ok(None);
3487 }
3488 let legacy = match legacy_deepseek_home() {
3489 Ok(home) => home.join(subdir),
3490 Err(_) => return Ok(None),
3491 };
3492 if !legacy.exists() {
3493 return Ok(None);
3494 }
3495 if let Some(parent) = primary.parent()
3497 && let Err(err) = std::fs::create_dir_all(parent)
3498 {
3499 tracing::warn!(
3500 target: "config::migration",
3501 "Could not create {} for state migration ({}); writing to primary anyway",
3502 parent.display(),
3503 err
3504 );
3505 }
3506 match std::fs::rename(&legacy, primary) {
3507 Ok(()) => {
3508 tracing::info!(
3509 target: "config::migration",
3510 "Migrated legacy state directory {} -> {} (relocated). The .deepseek copy was removed.",
3511 legacy.display(),
3512 primary.display()
3513 );
3514 return Ok(Some(StateMigration {
3515 subdir: subdir.to_string(),
3516 legacy_path: legacy,
3517 primary_path: primary.to_path_buf(),
3518 kind: StateMigrationKind::Relocated,
3519 }));
3520 }
3521 Err(err) => {
3522 match copy_dir_recursive(&legacy, primary) {
3527 Ok(()) => {
3528 tracing::info!(
3529 target: "config::migration",
3530 "Migrated legacy state directory {} -> {} (copied; rename failed: {err}). \
3531 The legacy .deepseek copy was left in place.",
3532 legacy.display(),
3533 primary.display()
3534 );
3535 return Ok(Some(StateMigration {
3536 subdir: subdir.to_string(),
3537 legacy_path: legacy,
3538 primary_path: primary.to_path_buf(),
3539 kind: StateMigrationKind::Copied,
3540 }));
3541 }
3542 Err(copy_err) => {
3543 tracing::warn!(
3544 target: "config::migration",
3545 "Could not migrate legacy state {} -> {} (rename: {err}; copy: {copy_err}). \
3546 New data is written to the primary path; the legacy tree remains untouched.",
3547 legacy.display(),
3548 primary.display()
3549 );
3550 }
3551 }
3552 }
3553 }
3554 Ok(None)
3555}
3556
3557fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
3560 std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
3561 for entry in
3562 std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
3563 {
3564 let entry = entry.with_context(|| format!("failed to read entry in {}", src.display()))?;
3565 let path = entry.path();
3566 let target = dst.join(entry.file_name());
3567 let file_type = entry
3568 .file_type()
3569 .with_context(|| format!("failed to read file type for {}", path.display()))?;
3570 if file_type.is_dir() {
3571 copy_dir_recursive(&path, &target)?;
3572 } else if file_type.is_file() {
3573 std::fs::copy(&path, &target).with_context(|| {
3574 format!("failed to copy {} -> {}", path.display(), target.display())
3575 })?;
3576 }
3577 }
3578 Ok(())
3579}
3580
3581pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> Result<(bool, PathBuf)> {
3588 ensure_safe_state_subdir(subdir)?;
3589 let workspace = normalize_project_workspace(workspace)?;
3590 let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
3591 if primary.exists() {
3592 return Ok((true, primary));
3593 }
3594 let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
3595 Ok((false, legacy))
3596}
3597
3598pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
3601 ensure_safe_state_subdir(subdir)?;
3602 let workspace = normalize_project_workspace(workspace)?;
3603 let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
3604 std::fs::create_dir_all(&dir)
3605 .with_context(|| format!("failed to create {}/", dir.display()))?;
3606 Ok(dir)
3607}
3608
3609pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
3610 if let Some(path) = explicit {
3611 return normalize_config_file_path(path);
3612 }
3613 if let Ok(path) = std::env::var("CODEWHALE_CONFIG_PATH") {
3614 if let Some(path) = config_path_from_env_value(&path)? {
3615 return Ok(path);
3616 }
3617 return default_config_path();
3618 }
3619 if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
3620 if let Some(path) = config_path_from_env_value(&path)? {
3621 return Ok(path);
3622 }
3623 return default_config_path();
3624 }
3625 default_config_path()
3626}
3627
3628fn config_path_from_env_value(path: &str) -> Result<Option<PathBuf>> {
3629 let trimmed = path.trim();
3630 if trimmed.is_empty() {
3631 Ok(None)
3632 } else {
3633 normalize_config_file_path(PathBuf::from(trimmed)).map(Some)
3634 }
3635}
3636
3637#[must_use]
3638pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
3639 config_sibling_path_unchecked(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
3640}
3641
3642fn checked_permissions_path_for_config_path(config_path: &Path) -> Result<PathBuf> {
3643 checked_config_sibling_path(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
3644}
3645
3646pub fn resolve_permissions_path(config_path: Option<PathBuf>) -> Result<PathBuf> {
3647 checked_permissions_path_for_config_path(&resolve_config_path(config_path)?)
3648}
3649
3650pub fn read_permissions_file(path: &Path) -> Result<String> {
3653 read_checked_permissions_file(path)
3654}
3655
3656fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
3657 let permissions_path = checked_permissions_path_for_config_path(config_path)?;
3658 if !checked_path_exists(&permissions_path)? {
3659 return Ok(PermissionsToml::default());
3660 }
3661
3662 let raw = read_checked_permissions_file(&permissions_path)?;
3663 toml::from_str(&raw).with_context(|| {
3664 format!(
3665 "failed to parse permissions at {}",
3666 permissions_path.display()
3667 )
3668 })
3669}
3670
3671fn append_ask_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
3672 match item {
3673 toml_edit::Item::ArrayOfTables(rules) => {
3674 rules.push(ask_rule_table(rule));
3675 Ok(())
3676 }
3677 toml_edit::Item::Value(value) => {
3678 let Some(rules) = value.as_array_mut() else {
3679 bail!("`rules` in permissions.toml must be an array");
3680 };
3681 rules.push(toml_edit::Value::InlineTable(ask_rule_inline_table(rule)));
3682 Ok(())
3683 }
3684 _ => bail!("`rules` in permissions.toml must be an array"),
3685 }
3686}
3687
3688fn ask_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
3689 let mut table = toml_edit::Table::new();
3690 table["tool"] = toml_edit::value(rule.tool.clone());
3691 if let Some(command) = rule.command.as_deref() {
3692 table["command"] = toml_edit::value(command);
3693 }
3694 if let Some(path) = rule.path.as_deref() {
3695 table["path"] = toml_edit::value(path);
3696 }
3697 table
3698}
3699
3700fn ask_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
3701 let mut table = toml_edit::InlineTable::new();
3702 table.insert("tool", toml_edit::Value::from(rule.tool.clone()));
3703 if let Some(command) = rule.command.as_deref() {
3704 table.insert("command", toml_edit::Value::from(command));
3705 }
3706 if let Some(path) = rule.path.as_deref() {
3707 table.insert("path", toml_edit::Value::from(path));
3708 }
3709 table
3710}
3711
3712fn write_permissions_atomic(path: &Path, body: &[u8]) -> Result<()> {
3713 let parent = path.parent().with_context(|| {
3714 format!(
3715 "permissions path has no parent directory: {}",
3716 path.display()
3717 )
3718 })?;
3719 fs::create_dir_all(parent).with_context(|| {
3720 format!(
3721 "failed to create permissions directory {}",
3722 parent.display()
3723 )
3724 })?;
3725
3726 let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
3727 format!(
3728 "failed to create temporary permissions file in {}",
3729 parent.display()
3730 )
3731 })?;
3732 #[cfg(unix)]
3733 temporary
3734 .as_file()
3735 .set_permissions(fs::Permissions::from_mode(0o600))
3736 .with_context(|| {
3737 format!(
3738 "failed to secure temporary permissions file for {}",
3739 path.display()
3740 )
3741 })?;
3742 temporary
3743 .write_all(body)
3744 .with_context(|| format!("failed to write permissions at {}", path.display()))?;
3745 temporary
3746 .as_file()
3747 .sync_all()
3748 .with_context(|| format!("failed to sync permissions at {}", path.display()))?;
3749 temporary
3750 .persist(path)
3751 .map_err(|error| error.error)
3752 .with_context(|| format!("failed to replace permissions at {}", path.display()))?;
3753 Ok(())
3754}
3755
3756pub fn default_config_path() -> Result<PathBuf> {
3757 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
3760 if codewhale_home_is_explicit() || primary.exists() {
3761 return Ok(primary);
3762 }
3763 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
3764 if legacy.exists() {
3765 return Ok(legacy);
3766 }
3767 Ok(primary)
3769}
3770
3771#[derive(Debug, Clone, PartialEq, Eq)]
3772pub struct ConfigMigration {
3773 pub legacy_path: PathBuf,
3774 pub primary_path: PathBuf,
3775}
3776
3777impl ConfigMigration {
3778 pub fn user_notice(&self) -> String {
3779 format!(
3780 "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.",
3781 self.legacy_path.display(),
3782 self.primary_path.display()
3783 )
3784 }
3785}
3786
3787pub fn migrate_config_if_needed() -> Result<Option<ConfigMigration>> {
3792 if codewhale_home_is_explicit() {
3793 return Ok(None);
3794 }
3795 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
3796 if primary.exists() {
3797 return Ok(None);
3798 }
3799 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
3800 if !legacy.exists() {
3801 return Ok(None);
3802 }
3803 if let Some(parent) = primary.parent() {
3805 std::fs::create_dir_all(parent).context("failed to create codewhale config directory")?;
3806 }
3807 std::fs::copy(&legacy, &primary)
3808 .context("failed to migrate config from deepseek to codewhale home")?;
3809 tracing::info!(
3810 "Migrated config from {} to {}",
3811 legacy.display(),
3812 primary.display()
3813 );
3814 Ok(Some(ConfigMigration {
3815 legacy_path: legacy,
3816 primary_path: primary,
3817 }))
3818}
3819
3820fn parse_bool(raw: &str) -> Result<bool> {
3821 match raw.trim().to_ascii_lowercase().as_str() {
3822 "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
3823 "0" | "false" | "no" | "off" | "disabled" => Ok(false),
3824 _ => bail!("invalid boolean '{raw}'"),
3825 }
3826}
3827
3828fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
3829 let mut headers = BTreeMap::new();
3830 for pair in raw.trim().split(',') {
3831 let pair = pair.trim();
3832 if pair.is_empty() {
3833 continue;
3834 }
3835 let Some((name, value)) = pair.split_once('=') else {
3836 bail!("invalid header pair '{pair}', expected name=value");
3837 };
3838 let name = name.trim();
3839 let value = value.trim();
3840 if name.is_empty() {
3841 bail!("header name cannot be empty");
3842 }
3843 if value.is_empty() {
3844 continue;
3845 }
3846 headers.insert(name.to_string(), value.to_string());
3847 }
3848 Ok(headers)
3849}
3850
3851fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
3852 if headers.is_empty() {
3853 return None;
3854 }
3855 Some(
3856 headers
3857 .iter()
3858 .map(|(name, value)| format!("{name}={value}"))
3859 .collect::<Vec<_>>()
3860 .join(","),
3861 )
3862}
3863
3864fn serialize_http_headers_for_display(headers: &BTreeMap<String, String>) -> Option<String> {
3865 if headers.is_empty() {
3866 return None;
3867 }
3868 Some(
3869 headers
3870 .iter()
3871 .map(|(name, value)| {
3872 let display_value = if is_sensitive_config_key(name) {
3873 redact_secret(value)
3874 } else {
3875 value.clone()
3876 };
3877 format!("{name}={display_value}")
3878 })
3879 .collect::<Vec<_>>()
3880 .join(","),
3881 )
3882}
3883
3884fn redact_secret(secret: &str) -> String {
3885 let chars: Vec<char> = secret.chars().collect();
3886 if chars.len() <= 16 {
3887 return "********".to_string();
3888 }
3889 let prefix: String = chars.iter().take(4).collect();
3890 let suffix: String = chars
3891 .iter()
3892 .rev()
3893 .take(4)
3894 .collect::<Vec<_>>()
3895 .into_iter()
3896 .rev()
3897 .collect();
3898 format!("{prefix}***{suffix}")
3899}
3900
3901#[must_use]
3902pub fn is_sensitive_config_key(key: &str) -> bool {
3903 let Some(segment) = key.rsplit('.').next() else {
3904 return false;
3905 };
3906 let normalized = segment
3907 .trim()
3908 .trim_matches('"')
3909 .replace('-', "_")
3910 .to_ascii_lowercase();
3911
3912 matches!(
3913 normalized.as_str(),
3914 "api_key"
3915 | "apikey"
3916 | "api_keys"
3917 | "authorization"
3918 | "bearer"
3919 | "client_secret"
3920 | "credential"
3921 | "credentials"
3922 | "id_token"
3923 | "password"
3924 | "passwords"
3925 | "passwd"
3926 | "proxy_authorization"
3927 | "refresh_token"
3928 | "secret"
3929 | "secrets"
3930 | "token"
3931 | "tokens"
3932 ) || normalized.ends_with("_api_key")
3933 || normalized.ends_with("_authorization")
3934 || normalized.ends_with("_password")
3935 || normalized.ends_with("_secret")
3936 || normalized.ends_with("_token")
3937}
3938
3939fn redact_toml_value_for_display(key: &str, value: &toml::Value) -> String {
3940 redact_toml_value_for_display_inner(key, false, value).to_string()
3941}
3942
3943fn toml_value_as_u64(value: &toml::Value) -> Option<u64> {
3944 match value {
3945 toml::Value::Integer(value) => u64::try_from(*value).ok(),
3946 toml::Value::String(value) => value.trim().parse().ok(),
3947 _ => None,
3948 }
3949}
3950
3951fn redact_toml_value_for_display_inner(
3952 key: &str,
3953 sensitive_ancestor: bool,
3954 value: &toml::Value,
3955) -> toml::Value {
3956 let sensitive = sensitive_ancestor || is_sensitive_config_key(key);
3957 match value {
3958 toml::Value::String(value) if sensitive => toml::Value::String(redact_secret(value)),
3959 toml::Value::Array(values) => toml::Value::Array(
3960 values
3961 .iter()
3962 .map(|value| redact_toml_value_for_display_inner(key, sensitive, value))
3963 .collect(),
3964 ),
3965 toml::Value::Table(table) => {
3966 let mut redacted = toml::map::Map::new();
3967 for (child_key, child_value) in table {
3968 let path = if key.is_empty() {
3969 child_key.clone()
3970 } else {
3971 format!("{key}.{child_key}")
3972 };
3973 redacted.insert(
3974 child_key.clone(),
3975 redact_toml_value_for_display_inner(&path, sensitive, child_value),
3976 );
3977 }
3978 toml::Value::Table(redacted)
3979 }
3980 _ if sensitive => toml::Value::String("********".to_string()),
3981 _ => value.clone(),
3982 }
3983}
3984
3985fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
3986 if path.as_os_str().is_empty() {
3987 bail!("config path cannot be empty");
3988 }
3989 if path
3990 .components()
3991 .any(|component| matches!(component, Component::ParentDir))
3992 {
3993 bail!("config path cannot contain '..' components");
3994 }
3995 if path.file_name().is_none() {
3996 bail!("config path must include a file name");
3997 }
3998 let absolute = if path.is_absolute() {
3999 path
4000 } else {
4001 std::env::current_dir()
4002 .context("failed to resolve current directory for config path")?
4003 .join(path)
4004 };
4005 let file_name = absolute
4006 .file_name()
4007 .map(OsString::from)
4008 .context("config path must include a file name")?;
4009 let parent = absolute
4010 .parent()
4011 .context("config path must include a parent directory")?;
4012 let parent = match parent.canonicalize() {
4013 Ok(parent) => parent,
4014 Err(err) if err.kind() == std::io::ErrorKind::NotFound => parent.to_path_buf(),
4015 Err(err) => {
4016 return Err(err).with_context(|| {
4017 format!("failed to resolve config directory {}", parent.display())
4018 });
4019 }
4020 };
4021 let normalized = parent.join(file_name);
4022 reject_path_symlink(&normalized)?;
4023 Ok(normalized)
4024}
4025
4026fn normalize_project_workspace(workspace: &Path) -> Result<PathBuf> {
4027 if workspace.as_os_str().is_empty() {
4028 bail!("project workspace path cannot be empty");
4029 }
4030 if workspace
4031 .components()
4032 .any(|component| matches!(component, Component::ParentDir))
4033 {
4034 bail!("project workspace path cannot contain '..' components");
4035 }
4036 let absolute = if workspace.is_absolute() {
4037 workspace.to_path_buf()
4038 } else {
4039 std::env::current_dir()
4040 .context("failed to resolve current directory for project workspace")?
4041 .join(workspace)
4042 };
4043 match absolute.canonicalize() {
4044 Ok(path) => Ok(path),
4045 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
4046 Ok(normalize_path_components(&absolute))
4047 }
4048 Err(err) => Err(err).with_context(|| {
4049 format!(
4050 "failed to resolve project workspace {}",
4051 workspace.display()
4052 )
4053 }),
4054 }
4055}
4056
4057fn normalize_path_components(path: &Path) -> PathBuf {
4058 let mut normalized = PathBuf::new();
4059 for component in path.components() {
4060 match component {
4061 Component::Prefix(_) | Component::RootDir => normalized.push(component.as_os_str()),
4062 Component::CurDir => {}
4063 Component::ParentDir => {
4064 normalized.pop();
4065 }
4066 Component::Normal(part) => normalized.push(part),
4067 }
4068 }
4069 if normalized.as_os_str().is_empty() {
4070 PathBuf::from(".")
4071 } else {
4072 normalized
4073 }
4074}
4075
4076fn checked_path_exists(path: &Path) -> Result<bool> {
4077 let path = normalize_config_file_path(path.to_path_buf())?;
4078 path.try_exists()
4079 .with_context(|| format!("failed to inspect config path {}", path.display()))
4080}
4081
4082fn read_checked_config_file(path: &Path) -> Result<String> {
4083 read_checked_toml_file(path, "config")
4084}
4085
4086fn read_checked_permissions_file(path: &Path) -> Result<String> {
4087 read_checked_toml_file(path, "permissions")
4088}
4089
4090fn read_checked_toml_file(path: &Path, label: &str) -> Result<String> {
4091 let path = normalize_config_file_path(path.to_path_buf())?;
4092 read_string_no_follow(&path)
4093 .with_context(|| format!("failed to read {label} at {}", path.display()))
4094}
4095
4096#[cfg(unix)]
4097fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
4098 let mut file = fs::OpenOptions::new()
4099 .read(true)
4100 .custom_flags(libc::O_NOFOLLOW)
4101 .open(path)?;
4102 let mut raw = String::new();
4103 file.read_to_string(&mut raw)?;
4104 Ok(raw)
4105}
4106
4107#[cfg(not(unix))]
4108fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
4109 fs::read_to_string(path)
4110}
4111
4112fn reject_path_symlink(path: &Path) -> Result<()> {
4113 let Ok(metadata) = fs::symlink_metadata(path) else {
4114 return Ok(());
4115 };
4116 if metadata.file_type().is_symlink() {
4117 bail!("config path must not be a symlink: {}", path.display());
4118 }
4119 Ok(())
4120}
4121
4122#[derive(Debug, Clone, Default)]
4123struct EnvRuntimeOverrides {
4124 provider: Option<ProviderKind>,
4125 provider_source: Option<&'static str>,
4126 model: Option<String>,
4127 volcengine_model: Option<String>,
4128 wanjie_ark_model: Option<String>,
4129 openrouter_model: Option<String>,
4130 moonshot_model: Option<String>,
4131 xiaomi_mimo_model: Option<String>,
4132 xiaomi_mimo_mode: Option<String>,
4133 novita_model: Option<String>,
4134 fireworks_model: Option<String>,
4135 arcee_model: Option<String>,
4136 output_mode: Option<String>,
4137 auth_mode: Option<String>,
4138 log_level: Option<String>,
4139 telemetry: Option<bool>,
4140 approval_policy: Option<String>,
4141 sandbox_mode: Option<String>,
4142 yolo: Option<bool>,
4143 verbosity: Option<String>,
4144 http_headers: Option<BTreeMap<String, String>>,
4145 deepseek_base_url: Option<String>,
4146 deepseek_anthropic_base_url: Option<String>,
4147 nvidia_base_url: Option<String>,
4148 openai_base_url: Option<String>,
4149 atlascloud_base_url: Option<String>,
4150 volcengine_base_url: Option<String>,
4151 wanjie_ark_base_url: Option<String>,
4152 openrouter_base_url: Option<String>,
4153 xiaomi_mimo_base_url: Option<String>,
4154 novita_base_url: Option<String>,
4155 fireworks_base_url: Option<String>,
4156 siliconflow_base_url: Option<String>,
4157 siliconflow_model: Option<String>,
4158 arcee_base_url: Option<String>,
4159 moonshot_base_url: Option<String>,
4160 sglang_base_url: Option<String>,
4161 vllm_base_url: Option<String>,
4162 ollama_base_url: Option<String>,
4163 huggingface_base_url: Option<String>,
4164 huggingface_model: Option<String>,
4165 together_base_url: Option<String>,
4166 together_model: Option<String>,
4167 qianfan_base_url: Option<String>,
4168 qianfan_model: Option<String>,
4169 openai_codex_base_url: Option<String>,
4170 openai_codex_model: Option<String>,
4171 anthropic_base_url: Option<String>,
4172 anthropic_model: Option<String>,
4173 openmodel_base_url: Option<String>,
4174 openmodel_model: Option<String>,
4175 zai_base_url: Option<String>,
4176 zai_model: Option<String>,
4177 stepfun_base_url: Option<String>,
4178 stepfun_model: Option<String>,
4179 minimax_base_url: Option<String>,
4180 minimax_model: Option<String>,
4181 deepinfra_base_url: Option<String>,
4182 deepinfra_model: Option<String>,
4183 sakana_base_url: Option<String>,
4184 sakana_model: Option<String>,
4185 longcat_base_url: Option<String>,
4186 longcat_model: Option<String>,
4187}
4188
4189impl EnvRuntimeOverrides {
4190 fn load() -> Self {
4191 let (provider, provider_source) = Self::load_provider();
4192 Self {
4193 provider,
4194 provider_source,
4195 model: std::env::var("CODEWHALE_MODEL")
4196 .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
4197 .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
4198 .ok()
4199 .filter(|v| !v.trim().is_empty()),
4200 volcengine_model: std::env::var("VOLCENGINE_MODEL")
4201 .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
4202 .ok()
4203 .filter(|v| !v.trim().is_empty()),
4204 wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
4205 .or_else(|_| std::env::var("WANJIE_MODEL"))
4206 .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
4207 .ok()
4208 .filter(|v| !v.trim().is_empty()),
4209 openrouter_model: std::env::var("OPENROUTER_MODEL")
4210 .ok()
4211 .filter(|v| !v.trim().is_empty()),
4212 moonshot_model: std::env::var("MOONSHOT_MODEL")
4213 .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
4214 .or_else(|_| std::env::var("KIMI_MODEL"))
4215 .ok()
4216 .filter(|v| !v.trim().is_empty()),
4217 xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
4218 .or_else(|_| std::env::var("MIMO_MODEL"))
4219 .ok()
4220 .filter(|v| !v.trim().is_empty()),
4221 xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
4222 .or_else(|_| std::env::var("MIMO_MODE"))
4223 .ok()
4224 .filter(|v| !v.trim().is_empty()),
4225 novita_model: std::env::var("NOVITA_MODEL")
4226 .ok()
4227 .filter(|v| !v.trim().is_empty()),
4228 fireworks_model: std::env::var("FIREWORKS_MODEL")
4229 .ok()
4230 .filter(|v| !v.trim().is_empty()),
4231 arcee_model: std::env::var("ARCEE_MODEL")
4232 .ok()
4233 .filter(|v| !v.trim().is_empty()),
4234 verbosity: std::env::var("CODEWHALE_VERBOSITY")
4235 .or_else(|_| std::env::var("DEEPSEEK_VERBOSITY"))
4236 .ok(),
4237 output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(),
4238 auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(),
4239 log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(),
4240 telemetry: std::env::var("DEEPSEEK_TELEMETRY")
4241 .ok()
4242 .and_then(|v| match parse_bool(&v) {
4243 Ok(b) => Some(b),
4244 Err(_) => {
4245 tracing::warn!("Invalid DEEPSEEK_TELEMETRY value '{v}', expected true/false");
4246 None
4247 }
4248 }),
4249 approval_policy: std::env::var("DEEPSEEK_APPROVAL_POLICY").ok(),
4250 sandbox_mode: std::env::var("DEEPSEEK_SANDBOX_MODE").ok(),
4251 yolo: std::env::var("DEEPSEEK_YOLO")
4252 .ok()
4253 .and_then(|v| match parse_bool(&v) {
4254 Ok(b) => Some(b),
4255 Err(_) => {
4256 tracing::warn!("Invalid DEEPSEEK_YOLO value '{v}', expected true/false");
4257 None
4258 }
4259 }),
4260 http_headers: std::env::var("DEEPSEEK_HTTP_HEADERS")
4261 .ok()
4262 .and_then(|value| match parse_http_headers(&value) {
4263 Ok(h) => Some(h),
4264 Err(_) => {
4265 tracing::warn!("Invalid DEEPSEEK_HTTP_HEADERS value, expected format: header1=val1,header2=val2");
4266 None
4267 }
4268 })
4269 .filter(|headers| !headers.is_empty()),
4270 deepseek_base_url: std::env::var("CODEWHALE_BASE_URL")
4271 .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
4272 .ok()
4273 .filter(|v| !v.trim().is_empty()),
4274 deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
4275 .or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
4276 .ok()
4277 .filter(|v| !v.trim().is_empty()),
4278 nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
4279 .or_else(|_| std::env::var("NIM_BASE_URL"))
4280 .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
4281 .ok()
4282 .filter(|v| !v.trim().is_empty()),
4283 openai_base_url: std::env::var("OPENAI_BASE_URL")
4284 .ok()
4285 .filter(|v| !v.trim().is_empty()),
4286 atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
4287 .ok()
4288 .filter(|v| !v.trim().is_empty()),
4289 volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
4290 .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
4291 .or_else(|_| std::env::var("ARK_BASE_URL"))
4292 .ok()
4293 .filter(|v| !v.trim().is_empty()),
4294 wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
4295 .or_else(|_| std::env::var("WANJIE_BASE_URL"))
4296 .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
4297 .ok()
4298 .filter(|v| !v.trim().is_empty()),
4299 openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
4300 .ok()
4301 .filter(|v| !v.trim().is_empty()),
4302 xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
4303 .or_else(|_| std::env::var("MIMO_BASE_URL"))
4304 .ok()
4305 .filter(|v| !v.trim().is_empty()),
4306 novita_base_url: std::env::var("NOVITA_BASE_URL")
4307 .ok()
4308 .filter(|v| !v.trim().is_empty()),
4309 fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
4310 .ok()
4311 .filter(|v| !v.trim().is_empty()),
4312 siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
4313 .ok()
4314 .filter(|v| !v.trim().is_empty()),
4315 siliconflow_model: std::env::var("SILICONFLOW_MODEL")
4316 .ok()
4317 .filter(|v| !v.trim().is_empty()),
4318 arcee_base_url: std::env::var("ARCEE_BASE_URL")
4319 .ok()
4320 .filter(|v| !v.trim().is_empty()),
4321 moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
4322 .or_else(|_| std::env::var("KIMI_BASE_URL"))
4323 .ok()
4324 .filter(|v| !v.trim().is_empty()),
4325 sglang_base_url: std::env::var("SGLANG_BASE_URL")
4326 .ok()
4327 .filter(|v| !v.trim().is_empty()),
4328 vllm_base_url: std::env::var("VLLM_BASE_URL")
4329 .ok()
4330 .filter(|v| !v.trim().is_empty()),
4331 ollama_base_url: std::env::var("OLLAMA_BASE_URL")
4332 .ok()
4333 .filter(|v| !v.trim().is_empty()),
4334 huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
4335 .or_else(|_| std::env::var("HF_BASE_URL"))
4336 .ok()
4337 .filter(|v| !v.trim().is_empty()),
4338 huggingface_model: std::env::var("HUGGINGFACE_MODEL")
4339 .or_else(|_| std::env::var("HF_MODEL"))
4340 .ok()
4341 .filter(|v| !v.trim().is_empty()),
4342 together_base_url: std::env::var("TOGETHER_BASE_URL")
4343 .ok()
4344 .filter(|v| !v.trim().is_empty()),
4345 together_model: std::env::var("TOGETHER_MODEL")
4346 .ok()
4347 .filter(|v| !v.trim().is_empty()),
4348 qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
4349 .ok()
4350 .filter(|v| !v.trim().is_empty())
4351 .or_else(|| {
4352 std::env::var("BAIDU_QIANFAN_BASE_URL")
4353 .ok()
4354 .filter(|v| !v.trim().is_empty())
4355 }),
4356 qianfan_model: std::env::var("QIANFAN_MODEL")
4357 .ok()
4358 .filter(|v| !v.trim().is_empty())
4359 .or_else(|| {
4360 std::env::var("BAIDU_QIANFAN_MODEL")
4361 .ok()
4362 .filter(|v| !v.trim().is_empty())
4363 }),
4364 openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
4365 .or_else(|_| std::env::var("CODEX_BASE_URL"))
4366 .ok()
4367 .filter(|v| !v.trim().is_empty()),
4368 openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
4369 .or_else(|_| std::env::var("CODEX_MODEL"))
4370 .ok()
4371 .filter(|v| !v.trim().is_empty()),
4372 anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
4373 .ok()
4374 .filter(|v| !v.trim().is_empty()),
4375 anthropic_model: std::env::var("ANTHROPIC_MODEL")
4376 .ok()
4377 .filter(|v| !v.trim().is_empty()),
4378 openmodel_base_url: std::env::var("OPENMODEL_BASE_URL")
4379 .ok()
4380 .filter(|v| !v.trim().is_empty()),
4381 openmodel_model: std::env::var("OPENMODEL_MODEL")
4382 .ok()
4383 .filter(|v| !v.trim().is_empty()),
4384 zai_base_url: std::env::var("ZAI_BASE_URL")
4385 .or_else(|_| std::env::var("Z_AI_BASE_URL"))
4386 .or_else(|_| std::env::var("ZHIPU_BASE_URL"))
4387 .or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
4388 .or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
4389 .ok()
4390 .filter(|v| !v.trim().is_empty()),
4391 zai_model: std::env::var("ZAI_MODEL")
4392 .or_else(|_| std::env::var("Z_AI_MODEL"))
4393 .or_else(|_| std::env::var("ZHIPU_MODEL"))
4394 .or_else(|_| std::env::var("ZHIPUAI_MODEL"))
4395 .or_else(|_| std::env::var("BIGMODEL_MODEL"))
4396 .or_else(|_| std::env::var("GLM_MODEL"))
4397 .ok()
4398 .filter(|v| !v.trim().is_empty()),
4399 stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
4400 .or_else(|_| std::env::var("STEP_BASE_URL"))
4401 .ok()
4402 .filter(|v| !v.trim().is_empty()),
4403 stepfun_model: std::env::var("STEPFUN_MODEL")
4404 .or_else(|_| std::env::var("STEP_MODEL"))
4405 .ok()
4406 .filter(|v| !v.trim().is_empty()),
4407 minimax_base_url: std::env::var("MINIMAX_BASE_URL")
4408 .ok()
4409 .filter(|v| !v.trim().is_empty()),
4410 minimax_model: std::env::var("MINIMAX_MODEL")
4411 .ok()
4412 .filter(|v| !v.trim().is_empty()),
4413 deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
4414 .ok()
4415 .filter(|v| !v.trim().is_empty()),
4416 deepinfra_model: std::env::var("DEEPINFRA_MODEL")
4417 .ok()
4418 .filter(|v| !v.trim().is_empty()),
4419 sakana_base_url: std::env::var("SAKANA_BASE_URL")
4420 .ok()
4421 .filter(|v| !v.trim().is_empty()),
4422 sakana_model: std::env::var("SAKANA_MODEL")
4423 .ok()
4424 .filter(|v| !v.trim().is_empty()),
4425 longcat_base_url: std::env::var("LONGCAT_BASE_URL")
4426 .ok()
4427 .filter(|v| !v.trim().is_empty()),
4428 longcat_model: std::env::var("LONGCAT_MODEL")
4429 .ok()
4430 .filter(|v| !v.trim().is_empty()),
4431 }
4432 }
4433
4434 fn load_provider() -> (Option<ProviderKind>, Option<&'static str>) {
4435 if let Ok(value) = std::env::var("CODEWHALE_PROVIDER") {
4436 let parsed = ProviderKind::parse(&value);
4437 return (parsed, parsed.map(|_| "CODEWHALE_PROVIDER"));
4438 }
4439
4440 if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
4441 let parsed = ProviderKind::parse(&value);
4442 return (parsed, parsed.map(|_| "DEEPSEEK_PROVIDER"));
4443 }
4444
4445 (None, None)
4446 }
4447
4448 fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
4449 match provider {
4452 ProviderKind::Deepseek => self.deepseek_base_url.clone(),
4453 ProviderKind::DeepseekAnthropic => self.deepseek_anthropic_base_url.clone(),
4454 ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
4455 ProviderKind::Openai => self.openai_base_url.clone(),
4456 ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
4457 ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
4458 ProviderKind::Volcengine => self.volcengine_base_url.clone(),
4459 ProviderKind::Openrouter => self.openrouter_base_url.clone(),
4460 ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
4461 ProviderKind::Novita => self.novita_base_url.clone(),
4462 ProviderKind::Fireworks => self.fireworks_base_url.clone(),
4463 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
4464 self.siliconflow_base_url.clone()
4465 }
4466 ProviderKind::Arcee => self.arcee_base_url.clone(),
4467 ProviderKind::Moonshot => self.moonshot_base_url.clone(),
4468 ProviderKind::Sglang => self.sglang_base_url.clone(),
4469 ProviderKind::Vllm => self.vllm_base_url.clone(),
4470 ProviderKind::Ollama => self.ollama_base_url.clone(),
4471 ProviderKind::Huggingface => self.huggingface_base_url.clone(),
4472 ProviderKind::Together => self.together_base_url.clone(),
4473 ProviderKind::Qianfan => self.qianfan_base_url.clone(),
4474 ProviderKind::OpenaiCodex => self.openai_codex_base_url.clone(),
4475 ProviderKind::Anthropic => self.anthropic_base_url.clone(),
4476 ProviderKind::Openmodel => self.openmodel_base_url.clone(),
4477 ProviderKind::Zai => self.zai_base_url.clone(),
4478 ProviderKind::Stepfun => self.stepfun_base_url.clone(),
4479 ProviderKind::Minimax => self.minimax_base_url.clone(),
4480 ProviderKind::Deepinfra => self.deepinfra_base_url.clone(),
4481 ProviderKind::Sakana => self.sakana_base_url.clone(),
4482 ProviderKind::LongCat => self.longcat_base_url.clone(),
4483 ProviderKind::Custom => None,
4486 }
4487 }
4488
4489 fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
4490 let model = match provider {
4491 ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
4492 ProviderKind::Volcengine => self.volcengine_model.clone(),
4493 ProviderKind::Openrouter => self.openrouter_model.clone(),
4494 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
4495 self.siliconflow_model.clone()
4496 }
4497 ProviderKind::Arcee => self.arcee_model.clone(),
4498 ProviderKind::Moonshot => self.moonshot_model.clone(),
4499 ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
4500 ProviderKind::Novita => self.novita_model.clone(),
4501 ProviderKind::Fireworks => self.fireworks_model.clone(),
4502 ProviderKind::Huggingface => self.huggingface_model.clone(),
4503 ProviderKind::Together => self.together_model.clone(),
4504 ProviderKind::Qianfan => self.qianfan_model.clone(),
4505 ProviderKind::OpenaiCodex => self.openai_codex_model.clone(),
4506 ProviderKind::Anthropic => self.anthropic_model.clone(),
4507 ProviderKind::Openmodel => self.openmodel_model.clone(),
4508 ProviderKind::Zai => self.zai_model.clone(),
4509 ProviderKind::Stepfun => self.stepfun_model.clone(),
4510 ProviderKind::Minimax => self.minimax_model.clone(),
4511 ProviderKind::Deepinfra => self.deepinfra_model.clone(),
4512 ProviderKind::Sakana => self.sakana_model.clone(),
4513 ProviderKind::LongCat => self.longcat_model.clone(),
4514 _ => None,
4515 }?;
4516
4517 if provider_preserves_custom_base_url_model(provider, base_url) {
4518 Some(model.trim().to_string())
4519 } else {
4520 Some(normalize_model_for_provider(provider, &model))
4521 }
4522 }
4523}
4524
4525#[cfg(test)]
4526mod tests;