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