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