1pub mod app_mode;
2pub mod auth_source;
3pub mod auto_model;
4pub mod catalog;
5mod config_document;
6pub mod external_credentials;
7mod harness;
8pub mod model_reference;
9pub mod models_dev;
10pub mod persistence;
11pub mod pricing;
12pub mod provider;
13mod provider_defaults;
14mod provider_kind;
15pub mod route;
16pub mod setup_state;
17pub mod user_constitution;
18mod xai_credentials;
19pub use config_document::{
20 create_config_document, mutate_config_document, replace_config_document_if_unchanged,
21 set_config_document_value, unset_config_document_value,
22};
23pub use harness::{
24 HarnessCompactionStrategy, HarnessPosture, HarnessPostureKind, HarnessProfile,
25 HarnessSafetyPosture, HarnessToolSurface, built_in_harness_profiles,
26};
27pub use model_reference::{Modality, ModelReferenceCard, ModelReferenceDatabase};
28pub(crate) use provider_defaults::*;
29pub use provider_kind::ProviderKind;
30pub use setup_state::{
31 ConstitutionAuthoring, ConstitutionChoice, ConstitutionSource, ConstitutionValidity,
32 InheritedConfigFacts, RuntimePostureSource, SetupState, SetupStep, StepEntry, StepStatus,
33 TELEMETRY_NOTICE_VERSION,
34};
35pub use user_constitution::{
36 APPROX_BYTES_PER_TOKEN, AutonomyPreference, CacheProjection, ClauseOrigin, ClauseStatus,
37 ConstitutionClause, ConstitutionRecommendation, MigrationOutcome, MigrationReceipt,
38 MigrationRejection, Ratification, RatificationError, RecommendationParse,
39 USER_CONSTITUTION_SCHEMA_VERSION, USER_CONSTITUTION_SCHEMA_VERSION_V1, UntrustedDraftParse,
40 UserConstitution, UserConstitutionLoad,
41};
42pub use xai_credentials::{
43 LEGACY_XAI_OAUTH_FILE_NAME, XAI_OAUTH_GENERATION_PREFIX, XAI_OAUTH_GENERATION_SUFFIX,
44 XaiOAuthCredentialStore, XaiOAuthRevocation, clear_all_xai_oauth_credentials,
45 is_valid_xai_oauth_generation, legacy_xai_oauth_path, remove_xai_oauth_generation,
46 validate_xai_oauth_generation, with_xai_oauth_lifecycle_lock,
47 with_xai_oauth_revocation_transaction, xai_oauth_credentials_dir, xai_oauth_generation_path,
48};
49
50use std::collections::{BTreeMap, BTreeSet};
51use std::ffi::{OsStr, OsString};
52use std::fmt;
53use std::fs;
54#[cfg(unix)]
55use std::io::Read;
56use std::io::Write;
57use std::path::{Component, Path, PathBuf};
58use std::sync::OnceLock;
59
60use anyhow::{Context, Result, bail};
61pub use app_mode::AppMode;
62pub use auth_source::{AuthSourceKind, ProviderAuthSourceToml};
63pub use codewhale_execpolicy::ToolAskRule;
64use codewhale_execpolicy::{ExecPolicyEngine, PermissionAction, Ruleset};
65use codewhale_secrets::SecretSource;
66pub use codewhale_secrets::Secrets;
67pub use external_credentials::{
68 EXTERNAL_CREDENTIAL_CONSENT_VERSION, EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS,
69 ExternalCredentialAccess, ExternalCredentialConsentStatus, ExternalCredentialConsentToml,
70 ExternalCredentialReadGrant, ExternalCredentialSource, default_agy_credentials_path,
71 default_dsh_credentials_path, external_credential_consent_status, quote_os_path,
72 resolve_external_credential_path,
73};
74use serde::{Deserialize, Serialize};
75use sha2::{Digest as _, Sha256};
76
77#[cfg(unix)]
78use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
79
80pub const CONFIG_FILE_NAME: &str = "config.toml";
81pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml";
82
83pub const API_KEYRING_SENTINEL: &str = "__KEYRING__";
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum ConfigApiKeyValueKind {
89 Empty,
90 SecretStoreSentinel,
91 Literal,
92}
93
94#[must_use]
95pub fn classify_config_api_key_value(value: &str) -> ConfigApiKeyValueKind {
96 match value.trim() {
97 "" => ConfigApiKeyValueKind::Empty,
98 API_KEYRING_SENTINEL => ConfigApiKeyValueKind::SecretStoreSentinel,
99 _ => ConfigApiKeyValueKind::Literal,
100 }
101}
102
103fn http_headers_are_effectively_empty(headers: &BTreeMap<String, String>) -> bool {
104 !headers
105 .iter()
106 .any(|(name, value)| !name.trim().is_empty() && !value.trim().is_empty())
107}
108
109#[must_use]
115pub fn is_upstream_auth_header(name: &str) -> bool {
116 let name = name.trim();
117 is_sensitive_config_key(name) || name.eq_ignore_ascii_case("cookie")
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, Default)]
126pub struct ProviderConfigToml {
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub api_key: Option<String>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub base_url: Option<String>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub model: Option<String>,
133 #[serde(
134 default,
135 skip_serializing_if = "Option::is_none",
136 alias = "contextWindow",
137 alias = "context_window_tokens",
138 alias = "contextWindowTokens",
139 alias = "context_length",
140 alias = "contextLength"
141 )]
142 pub context_window: Option<u32>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub mode: Option<String>,
145 #[serde(
149 default,
150 skip_serializing_if = "Option::is_none",
151 alias = "api_style",
152 alias = "protocol",
153 alias = "wire_format",
154 alias = "dialect"
155 )]
156 pub wire: Option<String>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub auth_mode: Option<String>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub insecure_skip_tls_verify: Option<bool>,
161 #[serde(default, skip_serializing_if = "http_headers_are_effectively_empty")]
162 pub http_headers: BTreeMap<String, String>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub path_suffix: Option<String>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub auth: Option<ProviderAuthSourceToml>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub external_credentials: Option<ExternalCredentialConsentToml>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub oauth_credential_generation: Option<String>,
176 #[serde(flatten)]
179 pub extras: BTreeMap<String, toml::Value>,
180}
181
182impl ProviderConfigToml {
183 #[must_use]
184 pub fn is_empty(&self) -> bool {
185 let blank = |value: Option<&String>| value.is_none_or(|value| value.trim().is_empty());
186
187 blank(self.api_key.as_ref())
188 && blank(self.base_url.as_ref())
189 && blank(self.model.as_ref())
190 && self.context_window.is_none()
191 && blank(self.mode.as_ref())
192 && blank(self.wire.as_ref())
193 && blank(self.auth_mode.as_ref())
194 && self.insecure_skip_tls_verify.is_none()
195 && http_headers_are_effectively_empty(&self.http_headers)
196 && blank(self.path_suffix.as_ref())
197 && self.auth.is_none()
198 && self.external_credentials.is_none()
199 && self.oauth_credential_generation.is_none()
200 && self.extras.is_empty()
201 }
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, Default)]
205pub struct ProvidersToml {
206 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
207 pub deepseek: ProviderConfigToml,
208 #[serde(
209 default,
210 skip_serializing_if = "ProviderConfigToml::is_empty",
211 alias = "deepseek-anthropic",
212 alias = "deepseekAnthropic",
213 alias = "deepseek-claude",
214 alias = "deepseek_claude"
215 )]
216 pub deepseek_anthropic: ProviderConfigToml,
217 #[serde(
218 default,
219 skip_serializing_if = "ProviderConfigToml::is_empty",
220 alias = "nvidia-nim",
224 alias = "nvidia",
225 alias = "nim"
226 )]
227 pub nvidia_nim: ProviderConfigToml,
228 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
229 pub openai: ProviderConfigToml,
230 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
231 pub atlascloud: ProviderConfigToml,
232 #[serde(
233 default,
234 skip_serializing_if = "ProviderConfigToml::is_empty",
235 alias = "wanjie-ark",
236 alias = "wanjie",
237 alias = "ark-wanjie",
238 alias = "ark_wanjie"
239 )]
240 pub wanjie_ark: ProviderConfigToml,
241 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
242 pub volcengine: ProviderConfigToml,
243 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
244 pub openrouter: ProviderConfigToml,
245 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
246 pub orcarouter: ProviderConfigToml,
247 #[serde(
248 default,
249 skip_serializing_if = "ProviderConfigToml::is_empty",
250 alias = "xiaomi-mimo",
251 alias = "xiaomi",
252 alias = "mimo",
253 alias = "xiaomimimo"
254 )]
255 pub xiaomi_mimo: ProviderConfigToml,
256 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
257 pub novita: ProviderConfigToml,
258 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
259 pub fireworks: ProviderConfigToml,
260 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
261 pub siliconflow: ProviderConfigToml,
262 #[serde(
263 default,
264 skip_serializing_if = "ProviderConfigToml::is_empty",
265 alias = "siliconflow-CN",
266 alias = "siliconflow-cn"
267 )]
268 pub siliconflow_cn: ProviderConfigToml,
269 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
270 pub arcee: ProviderConfigToml,
271 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
272 pub moonshot: ProviderConfigToml,
273 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
274 pub sglang: ProviderConfigToml,
275 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
276 pub vllm: ProviderConfigToml,
277 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
278 pub ollama: ProviderConfigToml,
279 #[serde(
280 default,
281 skip_serializing_if = "ProviderConfigToml::is_empty",
282 alias = "ollama-cloud"
283 )]
284 pub ollama_cloud: ProviderConfigToml,
285 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
286 pub huggingface: ProviderConfigToml,
287 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
288 pub together: ProviderConfigToml,
289 #[serde(
290 default,
291 skip_serializing_if = "ProviderConfigToml::is_empty",
292 alias = "baidu-qianfan",
293 alias = "baidu_qianfan",
294 alias = "baidu"
295 )]
296 pub qianfan: ProviderConfigToml,
297 #[serde(
298 default,
299 skip_serializing_if = "ProviderConfigToml::is_empty",
300 alias = "openai-codex",
301 alias = "openai_codex",
302 alias = "codex",
303 alias = "chatgpt",
304 alias = "chatgpt-codex"
305 )]
306 pub openai_codex: ProviderConfigToml,
307 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
308 pub anthropic: ProviderConfigToml,
309 #[serde(
310 default,
311 skip_serializing_if = "ProviderConfigToml::is_empty",
312 alias = "open-model",
313 alias = "open_model"
314 )]
315 pub openmodel: ProviderConfigToml,
316 #[serde(
317 default,
318 skip_serializing_if = "ProviderConfigToml::is_empty",
319 alias = "z-ai",
320 alias = "z_ai",
321 alias = "z.ai",
322 alias = "zhipu",
323 alias = "zhipuai",
324 alias = "bigmodel",
325 alias = "big-model"
326 )]
327 pub zai: ProviderConfigToml,
328 #[serde(
329 default,
330 skip_serializing_if = "ProviderConfigToml::is_empty",
331 alias = "step-fun",
332 alias = "step_fun",
333 alias = "stepfun",
334 alias = "stepflash",
335 alias = "step-flash",
336 alias = "step_flash"
337 )]
338 pub stepfun: ProviderConfigToml,
339 #[serde(
340 default,
341 skip_serializing_if = "ProviderConfigToml::is_empty",
342 alias = "mini-max",
343 alias = "mini_max",
344 alias = "minimax"
345 )]
346 pub minimax: ProviderConfigToml,
347 #[serde(
348 default,
349 skip_serializing_if = "ProviderConfigToml::is_empty",
350 alias = "minimax-anthropic",
351 alias = "minimaxAnthropic",
352 alias = "mini-max-anthropic",
353 alias = "mini_max_anthropic"
354 )]
355 pub minimax_anthropic: ProviderConfigToml,
356 #[serde(
357 default,
358 skip_serializing_if = "ProviderConfigToml::is_empty",
359 alias = "deep-infra",
360 alias = "deep_infra"
361 )]
362 pub deepinfra: ProviderConfigToml,
363 #[serde(
364 default,
365 skip_serializing_if = "ProviderConfigToml::is_empty",
366 alias = "sakana-ai",
367 alias = "sakana_ai",
368 alias = "fugu"
369 )]
370 pub sakana: ProviderConfigToml,
371 #[serde(
372 default,
373 skip_serializing_if = "ProviderConfigToml::is_empty",
374 alias = "long-cat",
375 alias = "meituan-longcat",
376 alias = "meituan"
377 )]
378 pub longcat: ProviderConfigToml,
379 #[serde(
380 default,
381 skip_serializing_if = "ProviderConfigToml::is_empty",
382 alias = "opencode-go",
383 alias = "opencodego"
384 )]
385 pub opencode_go: ProviderConfigToml,
386 #[serde(
387 default,
388 skip_serializing_if = "ProviderConfigToml::is_empty",
389 alias = "opencode-zen",
390 alias = "opencodezen",
391 alias = "zen",
392 alias = "opencode"
393 )]
394 pub opencode_zen: ProviderConfigToml,
395 #[serde(
396 default,
397 skip_serializing_if = "ProviderConfigToml::is_empty",
398 alias = "meta-ai",
399 alias = "meta_ai",
400 alias = "meta-model-api",
401 alias = "meta_model_api",
402 alias = "muse",
403 alias = "muse-spark"
404 )]
405 pub meta: ProviderConfigToml,
406 #[serde(
407 default,
408 skip_serializing_if = "ProviderConfigToml::is_empty",
409 alias = "x-ai",
410 alias = "x_ai",
411 alias = "grok"
412 )]
413 pub xai: ProviderConfigToml,
414 #[serde(
415 default,
416 skip_serializing_if = "ProviderConfigToml::is_empty",
417 alias = "mistral-ai",
418 alias = "mistral_ai",
419 alias = "mistralai",
420 alias = "la-plateforme",
421 alias = "la_plateforme"
422 )]
423 pub mistral: ProviderConfigToml,
424 #[serde(
427 default,
428 skip_serializing_if = "ProviderConfigToml::is_empty",
429 alias = "google-gemini",
430 alias = "google_gemini",
431 alias = "gemini"
432 )]
433 pub google: ProviderConfigToml,
434 #[serde(
437 default,
438 skip_serializing_if = "ProviderConfigToml::is_empty",
439 alias = "agy"
440 )]
441 pub antigravity: ProviderConfigToml,
442 #[serde(
444 default,
445 skip_serializing_if = "ProviderConfigToml::is_empty",
446 alias = "telecom-js",
447 alias = "telecom_js",
448 alias = "telecomjs-cn",
449 alias = "tokenhub"
450 )]
451 pub telecomjs: ProviderConfigToml,
452 #[serde(
454 default,
455 skip_serializing_if = "ProviderConfigToml::is_empty",
456 alias = "eden-ai",
457 alias = "eden_ai"
458 )]
459 pub edenai: ProviderConfigToml,
460 #[serde(
462 default,
463 skip_serializing_if = "ProviderConfigToml::is_empty",
464 alias = "modelstudio-token-plan",
465 alias = "modelstudio_token_plan",
466 alias = "alibaba-token-plan",
467 alias = "dashscope-token-plan"
468 )]
469 pub modelstudio_token_plan: ProviderConfigToml,
470 #[serde(
472 default,
473 skip_serializing_if = "ProviderConfigToml::is_empty",
474 alias = "modelstudio-token-plan-anthropic",
475 alias = "modelstudio_token_plan_anthropic",
476 alias = "alibaba-token-plan-anthropic"
477 )]
478 pub modelstudio_token_plan_anthropic: ProviderConfigToml,
479 #[serde(
481 default,
482 skip_serializing_if = "ProviderConfigToml::is_empty",
483 alias = "modelstudio-coding-plan",
484 alias = "modelstudio_coding_plan",
485 alias = "alibaba-coding-plan",
486 alias = "dashscope-coding-plan"
487 )]
488 pub modelstudio_coding_plan: ProviderConfigToml,
489 #[serde(
491 default,
492 skip_serializing_if = "ProviderConfigToml::is_empty",
493 alias = "modelstudio-coding-plan-anthropic",
494 alias = "modelstudio_coding_plan_anthropic",
495 alias = "alibaba-coding-plan-anthropic"
496 )]
497 pub modelstudio_coding_plan_anthropic: ProviderConfigToml,
498 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
504 pub custom: ProviderConfigToml,
505 #[serde(flatten)]
508 pub extras: BTreeMap<String, toml::Value>,
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
517#[serde(deny_unknown_fields)]
518pub struct PermissionsToml {
519 #[serde(default, skip_serializing_if = "Vec::is_empty")]
520 pub rules: Vec<ToolAskRule>,
521}
522
523#[derive(Debug, Clone, Copy, PartialEq, Eq)]
525pub enum PermissionsFileState {
526 Missing,
528 Empty,
530 Present,
532}
533
534#[derive(Debug, Clone, PartialEq, Eq)]
541pub struct PermissionsSnapshot {
542 path: PathBuf,
543 file_state: PermissionsFileState,
544 permissions: PermissionsToml,
545 removal_tokens: Vec<String>,
546}
547
548impl PermissionsSnapshot {
549 #[must_use]
550 pub fn path(&self) -> &Path {
551 &self.path
552 }
553
554 #[must_use]
555 pub fn file_exists(&self) -> bool {
556 self.file_state != PermissionsFileState::Missing
557 }
558
559 #[must_use]
560 pub fn file_state(&self) -> PermissionsFileState {
561 self.file_state
562 }
563
564 #[must_use]
565 pub fn permissions(&self) -> &PermissionsToml {
566 &self.permissions
567 }
568
569 #[must_use]
570 pub fn rules(&self) -> &[ToolAskRule] {
571 &self.permissions.rules
572 }
573
574 #[must_use]
576 pub fn removal_token(&self, index: usize) -> Option<&str> {
577 self.removal_tokens.get(index).map(String::as_str)
578 }
579}
580
581impl PermissionsToml {
582 #[must_use]
583 pub fn is_empty(&self) -> bool {
584 self.rules.is_empty()
585 }
586
587 #[must_use]
588 pub fn ruleset(&self) -> Ruleset {
589 let mut denied = Vec::new();
590 let mut trusted = Vec::new();
591 let mut ask_rules = Vec::new();
592
593 for rule in &self.rules {
594 match rule.action {
595 PermissionAction::Deny => {
596 if let Some(cmd) = &rule.command
599 && !rule.command_exact
600 && rule.workspace.is_none()
601 {
602 denied.push(cmd.clone());
603 }
604 ask_rules.push(rule.clone());
606 }
607 PermissionAction::Allow => {
608 if let Some(cmd) = &rule.command
612 && !rule.command_exact
613 && rule.workspace.is_none()
614 {
615 trusted.push(cmd.clone());
616 }
617 ask_rules.push(rule.clone());
619 }
620 PermissionAction::Ask => {
621 ask_rules.push(rule.clone());
622 }
623 }
624 }
625
626 Ruleset::user(trusted, denied).with_ask_rules(ask_rules)
627 }
628}
629
630impl ProvidersToml {
631 #[must_use]
632 pub fn is_empty(&self) -> bool {
633 self.extras.is_empty()
634 && ProviderKind::all()
635 .iter()
636 .all(|provider| self.for_provider(*provider).is_empty())
637 }
638
639 #[must_use]
640 pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
641 match provider {
642 ProviderKind::Deepseek => &self.deepseek,
643 ProviderKind::DeepseekAnthropic => &self.deepseek_anthropic,
644 ProviderKind::NvidiaNim => &self.nvidia_nim,
645 ProviderKind::Openai => &self.openai,
646 ProviderKind::Atlascloud => &self.atlascloud,
647 ProviderKind::WanjieArk => &self.wanjie_ark,
648 ProviderKind::Volcengine => &self.volcengine,
649 ProviderKind::Openrouter => &self.openrouter,
650 ProviderKind::Orcarouter => &self.orcarouter,
651 ProviderKind::XiaomiMimo => &self.xiaomi_mimo,
652 ProviderKind::Novita => &self.novita,
653 ProviderKind::Fireworks => &self.fireworks,
654 ProviderKind::Siliconflow => &self.siliconflow,
655 ProviderKind::SiliconflowCN => &self.siliconflow_cn,
656 ProviderKind::Arcee => &self.arcee,
657 ProviderKind::Moonshot => &self.moonshot,
658 ProviderKind::Sglang => &self.sglang,
659 ProviderKind::Vllm => &self.vllm,
660 ProviderKind::Ollama => &self.ollama,
661 ProviderKind::OllamaCloud => &self.ollama_cloud,
662 ProviderKind::Huggingface => &self.huggingface,
663 ProviderKind::Together => &self.together,
664 ProviderKind::Qianfan => &self.qianfan,
665 ProviderKind::OpenaiCodex => &self.openai_codex,
666 ProviderKind::Anthropic => &self.anthropic,
667 ProviderKind::Openmodel => &self.openmodel,
668 ProviderKind::Zai => &self.zai,
669 ProviderKind::Stepfun => &self.stepfun,
670 ProviderKind::Minimax => &self.minimax,
671 ProviderKind::MinimaxAnthropic => &self.minimax_anthropic,
672 ProviderKind::Deepinfra => &self.deepinfra,
673 ProviderKind::Sakana => &self.sakana,
674 ProviderKind::LongCat => &self.longcat,
675 ProviderKind::OpencodeGo => &self.opencode_go,
676 ProviderKind::OpencodeZen => &self.opencode_zen,
677 ProviderKind::Meta => &self.meta,
678 ProviderKind::Xai => &self.xai,
679 ProviderKind::Mistral => &self.mistral,
680 ProviderKind::Google => &self.google,
681 ProviderKind::Antigravity => &self.antigravity,
682 ProviderKind::Telecomjs => &self.telecomjs,
683 ProviderKind::Edenai => &self.edenai,
684 ProviderKind::ModelstudioTokenPlan => &self.modelstudio_token_plan,
685 ProviderKind::ModelstudioTokenPlanAnthropic => &self.modelstudio_token_plan_anthropic,
686 ProviderKind::ModelstudioCodingPlan => &self.modelstudio_coding_plan,
687 ProviderKind::ModelstudioCodingPlanAnthropic => &self.modelstudio_coding_plan_anthropic,
688 ProviderKind::Custom => &self.custom,
689 }
690 }
691
692 pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
693 match provider {
694 ProviderKind::Deepseek => &mut self.deepseek,
695 ProviderKind::DeepseekAnthropic => &mut self.deepseek_anthropic,
696 ProviderKind::NvidiaNim => &mut self.nvidia_nim,
697 ProviderKind::Openai => &mut self.openai,
698 ProviderKind::Atlascloud => &mut self.atlascloud,
699 ProviderKind::WanjieArk => &mut self.wanjie_ark,
700 ProviderKind::Volcengine => &mut self.volcengine,
701 ProviderKind::Openrouter => &mut self.openrouter,
702 ProviderKind::Orcarouter => &mut self.orcarouter,
703 ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo,
704 ProviderKind::Novita => &mut self.novita,
705 ProviderKind::Fireworks => &mut self.fireworks,
706 ProviderKind::Siliconflow => &mut self.siliconflow,
707 ProviderKind::SiliconflowCN => &mut self.siliconflow_cn,
708 ProviderKind::Arcee => &mut self.arcee,
709 ProviderKind::Moonshot => &mut self.moonshot,
710 ProviderKind::Sglang => &mut self.sglang,
711 ProviderKind::Vllm => &mut self.vllm,
712 ProviderKind::Ollama => &mut self.ollama,
713 ProviderKind::OllamaCloud => &mut self.ollama_cloud,
714 ProviderKind::Huggingface => &mut self.huggingface,
715 ProviderKind::Together => &mut self.together,
716 ProviderKind::Qianfan => &mut self.qianfan,
717 ProviderKind::OpenaiCodex => &mut self.openai_codex,
718 ProviderKind::Anthropic => &mut self.anthropic,
719 ProviderKind::Openmodel => &mut self.openmodel,
720 ProviderKind::Zai => &mut self.zai,
721 ProviderKind::Stepfun => &mut self.stepfun,
722 ProviderKind::Minimax => &mut self.minimax,
723 ProviderKind::MinimaxAnthropic => &mut self.minimax_anthropic,
724 ProviderKind::Deepinfra => &mut self.deepinfra,
725 ProviderKind::Sakana => &mut self.sakana,
726 ProviderKind::LongCat => &mut self.longcat,
727 ProviderKind::OpencodeGo => &mut self.opencode_go,
728 ProviderKind::OpencodeZen => &mut self.opencode_zen,
729 ProviderKind::Meta => &mut self.meta,
730 ProviderKind::Xai => &mut self.xai,
731 ProviderKind::Mistral => &mut self.mistral,
732 ProviderKind::Google => &mut self.google,
733 ProviderKind::Antigravity => &mut self.antigravity,
734 ProviderKind::Telecomjs => &mut self.telecomjs,
735 ProviderKind::Edenai => &mut self.edenai,
736 ProviderKind::ModelstudioTokenPlan => &mut self.modelstudio_token_plan,
737 ProviderKind::ModelstudioTokenPlanAnthropic => {
738 &mut self.modelstudio_token_plan_anthropic
739 }
740 ProviderKind::ModelstudioCodingPlan => &mut self.modelstudio_coding_plan,
741 ProviderKind::ModelstudioCodingPlanAnthropic => {
742 &mut self.modelstudio_coding_plan_anthropic
743 }
744 ProviderKind::Custom => &mut self.custom,
745 }
746 }
747}
748
749fn deserialize_root_provider<'de, D>(deserializer: D) -> std::result::Result<ProviderKind, D::Error>
750where
751 D: serde::Deserializer<'de>,
752{
753 let value = String::deserialize(deserializer)?;
754 let strict = serde::de::value::StringDeserializer::<D::Error>::new(value);
755 Ok(ProviderKind::deserialize(strict).unwrap_or(ProviderKind::Custom))
756}
757
758#[derive(Debug, Clone, Serialize, Deserialize, Default)]
759pub struct ConfigToml {
760 pub api_key: Option<String>,
763 pub base_url: Option<String>,
765 #[serde(default, skip_serializing_if = "http_headers_are_effectively_empty")]
767 pub http_headers: BTreeMap<String, String>,
768 pub default_text_model: Option<String>,
770 #[serde(default, deserialize_with = "deserialize_root_provider")]
771 pub provider: ProviderKind,
772 #[doc(hidden)]
779 #[serde(skip)]
780 pub selected_provider_id: Option<String>,
781 pub model: Option<String>,
782 pub auth_mode: Option<String>,
783 pub output_mode: Option<String>,
784 pub verbosity: Option<String>,
785 pub log_level: Option<String>,
786 pub telemetry: Option<bool>,
787 pub telemetry_endpoint: Option<String>,
804 pub approval_policy: Option<String>,
805 pub sandbox_mode: Option<String>,
806 #[serde(default)]
808 pub tools: Option<ToolsToml>,
809 #[serde(default, skip_serializing_if = "ProvidersToml::is_empty")]
810 pub providers: ProvidersToml,
811 #[serde(default, skip_serializing_if = "Vec::is_empty")]
815 pub fallback_providers: Vec<ProviderKind>,
816 #[serde(default)]
819 pub network: Option<NetworkPolicyToml>,
820 #[serde(default)]
823 pub verifier: Option<VerifierConfigToml>,
824 #[serde(default)]
828 pub skills: Option<SkillsToml>,
829 #[serde(default)]
832 pub snapshots: Option<SnapshotsToml>,
833 #[serde(default)]
836 pub lsp: Option<LspConfigToml>,
837 #[serde(default)]
840 pub harness_profiles: Vec<HarnessProfile>,
841 #[serde(default, skip_serializing_if = "Option::is_none")]
844 pub hotbar: Option<Vec<HotbarBindingToml>>,
845 #[serde(default)]
848 pub hook_sinks: Option<HookSinksToml>,
849 #[serde(default)]
852 pub fleet: Option<FleetConfigToml>,
853 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
863 pub fleets: BTreeMap<String, NamedFleetConfigToml>,
864 #[serde(default)]
868 pub workflow: Option<WorkflowConfigToml>,
869 #[serde(flatten)]
870 pub extras: BTreeMap<String, toml::Value>,
871}
872
873#[derive(Debug, Clone, Copy, PartialEq, Eq)]
874enum ProviderConfigField {
875 ApiKey,
876 BaseUrl,
877 Model,
878 ContextWindow,
879 Mode,
880 Wire,
881 AuthMode,
882 InsecureSkipTlsVerify,
883 HttpHeaders,
884 PathSuffix,
885}
886
887impl ProviderConfigField {
888 fn parse(key: &str) -> Option<Self> {
889 Some(match key {
890 "api_key" => Self::ApiKey,
891 "base_url" => Self::BaseUrl,
892 "model" => Self::Model,
893 "context_window" | "context_window_tokens" => Self::ContextWindow,
894 "mode" => Self::Mode,
895 "wire" | "api_style" | "protocol" | "wire_format" | "dialect" => Self::Wire,
896 "auth_mode" => Self::AuthMode,
897 "insecure_skip_tls_verify" => Self::InsecureSkipTlsVerify,
898 "http_headers" => Self::HttpHeaders,
899 "path_suffix" => Self::PathSuffix,
900 _ => return None,
901 })
902 }
903
904 fn key(self) -> &'static str {
905 match self {
906 Self::ApiKey => "api_key",
907 Self::BaseUrl => "base_url",
908 Self::Model => "model",
909 Self::ContextWindow => "context_window",
910 Self::Mode => "mode",
911 Self::Wire => "wire",
912 Self::AuthMode => "auth_mode",
913 Self::InsecureSkipTlsVerify => "insecure_skip_tls_verify",
914 Self::HttpHeaders => "http_headers",
915 Self::PathSuffix => "path_suffix",
916 }
917 }
918}
919
920fn parse_provider_config_key(key: &str) -> Option<(ProviderKind, ProviderConfigField)> {
921 let suffix = key.strip_prefix("providers.")?;
922 let (provider_key, field_key) = suffix.split_once('.')?;
923 let field = ProviderConfigField::parse(field_key)?;
924 let provider = provider::all_providers()
927 .iter()
928 .map(|p| p.kind())
929 .find(|kind| kind.provider().provider_config_key() == provider_key)?;
930 Some((provider, field))
931}
932
933fn parse_custom_provider_config_key(key: &str) -> Option<(&str, &str)> {
937 let suffix = key.strip_prefix("providers.")?;
938 let (provider_id, field_key) = suffix.split_once('.')?;
939 (!provider_id.is_empty()).then_some((provider_id, field_key))
940}
941
942fn is_builtin_provider_config_id(provider_id: &str) -> bool {
943 provider::all_providers()
944 .iter()
945 .any(|p| p.provider_config_key() == provider_id)
946}
947
948const CUSTOM_PROVIDER_FIELD_HINT: &str = "api_key, base_url, model, context_window, mode, wire, auth_mode, \
951 insecure_skip_tls_verify, http_headers, path_suffix, kind";
952
953fn provider_config_key(provider: ProviderKind, field: ProviderConfigField) -> String {
954 format!(
955 "providers.{}.{}",
956 provider.provider().provider_config_key(),
957 field.key()
958 )
959}
960
961fn get_provider_config_value(
962 config: &ProviderConfigToml,
963 field: ProviderConfigField,
964) -> Option<String> {
965 match field {
966 ProviderConfigField::ApiKey => config.api_key.clone(),
967 ProviderConfigField::BaseUrl => config.base_url.clone(),
968 ProviderConfigField::Model => config.model.clone(),
969 ProviderConfigField::ContextWindow => config.context_window.map(|value| value.to_string()),
970 ProviderConfigField::Mode => config.mode.clone(),
971 ProviderConfigField::Wire => config.wire.clone(),
972 ProviderConfigField::AuthMode => config.auth_mode.clone(),
973 ProviderConfigField::InsecureSkipTlsVerify => config
974 .insecure_skip_tls_verify
975 .map(|value| value.to_string()),
976 ProviderConfigField::HttpHeaders => serialize_http_headers(&config.http_headers),
977 ProviderConfigField::PathSuffix => config.path_suffix.clone(),
978 }
979}
980
981fn get_provider_config_display_value(
982 config: &ProviderConfigToml,
983 field: ProviderConfigField,
984) -> Option<String> {
985 match field {
986 ProviderConfigField::ApiKey => config.api_key.as_deref().map(redact_secret),
987 ProviderConfigField::HttpHeaders => {
988 serialize_http_headers_for_display(&config.http_headers)
989 }
990 _ => get_provider_config_value(config, field),
991 }
992}
993
994fn parse_context_window(value: &str) -> Result<u32> {
995 let parsed = value.trim().parse::<u32>().with_context(|| {
996 format!("invalid context_window '{value}': expected a positive token count")
997 })?;
998 if parsed == 0 {
999 bail!("context_window must be greater than 0");
1000 }
1001 Ok(parsed)
1002}
1003
1004fn set_provider_config_value(
1005 config: &mut ConfigToml,
1006 provider: ProviderKind,
1007 field: ProviderConfigField,
1008 value: &str,
1009) -> Result<()> {
1010 match field {
1011 ProviderConfigField::ApiKey => {
1012 let value = value.to_string();
1013 config.providers.for_provider_mut(provider).api_key = Some(value.clone());
1014 if provider == ProviderKind::Deepseek {
1015 config.api_key = Some(value);
1016 }
1017 }
1018 ProviderConfigField::BaseUrl => {
1019 let value = value.to_string();
1020 config.providers.for_provider_mut(provider).base_url = Some(value.clone());
1021 if provider == ProviderKind::Deepseek {
1022 config.base_url = Some(value);
1023 }
1024 }
1025 ProviderConfigField::Model => {
1026 let value = value.to_string();
1027 config.providers.for_provider_mut(provider).model = Some(value.clone());
1028 if provider == ProviderKind::Deepseek {
1029 config.default_text_model = Some(value);
1030 }
1031 }
1032 ProviderConfigField::ContextWindow => {
1033 config.providers.for_provider_mut(provider).context_window =
1034 Some(parse_context_window(value)?);
1035 }
1036 ProviderConfigField::Mode => {
1037 config.providers.for_provider_mut(provider).mode = Some(value.to_string());
1038 }
1039 ProviderConfigField::Wire => {
1040 config.providers.for_provider_mut(provider).wire = Some(value.to_string());
1041 }
1042 ProviderConfigField::AuthMode => {
1043 config.providers.for_provider_mut(provider).auth_mode = Some(value.to_string());
1044 }
1045 ProviderConfigField::InsecureSkipTlsVerify => {
1046 config
1047 .providers
1048 .for_provider_mut(provider)
1049 .insecure_skip_tls_verify = Some(parse_bool(value)?);
1050 }
1051 ProviderConfigField::HttpHeaders => {
1052 let headers = parse_http_headers(value)?;
1053 config.providers.for_provider_mut(provider).http_headers = headers.clone();
1054 if provider == ProviderKind::Deepseek {
1055 config.http_headers = headers;
1056 }
1057 }
1058 ProviderConfigField::PathSuffix => {
1059 config.providers.for_provider_mut(provider).path_suffix = Some(value.to_string());
1060 }
1061 }
1062 Ok(())
1063}
1064
1065fn unset_provider_config_value(
1066 config: &mut ConfigToml,
1067 provider: ProviderKind,
1068 field: ProviderConfigField,
1069) {
1070 match field {
1071 ProviderConfigField::ApiKey => {
1072 config.providers.for_provider_mut(provider).api_key = None;
1073 if provider == ProviderKind::Deepseek {
1074 config.api_key = None;
1075 }
1076 }
1077 ProviderConfigField::BaseUrl => {
1078 config.providers.for_provider_mut(provider).base_url = None;
1079 if provider == ProviderKind::Deepseek {
1080 config.base_url = None;
1081 }
1082 }
1083 ProviderConfigField::Model => {
1084 config.providers.for_provider_mut(provider).model = None;
1085 if provider == ProviderKind::Deepseek {
1086 config.default_text_model = None;
1087 }
1088 }
1089 ProviderConfigField::ContextWindow => {
1090 config.providers.for_provider_mut(provider).context_window = None;
1091 }
1092 ProviderConfigField::Mode => {
1093 config.providers.for_provider_mut(provider).mode = None;
1094 }
1095 ProviderConfigField::Wire => {
1096 config.providers.for_provider_mut(provider).wire = None;
1097 }
1098 ProviderConfigField::AuthMode => {
1099 config.providers.for_provider_mut(provider).auth_mode = None;
1100 }
1101 ProviderConfigField::InsecureSkipTlsVerify => {
1102 config
1103 .providers
1104 .for_provider_mut(provider)
1105 .insecure_skip_tls_verify = None;
1106 }
1107 ProviderConfigField::HttpHeaders => {
1108 config
1109 .providers
1110 .for_provider_mut(provider)
1111 .http_headers
1112 .clear();
1113 if provider == ProviderKind::Deepseek {
1114 config.http_headers.clear();
1115 }
1116 }
1117 ProviderConfigField::PathSuffix => {
1118 config.providers.for_provider_mut(provider).path_suffix = None;
1119 }
1120 }
1121}
1122
1123fn insert_provider_config_values(
1124 out: &mut BTreeMap<String, String>,
1125 provider: ProviderKind,
1126 config: &ProviderConfigToml,
1127) {
1128 if let Some(v) = config.api_key.as_ref() {
1129 out.insert(
1130 provider_config_key(provider, ProviderConfigField::ApiKey),
1131 redact_secret(v),
1132 );
1133 }
1134 if let Some(v) = config.base_url.as_ref() {
1135 out.insert(
1136 provider_config_key(provider, ProviderConfigField::BaseUrl),
1137 v.clone(),
1138 );
1139 }
1140 if let Some(v) = config.model.as_ref() {
1141 out.insert(
1142 provider_config_key(provider, ProviderConfigField::Model),
1143 v.clone(),
1144 );
1145 }
1146 if let Some(v) = config.context_window {
1147 out.insert(
1148 provider_config_key(provider, ProviderConfigField::ContextWindow),
1149 v.to_string(),
1150 );
1151 }
1152 if let Some(v) = config.mode.as_ref() {
1153 out.insert(
1154 provider_config_key(provider, ProviderConfigField::Mode),
1155 v.clone(),
1156 );
1157 }
1158 if let Some(v) = config.auth_mode.as_ref() {
1159 out.insert(
1160 provider_config_key(provider, ProviderConfigField::AuthMode),
1161 v.clone(),
1162 );
1163 }
1164 if let Some(v) = config.insecure_skip_tls_verify {
1165 out.insert(
1166 provider_config_key(provider, ProviderConfigField::InsecureSkipTlsVerify),
1167 v.to_string(),
1168 );
1169 }
1170 if let Some(v) = serialize_http_headers_for_display(&config.http_headers) {
1171 out.insert(
1172 provider_config_key(provider, ProviderConfigField::HttpHeaders),
1173 v,
1174 );
1175 }
1176 if let Some(v) = config.path_suffix.as_ref() {
1177 out.insert(
1178 provider_config_key(provider, ProviderConfigField::PathSuffix),
1179 v.clone(),
1180 );
1181 }
1182}
1183
1184impl ConfigToml {
1185 #[must_use]
1191 pub fn resolve_harness_profile(
1192 &self,
1193 provider_route: &str,
1194 model: &str,
1195 ) -> Option<&HarnessProfile> {
1196 self.harness_profiles
1197 .iter()
1198 .chain(built_in_harness_profiles().iter())
1199 .find(|profile| profile.matches_route(provider_route, model))
1200 }
1201
1202 #[must_use]
1208 pub fn resolve_hotbar_bindings(&self, known_action_ids: &[&str]) -> HotbarConfigResolution {
1209 resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids)
1210 }
1211
1212 pub fn resolve_fleet(&self, name: &str) -> Result<&NamedFleetConfigToml, FleetResolutionError> {
1226 self.fleets
1227 .get(name)
1228 .ok_or_else(|| FleetResolutionError::UnknownFleet {
1229 name: name.to_string(),
1230 available: self.fleets.keys().cloned().collect(),
1231 })
1232 }
1233
1234 pub fn resolve_fleet_for_operator(
1251 &self,
1252 operator: &str,
1253 ) -> Result<(&str, &NamedFleetConfigToml), FleetResolutionError> {
1254 let matches: Vec<(&str, &NamedFleetConfigToml)> = self
1255 .fleets
1256 .iter()
1257 .filter(|(_, fleet)| fleet.operator == operator)
1258 .map(|(name, fleet)| (name.as_str(), fleet))
1259 .collect();
1260
1261 match matches.len() {
1262 0 => {
1263 let mut available: Vec<String> = self
1264 .fleets
1265 .values()
1266 .map(|f| f.operator.clone())
1267 .filter(|op| !op.is_empty())
1268 .collect::<std::collections::BTreeSet<_>>()
1269 .into_iter()
1270 .collect();
1271 available.sort();
1272 Err(FleetResolutionError::UnknownOperator {
1273 operator: operator.to_string(),
1274 available,
1275 })
1276 }
1277 1 => Ok(matches.into_iter().next().unwrap()),
1278 _ => Err(FleetResolutionError::AmbiguousOperator {
1279 operator: operator.to_string(),
1280 fleet_names: matches
1281 .iter()
1282 .map(|(name, _)| (*name).to_string())
1283 .collect(),
1284 }),
1285 }
1286 }
1287}
1288
1289#[derive(Debug, Clone, PartialEq, Eq)]
1294pub struct ProviderChain {
1295 providers: Vec<ProviderKind>,
1296 position: usize,
1297}
1298
1299pub const HOTBAR_SLOT_COUNT: u8 = 8;
1300
1301pub const DEFAULT_HOTBAR_ACTIONS: [&str; HOTBAR_SLOT_COUNT as usize] = [
1302 "voice.toggle",
1303 "session.compact",
1304 "mode.plan",
1305 "mode.agent",
1306 "mode.operate",
1307 "palette.open",
1308 "sidebar.toggle",
1309 "trust.toggle",
1310];
1311
1312#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1314#[serde(deny_unknown_fields)]
1315pub struct HotbarBindingToml {
1316 pub slot: u8,
1317 pub action: String,
1318 #[serde(default)]
1319 pub label: Option<String>,
1320}
1321
1322#[derive(Debug, Clone, PartialEq, Eq)]
1324pub struct HotbarBinding {
1325 pub slot: u8,
1326 pub action: String,
1327 pub label: Option<String>,
1328}
1329
1330#[derive(Debug, Clone, PartialEq, Eq)]
1333pub enum HotbarConfigWarning {
1334 SlotOutOfRange {
1335 slot: u8,
1336 action: String,
1337 },
1338 DuplicateSlot {
1339 slot: u8,
1340 previous_action: String,
1341 replacement_action: String,
1342 },
1343 UnknownAction {
1344 slot: u8,
1345 action: String,
1346 },
1347}
1348
1349impl fmt::Display for HotbarConfigWarning {
1350 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1351 match self {
1352 Self::SlotOutOfRange { slot, action } => write!(
1353 f,
1354 "hotbar slot {slot} for action '{action}' is outside 1-{HOTBAR_SLOT_COUNT}; skipped"
1355 ),
1356 Self::DuplicateSlot {
1357 slot,
1358 previous_action,
1359 replacement_action,
1360 } => write!(
1361 f,
1362 "hotbar slot {slot} was bound to '{previous_action}' more than once; using '{replacement_action}'"
1363 ),
1364 Self::UnknownAction { slot, action } => write!(
1365 f,
1366 "hotbar slot {slot} references unknown action '{action}'; keeping binding"
1367 ),
1368 }
1369 }
1370}
1371
1372#[derive(Debug, Clone, PartialEq, Eq)]
1373pub struct HotbarConfigResolution {
1374 pub bindings: Vec<HotbarBinding>,
1375 pub warnings: Vec<HotbarConfigWarning>,
1376}
1377
1378#[must_use]
1379pub fn default_hotbar_bindings() -> Vec<HotbarBinding> {
1380 DEFAULT_HOTBAR_ACTIONS
1381 .iter()
1382 .enumerate()
1383 .map(|(idx, action)| HotbarBinding {
1384 slot: u8::try_from(idx + 1).expect("default hotbar slot fits in u8"),
1385 action: (*action).to_string(),
1386 label: None,
1387 })
1388 .collect()
1389}
1390
1391#[must_use]
1397pub fn default_hotbar_bindings_toml() -> Vec<HotbarBindingToml> {
1398 default_hotbar_bindings()
1399 .into_iter()
1400 .map(|binding| HotbarBindingToml {
1401 slot: binding.slot,
1402 action: binding.action,
1403 label: binding.label,
1404 })
1405 .collect()
1406}
1407
1408#[must_use]
1409pub fn resolve_hotbar_bindings(
1410 configured: Option<&[HotbarBindingToml]>,
1411 known_action_ids: &[&str],
1412) -> HotbarConfigResolution {
1413 let known = known_action_ids.iter().copied().collect::<BTreeSet<&str>>();
1414 let mut warnings = Vec::new();
1415
1416 let source = match configured {
1417 Some(bindings) => bindings
1418 .iter()
1419 .map(|binding| HotbarBinding {
1420 slot: binding.slot,
1421 action: binding.action.clone(),
1422 label: binding.label.clone(),
1423 })
1424 .collect::<Vec<_>>(),
1425 None => Vec::new(),
1429 };
1430
1431 let mut by_slot: BTreeMap<u8, HotbarBinding> = BTreeMap::new();
1432 for binding in source {
1433 if !(1..=HOTBAR_SLOT_COUNT).contains(&binding.slot) {
1434 warnings.push(HotbarConfigWarning::SlotOutOfRange {
1435 slot: binding.slot,
1436 action: binding.action,
1437 });
1438 continue;
1439 }
1440 if !known.is_empty() && !known.contains(binding.action.as_str()) {
1441 warnings.push(HotbarConfigWarning::UnknownAction {
1442 slot: binding.slot,
1443 action: binding.action.clone(),
1444 });
1445 }
1446 if let Some(previous) = by_slot.insert(binding.slot, binding.clone()) {
1447 warnings.push(HotbarConfigWarning::DuplicateSlot {
1448 slot: binding.slot,
1449 previous_action: previous.action,
1450 replacement_action: binding.action,
1451 });
1452 }
1453 }
1454
1455 HotbarConfigResolution {
1456 bindings: by_slot.into_values().collect(),
1457 warnings,
1458 }
1459}
1460
1461impl ProviderChain {
1462 #[must_use]
1463 pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self {
1464 let mut providers = vec![active];
1465 for fallback in fallbacks {
1466 if *fallback != active && !providers.contains(fallback) {
1467 providers.push(*fallback);
1468 }
1469 }
1470 Self {
1471 providers,
1472 position: 0,
1473 }
1474 }
1475
1476 #[must_use]
1477 pub fn providers(&self) -> &[ProviderKind] {
1478 &self.providers
1479 }
1480
1481 #[must_use]
1482 pub fn position(&self) -> usize {
1483 self.position
1484 }
1485
1486 #[must_use]
1487 pub fn current(&self) -> ProviderKind {
1488 self.providers
1489 .get(self.position)
1490 .copied()
1491 .or_else(|| self.providers.first().copied())
1492 .unwrap_or_default()
1493 }
1494
1495 #[must_use]
1496 pub fn has_next(&self) -> bool {
1497 self.position + 1 < self.providers.len()
1498 }
1499
1500 pub fn advance(&mut self) -> Option<ProviderKind> {
1501 if !self.has_next() {
1502 return None;
1503 }
1504 self.position += 1;
1505 Some(self.current())
1506 }
1507
1508 pub fn reset(&mut self) {
1509 self.position = 0;
1510 }
1511
1512 #[must_use]
1513 pub fn is_fallback_active(&self) -> bool {
1514 self.position > 0
1515 }
1516
1517 #[must_use]
1519 pub fn remaining(&self) -> usize {
1520 self.providers.len() - self.position
1521 }
1522}
1523
1524#[cfg(test)]
1525mod provider_chain_tests {
1526 use super::*;
1527
1528 #[test]
1529 fn current_on_empty_chain_returns_default_provider() {
1530 let chain = ProviderChain {
1531 providers: vec![],
1532 position: 0,
1533 };
1534 assert_eq!(chain.current(), ProviderKind::default());
1535 }
1536}
1537
1538#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1540pub struct HookSinksToml {
1541 #[serde(default)]
1546 pub unix_socket_path: Option<PathBuf>,
1547}
1548
1549#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1552pub struct SkillsToml {
1553 #[serde(default)]
1556 pub registry_url: Option<String>,
1557 #[serde(default)]
1560 pub max_install_size_bytes: Option<u64>,
1561}
1562
1563#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1565pub struct ToolsToml {
1566 #[serde(default)]
1568 pub always_load: Vec<String>,
1569}
1570
1571#[derive(Debug, Clone, Serialize, Deserialize)]
1574pub struct SnapshotsToml {
1575 #[serde(default = "default_snapshots_enabled")]
1576 pub enabled: bool,
1577 #[serde(default = "default_snapshot_max_age_days")]
1578 pub max_age_days: u64,
1579}
1580
1581fn default_snapshots_enabled() -> bool {
1582 true
1583}
1584
1585fn default_snapshot_max_age_days() -> u64 {
1586 7
1587}
1588
1589impl Default for SnapshotsToml {
1590 fn default() -> Self {
1591 Self {
1592 enabled: default_snapshots_enabled(),
1593 max_age_days: default_snapshot_max_age_days(),
1594 }
1595 }
1596}
1597
1598#[derive(Debug, Clone, PartialEq, Eq)]
1603pub enum FleetResolutionError {
1604 UnknownFleet {
1606 name: String,
1608 available: Vec<String>,
1610 },
1611 UnknownOperator {
1613 operator: String,
1615 available: Vec<String>,
1617 },
1618 AmbiguousOperator {
1620 operator: String,
1622 fleet_names: Vec<String>,
1624 },
1625}
1626
1627impl std::fmt::Display for FleetResolutionError {
1628 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1629 match self {
1630 Self::UnknownFleet { name, available } => {
1631 write!(f, "fleet `{name}` is not defined")?;
1632 if available.is_empty() {
1633 write!(
1634 f,
1635 ". No named fleets are configured. Add `[fleets.{name}]` to your \
1636 config.toml or use the default `[fleet]` table."
1637 )
1638 } else {
1639 write!(
1640 f,
1641 ". Available named fleets: {}. Check your config.toml `[fleets.*]` \
1642 tables.",
1643 available.join(", ")
1644 )
1645 }
1646 }
1647 Self::UnknownOperator {
1648 operator,
1649 available,
1650 } => {
1651 write!(f, "no fleet is owned by operator `{operator}`")?;
1652 if available.is_empty() {
1653 write!(
1654 f,
1655 ". No named fleets define an operator. Add \
1656 `operator = \"{operator}\"` inside a `[fleets.<name>]` table."
1657 )
1658 } else {
1659 write!(
1660 f,
1661 ". Operators with configured fleets: {}.",
1662 available.join(", ")
1663 )
1664 }
1665 }
1666 Self::AmbiguousOperator {
1667 operator,
1668 fleet_names,
1669 } => {
1670 write!(
1671 f,
1672 "operator `{operator}` owns multiple fleets ({}); specify a fleet name \
1673 explicitly.",
1674 fleet_names.join(", ")
1675 )
1676 }
1677 }
1678 }
1679}
1680
1681impl std::error::Error for FleetResolutionError {}
1682
1683#[derive(Debug, Clone, Serialize, Deserialize)]
1686pub struct FleetConfigToml {
1687 #[serde(default = "default_fleet_trust_level_str")]
1690 pub default_trust_level: String,
1691 #[serde(default = "default_fleet_require_identity")]
1694 pub require_identity_verification: bool,
1695 #[serde(default = "default_fleet_max_trust_level_str")]
1698 pub max_trust_level: String,
1699 #[serde(default)]
1706 pub roles: BTreeMap<String, FleetRolePreset>,
1707 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1711 pub profiles: BTreeMap<String, FleetProfile>,
1712 #[serde(default)]
1714 pub exec: FleetExecConfig,
1715}
1716
1717pub const DEFAULT_SPAWN_DEPTH: u32 = 3;
1732pub const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 900;
1733pub const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1;
1734pub const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600;
1735
1736pub const MAX_SPAWN_DEPTH_CEILING: u32 = 8;
1742
1743#[derive(Debug, Clone, Serialize, Deserialize)]
1748pub struct FleetExecConfig {
1749 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1751 pub allowed_tools: Vec<String>,
1752 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1754 pub disallowed_tools: Vec<String>,
1755 #[serde(default = "default_fleet_max_turns")]
1759 pub max_turns: u32,
1760 #[serde(default = "default_fleet_max_spawn_depth")]
1766 pub max_spawn_depth: u32,
1767 #[serde(default, skip_serializing_if = "String::is_empty")]
1770 pub append_system_prompt: String,
1771 #[serde(default = "default_fleet_output_format")]
1774 pub output_format: String,
1775}
1776
1777pub const FLEET_DEFAULT_MAX_TURNS: u32 = 500;
1781
1782fn default_fleet_max_turns() -> u32 {
1783 FLEET_DEFAULT_MAX_TURNS
1784}
1785
1786fn default_fleet_max_spawn_depth() -> u32 {
1787 DEFAULT_SPAWN_DEPTH
1788}
1789
1790fn default_fleet_output_format() -> String {
1791 "text".to_string()
1792}
1793
1794impl Default for FleetExecConfig {
1795 fn default() -> Self {
1796 Self {
1797 allowed_tools: Vec::new(),
1798 disallowed_tools: Vec::new(),
1799 max_turns: default_fleet_max_turns(),
1800 max_spawn_depth: default_fleet_max_spawn_depth(),
1801 append_system_prompt: String::new(),
1802 output_format: default_fleet_output_format(),
1803 }
1804 }
1805}
1806
1807#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1813pub struct FleetProfile {
1814 #[serde(default)]
1816 pub slot: FleetSlot,
1817 #[serde(default)]
1819 pub role: FleetRole,
1820 #[serde(default)]
1822 pub loadout: FleetLoadout,
1823 #[serde(default, skip_serializing_if = "Option::is_none")]
1828 pub model: Option<String>,
1829 #[serde(default, skip_serializing_if = "Option::is_none")]
1842 pub provider: Option<String>,
1843 #[serde(default, skip_serializing_if = "Option::is_none")]
1849 pub reasoning_effort: Option<String>,
1850 #[serde(default)]
1852 pub permissions: FleetProfilePermissions,
1853 #[serde(default)]
1855 pub delegation: FleetDelegationHints,
1856}
1857
1858#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
1863pub struct FleetRole {
1864 pub name: String,
1866 #[serde(default, skip_serializing_if = "Option::is_none")]
1868 pub description: Option<String>,
1869 #[serde(default, skip_serializing_if = "Option::is_none")]
1871 pub instructions: Option<String>,
1872}
1873
1874impl Default for FleetRole {
1875 fn default() -> Self {
1876 Self {
1877 name: "general".to_string(),
1878 description: None,
1879 instructions: None,
1880 }
1881 }
1882}
1883
1884impl<'de> Deserialize<'de> for FleetRole {
1885 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1886 where
1887 D: serde::Deserializer<'de>,
1888 {
1889 #[derive(Deserialize)]
1890 #[serde(untagged)]
1891 enum FleetRoleWire {
1892 Name(String),
1893 Full {
1894 #[serde(default)]
1895 name: Option<String>,
1896 #[serde(default)]
1897 description: Option<String>,
1898 #[serde(default)]
1899 instructions: Option<String>,
1900 },
1901 }
1902
1903 match FleetRoleWire::deserialize(deserializer)? {
1904 FleetRoleWire::Name(name) => Ok(Self {
1905 name,
1906 ..Self::default()
1907 }),
1908 FleetRoleWire::Full {
1909 name,
1910 description,
1911 instructions,
1912 } => Ok(Self {
1913 name: name.unwrap_or_else(|| Self::default().name),
1914 description,
1915 instructions,
1916 }),
1917 }
1918 }
1919}
1920
1921#[derive(Debug, Clone, PartialEq, Eq, Default)]
1923pub enum FleetSlot {
1924 Manager,
1925 Scout,
1926 Planner,
1927 Implementer,
1928 Reviewer,
1929 Verifier,
1930 Operator,
1931 Summarizer,
1932 #[default]
1933 General,
1934 Custom(String),
1935}
1936
1937impl FleetSlot {
1938 #[must_use]
1939 pub fn as_str(&self) -> &str {
1940 match self {
1941 Self::Manager => "manager",
1942 Self::Scout => "scout",
1943 Self::Planner => "planner",
1944 Self::Implementer => "implementer",
1945 Self::Reviewer => "reviewer",
1946 Self::Verifier => "verifier",
1947 Self::Operator => "operator",
1948 Self::Summarizer => "summarizer",
1949 Self::General => "general",
1950 Self::Custom(value) => value.as_str(),
1951 }
1952 }
1953
1954 #[must_use]
1955 pub fn from_name(value: &str) -> Self {
1956 match value.trim() {
1957 "manager" | "coordinator" => Self::Manager,
1958 "scout" | "research" | "research-worker" => Self::Scout,
1959 "planner" | "plan" | "awaiter" => Self::Planner,
1960 "implementer" | "builder" => Self::Implementer,
1961 "reviewer" => Self::Reviewer,
1962 "verifier" | "tester" => Self::Verifier,
1963 "operator" | "incident" | "incident-worker" => Self::Operator,
1964 "summarizer" | "reducer" => Self::Summarizer,
1965 "general" | "" => Self::General,
1966 other => Self::Custom(other.to_string()),
1970 }
1971 }
1972}
1973
1974impl Serialize for FleetSlot {
1975 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1976 where
1977 S: serde::Serializer,
1978 {
1979 serializer.serialize_str(self.as_str())
1980 }
1981}
1982
1983impl<'de> Deserialize<'de> for FleetSlot {
1984 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1985 where
1986 D: serde::Deserializer<'de>,
1987 {
1988 let value = String::deserialize(deserializer)?;
1989 Ok(Self::from_name(&value))
1990 }
1991}
1992
1993#[derive(Debug, Clone, PartialEq, Eq, Default)]
1995pub enum FleetLoadout {
1996 #[default]
1998 Inherit,
1999 Fast,
2001 Custom(String),
2005}
2006
2007impl FleetLoadout {
2008 #[must_use]
2009 pub fn as_str(&self) -> &str {
2010 match self {
2011 Self::Inherit => "inherit",
2012 Self::Fast => "fast",
2013 Self::Custom(value) => value.as_str(),
2014 }
2015 }
2016
2017 #[must_use]
2018 pub fn from_name(value: &str) -> Self {
2019 match value.trim() {
2020 "inherit" | "default" | "auto" | "" => Self::Inherit,
2021 "fast" => Self::Fast,
2022 other => Self::Custom(other.to_string()),
2026 }
2027 }
2028}
2029
2030impl Serialize for FleetLoadout {
2031 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2032 where
2033 S: serde::Serializer,
2034 {
2035 serializer.serialize_str(self.as_str())
2036 }
2037}
2038
2039impl<'de> Deserialize<'de> for FleetLoadout {
2040 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
2041 where
2042 D: serde::Deserializer<'de>,
2043 {
2044 let value = String::deserialize(deserializer)?;
2045 Ok(Self::from_name(&value))
2046 }
2047}
2048
2049#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2051pub struct FleetProfilePermissions {
2052 #[serde(default)]
2054 pub allow_shell: bool,
2055 #[serde(default)]
2057 pub trust: bool,
2058 #[serde(default = "default_fleet_profile_approval_required")]
2060 pub approval_required: bool,
2061}
2062
2063fn default_fleet_profile_approval_required() -> bool {
2064 true
2065}
2066
2067impl Default for FleetProfilePermissions {
2068 fn default() -> Self {
2069 Self {
2070 allow_shell: false,
2071 trust: false,
2072 approval_required: true,
2073 }
2074 }
2075}
2076
2077#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
2079pub struct FleetDelegationHints {
2080 #[serde(default, skip_serializing_if = "Option::is_none")]
2083 pub max_spawn_depth: Option<u32>,
2084 #[serde(
2086 default,
2087 alias = "concurrency",
2088 skip_serializing_if = "Option::is_none"
2089 )]
2090 pub max_concurrency: Option<usize>,
2091}
2092
2093#[derive(Debug, Clone, Serialize, Deserialize)]
2102pub struct FleetRolePreset {
2103 #[serde(skip_serializing_if = "Option::is_none")]
2105 pub description: Option<String>,
2106 #[serde(skip_serializing_if = "Option::is_none")]
2108 pub tool_profile: Option<String>,
2109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2111 pub tools: Vec<String>,
2112 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2114 pub capabilities: Vec<String>,
2115 #[serde(skip_serializing_if = "Option::is_none")]
2117 pub timeout_seconds: Option<u64>,
2118 #[serde(skip_serializing_if = "Option::is_none")]
2120 pub trust_level: Option<String>,
2121}
2122
2123fn default_fleet_trust_level_str() -> String {
2124 "sandbox".to_string()
2125}
2126
2127fn default_fleet_require_identity() -> bool {
2128 true
2129}
2130
2131fn default_fleet_max_trust_level_str() -> String {
2132 "operator".to_string()
2133}
2134
2135impl Default for FleetConfigToml {
2136 fn default() -> Self {
2137 Self {
2138 default_trust_level: default_fleet_trust_level_str(),
2139 require_identity_verification: default_fleet_require_identity(),
2140 max_trust_level: default_fleet_max_trust_level_str(),
2141 roles: BTreeMap::new(),
2142 profiles: BTreeMap::new(),
2143 exec: FleetExecConfig::default(),
2144 }
2145 }
2146}
2147
2148impl FleetConfigToml {
2149 #[must_use]
2152 pub fn resolve_role(&self, name: &str) -> Option<FleetRolePreset> {
2153 self.roles
2154 .get(name)
2155 .cloned()
2156 .or_else(|| built_in_role_presets().get(name).cloned())
2157 }
2158}
2159
2160#[derive(Debug, Clone, Serialize, Deserialize)]
2184pub struct NamedFleetConfigToml {
2185 pub operator: String,
2190 #[serde(default = "default_fleet_trust_level_str")]
2193 pub default_trust_level: String,
2194 #[serde(default = "default_fleet_require_identity")]
2197 pub require_identity_verification: bool,
2198 #[serde(default = "default_fleet_max_trust_level_str")]
2200 pub max_trust_level: String,
2201 #[serde(default)]
2203 pub roles: BTreeMap<String, FleetRolePreset>,
2204 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2206 pub profiles: BTreeMap<String, FleetProfile>,
2207 #[serde(default)]
2209 pub exec: FleetExecConfig,
2210}
2211
2212impl NamedFleetConfigToml {
2213 #[must_use]
2216 pub fn resolve_role(&self, name: &str) -> Option<FleetRolePreset> {
2217 self.roles
2218 .get(name)
2219 .cloned()
2220 .or_else(|| built_in_role_presets().get(name).cloned())
2221 }
2222
2223 #[must_use]
2228 pub fn as_fleet_config(&self) -> FleetConfigToml {
2229 FleetConfigToml {
2230 default_trust_level: self.default_trust_level.clone(),
2231 require_identity_verification: self.require_identity_verification,
2232 max_trust_level: self.max_trust_level.clone(),
2233 roles: self.roles.clone(),
2234 profiles: self.profiles.clone(),
2235 exec: self.exec.clone(),
2236 }
2237 }
2238}
2239
2240#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2247pub struct WorkflowConfigToml {
2248 #[serde(default = "default_workflow_automatic")]
2251 pub automatic: bool,
2252 #[serde(default = "default_workflow_auto_start_read_only")]
2256 pub auto_start_read_only: bool,
2257 #[serde(default = "default_workflow_require_approval_for_writes")]
2260 pub require_approval_for_writes: bool,
2261 #[serde(default = "default_workflow_auto_start_child_limit")]
2264 pub auto_start_child_limit: u32,
2265 #[serde(default = "default_workflow_max_children")]
2267 pub max_children: u32,
2268 #[serde(default = "default_workflow_max_concurrent")]
2270 pub max_concurrent: u32,
2271 #[serde(default = "default_workflow_max_depth")]
2273 pub max_depth: u32,
2274 #[serde(default = "default_workflow_default_token_budget")]
2276 pub default_token_budget: u64,
2277 #[serde(default = "default_workflow_max_parallel_writes_without_worktree")]
2280 pub max_parallel_writes_without_worktree: u32,
2281 #[serde(default = "default_workflow_persist_completed_activity")]
2284 pub persist_completed_activity: bool,
2285 #[serde(default = "default_workflow_persist_completed_across_restarts")]
2288 pub persist_completed_across_restarts: bool,
2289}
2290
2291fn default_workflow_automatic() -> bool {
2292 true
2293}
2294
2295fn default_workflow_auto_start_read_only() -> bool {
2296 true
2297}
2298
2299fn default_workflow_require_approval_for_writes() -> bool {
2300 true
2301}
2302
2303fn default_workflow_auto_start_child_limit() -> u32 {
2304 16
2306}
2307
2308fn default_workflow_max_children() -> u32 {
2309 1000
2310}
2311
2312fn default_workflow_max_concurrent() -> u32 {
2313 16
2314}
2315
2316fn default_workflow_max_depth() -> u32 {
2317 2
2318}
2319
2320fn default_workflow_default_token_budget() -> u64 {
2321 120_000
2322}
2323
2324fn default_workflow_max_parallel_writes_without_worktree() -> u32 {
2325 0
2326}
2327
2328fn default_workflow_persist_completed_activity() -> bool {
2329 true
2330}
2331
2332fn default_workflow_persist_completed_across_restarts() -> bool {
2333 true
2334}
2335
2336impl Default for WorkflowConfigToml {
2337 fn default() -> Self {
2338 Self {
2339 automatic: default_workflow_automatic(),
2340 auto_start_read_only: default_workflow_auto_start_read_only(),
2341 require_approval_for_writes: default_workflow_require_approval_for_writes(),
2342 auto_start_child_limit: default_workflow_auto_start_child_limit(),
2343 max_children: default_workflow_max_children(),
2344 max_concurrent: default_workflow_max_concurrent(),
2345 max_depth: default_workflow_max_depth(),
2346 default_token_budget: default_workflow_default_token_budget(),
2347 max_parallel_writes_without_worktree:
2348 default_workflow_max_parallel_writes_without_worktree(),
2349 persist_completed_activity: default_workflow_persist_completed_activity(),
2350 persist_completed_across_restarts: default_workflow_persist_completed_across_restarts(),
2351 }
2352 }
2353}
2354
2355#[must_use]
2357pub fn built_in_role_presets() -> BTreeMap<String, FleetRolePreset> {
2358 [
2359 (
2360 "smoke-runner".to_string(),
2361 FleetRolePreset {
2362 description: Some("Lightweight read-only smoke check worker".to_string()),
2363 tool_profile: Some("read-only".to_string()),
2364 tools: vec![],
2365 capabilities: vec![],
2366 timeout_seconds: Some(300),
2367 trust_level: Some("local".to_string()),
2368 },
2369 ),
2370 (
2371 "reviewer".to_string(),
2372 FleetRolePreset {
2373 description: Some("Read-only code and documentation review".to_string()),
2374 tool_profile: Some("read-only".to_string()),
2375 tools: vec![],
2376 capabilities: vec![],
2377 timeout_seconds: Some(600),
2378 trust_level: None,
2379 },
2380 ),
2381 (
2382 "builder".to_string(),
2383 FleetRolePreset {
2384 description: Some(
2385 "Read-write builder with compilation and test access".to_string(),
2386 ),
2387 tool_profile: Some("read-write".to_string()),
2388 tools: vec![],
2389 capabilities: vec![],
2390 timeout_seconds: Some(1800),
2391 trust_level: Some("local".to_string()),
2392 },
2393 ),
2394 (
2395 "read-only".to_string(),
2396 FleetRolePreset {
2397 description: Some(
2398 "Minimal read-only observer with no writes or secrets".to_string(),
2399 ),
2400 tool_profile: Some("read-only".to_string()),
2401 tools: vec![],
2402 capabilities: vec![],
2403 timeout_seconds: Some(300),
2404 trust_level: Some("sandbox".to_string()),
2405 },
2406 ),
2407 ]
2408 .into()
2409}
2410
2411#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2417#[serde(rename_all = "snake_case")]
2418pub enum VerifierVerdictPolicy {
2419 #[default]
2420 Hunt,
2421}
2422
2423#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2425pub struct VerifierConfigToml {
2426 #[serde(default)]
2430 pub enabled: bool,
2431 #[serde(default)]
2433 pub verdict_policy: VerifierVerdictPolicy,
2434}
2435
2436impl Default for VerifierConfigToml {
2437 fn default() -> Self {
2438 Self {
2439 enabled: false,
2440 verdict_policy: VerifierVerdictPolicy::Hunt,
2441 }
2442 }
2443}
2444
2445#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2453pub struct AdvisorConfigToml {
2454 #[serde(default)]
2458 pub enabled: bool,
2459 #[serde(default = "advisor_default_max_tool_calls")]
2463 pub max_tool_calls: u32,
2464 #[serde(default = "advisor_default_rate_limit_secs")]
2468 pub rate_limit_secs: u64,
2469 #[serde(default = "advisor_default_dedup_window_secs")]
2473 pub dedup_window_secs: u64,
2474 #[serde(default)]
2477 pub model: Option<String>,
2478}
2479
2480fn advisor_default_max_tool_calls() -> u32 {
2481 10
2482}
2483fn advisor_default_rate_limit_secs() -> u64 {
2484 60
2485}
2486fn advisor_default_dedup_window_secs() -> u64 {
2487 300
2488}
2489
2490impl Default for AdvisorConfigToml {
2491 fn default() -> Self {
2492 Self {
2493 enabled: false,
2494 max_tool_calls: advisor_default_max_tool_calls(),
2495 rate_limit_secs: advisor_default_rate_limit_secs(),
2496 dedup_window_secs: advisor_default_dedup_window_secs(),
2497 model: None,
2498 }
2499 }
2500}
2501
2502#[derive(Debug, Clone, Serialize, Deserialize)]
2505pub struct NetworkPolicyToml {
2506 #[serde(default = "default_network_decision")]
2509 pub default: String,
2510 #[serde(default)]
2513 pub allow: Vec<String>,
2514 #[serde(default)]
2516 pub deny: Vec<String>,
2517 #[serde(default)]
2520 pub proxy: Vec<String>,
2521 #[serde(default)]
2524 pub proxy_fake_ip_cidrs: Vec<String>,
2525 #[serde(default = "default_network_audit")]
2527 pub audit: bool,
2528}
2529
2530fn default_network_decision() -> String {
2531 "prompt".to_string()
2532}
2533
2534fn default_network_audit() -> bool {
2535 true
2536}
2537
2538impl Default for NetworkPolicyToml {
2539 fn default() -> Self {
2540 Self {
2541 default: default_network_decision(),
2542 allow: Vec::new(),
2543 deny: Vec::new(),
2544 proxy: Vec::new(),
2545 proxy_fake_ip_cidrs: Vec::new(),
2546 audit: default_network_audit(),
2547 }
2548 }
2549}
2550
2551#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2554pub struct CustomLspDef {
2555 pub language_id: String,
2557 pub command: String,
2559 #[serde(default)]
2561 pub args: Vec<String>,
2562}
2563
2564#[derive(Debug, Clone, Serialize, Deserialize, Default)]
2568pub struct LspConfigToml {
2569 pub enabled: Option<bool>,
2571 pub poll_after_edit_ms: Option<u64>,
2573 pub max_diagnostics_per_file: Option<usize>,
2575 pub include_warnings: Option<bool>,
2577 pub servers: Option<BTreeMap<String, Vec<String>>>,
2579 pub custom: Option<BTreeMap<String, CustomLspDef>>,
2582}
2583
2584impl ConfigToml {
2585 #[must_use]
2588 pub fn provider_id(&self) -> &str {
2589 self.named_custom_provider_id()
2590 .unwrap_or_else(|| self.provider.as_str())
2591 }
2592
2593 #[must_use]
2596 pub fn named_custom_provider_id(&self) -> Option<&str> {
2597 (self.provider == ProviderKind::Custom)
2598 .then_some(self.selected_provider_id.as_deref())
2599 .flatten()
2600 }
2601
2602 fn named_custom_provider_table(&self, provider_id: &str) -> Result<&toml::value::Table> {
2603 let table = self
2604 .providers
2605 .extras
2606 .get(provider_id)
2607 .and_then(toml::Value::as_table)
2608 .with_context(|| {
2609 format!(
2610 "custom provider '{provider_id}' requires a matching [providers.{provider_id}] table"
2611 )
2612 })?;
2613 let compatible = table
2614 .get("kind")
2615 .and_then(toml::Value::as_str)
2616 .is_some_and(|kind| {
2617 kind.trim()
2618 .to_ascii_lowercase()
2619 .replace('_', "-")
2620 .eq("openai-compatible")
2621 });
2622 if !compatible {
2623 bail!(
2624 "custom provider '{provider_id}' must set [providers.{provider_id}].kind = \"openai-compatible\""
2625 );
2626 }
2627 Ok(table)
2628 }
2629
2630 fn named_custom_provider_config(&self) -> Option<ProviderConfigToml> {
2631 let provider_id = self.named_custom_provider_id()?;
2632 self.named_custom_provider_table(provider_id).ok()?;
2633 self.providers
2634 .extras
2635 .get(provider_id)
2636 .cloned()?
2637 .try_into()
2638 .ok()
2639 }
2640
2641 fn custom_provider_table_mut(&mut self, provider_id: &str) -> Result<&mut toml::value::Table> {
2644 let entry = self
2645 .providers
2646 .extras
2647 .entry(provider_id.to_string())
2648 .or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
2649 entry.as_table_mut().with_context(|| {
2650 format!("custom provider '{provider_id}' must be a [providers.{provider_id}] table")
2651 })
2652 }
2653
2654 fn set_custom_provider_value(
2659 &mut self,
2660 provider_id: &str,
2661 field_key: &str,
2662 value: &str,
2663 ) -> Result<()> {
2664 if is_builtin_provider_config_id(provider_id) {
2665 bail!(
2666 "unknown field '{field_key}' for built-in provider '{provider_id}': \
2667 expected one of api_key, base_url, model, context_window, mode, auth_mode, \
2668 insecure_skip_tls_verify, http_headers, path_suffix"
2669 );
2670 }
2671 if field_key == "kind" {
2672 let compatible =
2673 value.trim().to_ascii_lowercase().replace('_', "-") == "openai-compatible";
2674 if !compatible {
2675 bail!(
2676 "custom provider '{provider_id}' must set [providers.{provider_id}].kind = \"openai-compatible\""
2677 );
2678 }
2679 self.custom_provider_table_mut(provider_id)?.insert(
2680 "kind".to_string(),
2681 toml::Value::String(value.trim().to_string()),
2682 );
2683 return Ok(());
2684 }
2685 let Some(field) = ProviderConfigField::parse(field_key) else {
2686 bail!(
2687 "unknown field '{field_key}' for custom provider '{provider_id}': \
2688 expected one of {CUSTOM_PROVIDER_FIELD_HINT}"
2689 );
2690 };
2691 let toml_value = match field {
2692 ProviderConfigField::ApiKey
2693 | ProviderConfigField::BaseUrl
2694 | ProviderConfigField::Model
2695 | ProviderConfigField::Mode
2696 | ProviderConfigField::Wire
2697 | ProviderConfigField::AuthMode
2698 | ProviderConfigField::PathSuffix => toml::Value::String(value.to_string()),
2699 ProviderConfigField::ContextWindow => {
2700 toml::Value::Integer(i64::from(parse_context_window(value)?))
2701 }
2702 ProviderConfigField::InsecureSkipTlsVerify => toml::Value::Boolean(parse_bool(value)?),
2703 ProviderConfigField::HttpHeaders => toml::Value::Table(
2704 parse_http_headers(value)?
2705 .into_iter()
2706 .map(|(name, header)| (name, toml::Value::String(header)))
2707 .collect(),
2708 ),
2709 };
2710 self.custom_provider_table_mut(provider_id)?
2711 .insert(field.key().to_string(), toml_value);
2712 Ok(())
2713 }
2714
2715 fn get_custom_provider_value_with(
2716 &self,
2717 provider_id: &str,
2718 field_key: &str,
2719 render: fn(&ProviderConfigToml, ProviderConfigField) -> Option<String>,
2720 ) -> Option<String> {
2721 let table = self.providers.extras.get(provider_id)?.as_table()?;
2722 if field_key == "kind" {
2723 return table.get("kind")?.as_str().map(str::to_string);
2724 }
2725 let field = ProviderConfigField::parse(field_key)?;
2726 let config: ProviderConfigToml = toml::Value::Table(table.clone()).try_into().ok()?;
2727 render(&config, field)
2728 }
2729
2730 fn unset_custom_provider_value(&mut self, provider_id: &str, field_key: &str) {
2731 let Some(table) = self
2732 .providers
2733 .extras
2734 .get_mut(provider_id)
2735 .and_then(toml::Value::as_table_mut)
2736 else {
2737 return;
2738 };
2739 let leg = if field_key == "kind" {
2740 "kind"
2741 } else {
2742 ProviderConfigField::parse(field_key).map_or(field_key, |field| field.key())
2743 };
2744 table.remove(leg);
2745 }
2746
2747 fn bind_persisted_provider_id(&mut self, provider_id: &str) -> Result<()> {
2748 self.selected_provider_id = None;
2749 if self.provider != ProviderKind::Custom || provider_id == ProviderKind::Custom.as_str() {
2750 return Ok(());
2751 }
2752
2753 self.named_custom_provider_table(provider_id)?;
2754 self.selected_provider_id = Some(provider_id.to_string());
2755 Ok(())
2756 }
2757
2758 pub fn merge_project_overrides(&mut self, project: ConfigToml) {
2767 if project.default_text_model.is_some() {
2768 self.default_text_model = project.default_text_model;
2769 }
2770 if project.model.is_some() {
2771 self.model = project.model;
2772 }
2773 if project.output_mode.is_some() {
2774 self.output_mode = project.output_mode;
2775 }
2776 if project.verbosity.is_some() {
2777 self.verbosity = project.verbosity;
2778 }
2779 if project.log_level.is_some() {
2780 self.log_level = project.log_level;
2781 }
2782 if let Some(policy) = project.approval_policy
2783 && project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy)
2784 {
2785 self.approval_policy = Some(policy);
2786 }
2787 if let Some(mode) = project.sandbox_mode
2788 && project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode)
2789 {
2790 self.sandbox_mode = Some(mode);
2791 }
2792 if project.tools.is_some() {
2793 self.tools = project.tools;
2794 }
2795 for provider in provider::all_providers().iter().map(|p| p.kind()) {
2796 merge_project_provider_config(
2797 self.providers.for_provider_mut(provider),
2798 project.providers.for_provider(provider),
2799 );
2800 }
2801 }
2802
2803 #[must_use]
2804 pub fn get_value(&self, key: &str) -> Option<String> {
2805 if let Some((provider, field)) = parse_provider_config_key(key) {
2806 return get_provider_config_value(self.providers.for_provider(provider), field);
2807 }
2808 if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
2809 return self.get_custom_provider_value_with(
2810 provider_id,
2811 field_key,
2812 get_provider_config_value,
2813 );
2814 }
2815
2816 match key {
2817 "provider" => Some(self.provider_id().to_string()),
2818 "stream_chunk_timeout_secs" | "tui.stream_chunk_timeout_secs" => {
2819 Some(self.stream_chunk_timeout_secs().to_string())
2820 }
2821 "api_key" => self.api_key.clone(),
2822 "base_url" => self.base_url.clone(),
2823 "http_headers" => serialize_http_headers(&self.http_headers),
2824 "default_text_model" => self.default_text_model.clone(),
2825 "model" => self.model.clone(),
2826 "auth.mode" => self.auth_mode.clone(),
2827 "output_mode" => self.output_mode.clone(),
2828 "verbosity" => self.verbosity.clone(),
2829 "log_level" => self.log_level.clone(),
2830 "telemetry" => self.telemetry.map(|v| v.to_string()),
2831 "telemetry_endpoint" => self.telemetry_endpoint.clone(),
2832 "approval_policy" => self.approval_policy.clone(),
2833 "sandbox_mode" => self.sandbox_mode.clone(),
2834 "tools.always_load" => self.tools.as_ref().map(|tools| tools.always_load.join(",")),
2835 "hook_sinks.unix_socket_path" => self
2836 .hook_sinks
2837 .as_ref()
2838 .and_then(|sinks| sinks.unix_socket_path.as_ref())
2839 .map(|path| path.display().to_string()),
2840 _ => self.extras.get(key).map(toml::Value::to_string),
2841 }
2842 }
2843
2844 #[must_use]
2853 pub fn get_raw_string(&self, key: &str) -> Option<&str> {
2854 self.extras.get(key).and_then(toml::Value::as_str)
2855 }
2856
2857 #[must_use]
2858 pub fn get_display_value(&self, key: &str) -> Option<String> {
2859 if let Some((provider, field)) = parse_provider_config_key(key) {
2860 return get_provider_config_display_value(self.providers.for_provider(provider), field);
2861 }
2862 if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
2863 return self.get_custom_provider_value_with(
2864 provider_id,
2865 field_key,
2866 get_provider_config_display_value,
2867 );
2868 }
2869
2870 if key == "telemetry" {
2871 let (on, source) = resolved_telemetry_consent(self.telemetry);
2876 return Some(format!(
2877 "{} ({})",
2878 if on { "on" } else { "off" },
2879 source.as_str()
2880 ));
2881 }
2882
2883 if key == "http_headers" {
2884 return serialize_http_headers_for_display(&self.http_headers);
2885 }
2886
2887 if let Some(value) = self.extras.get(key) {
2888 return Some(redact_toml_value_for_display(key, value));
2889 }
2890
2891 self.get_value(key).map(|value| {
2892 if is_sensitive_config_key(key) {
2893 redact_secret(&value)
2894 } else {
2895 value
2896 }
2897 })
2898 }
2899
2900 #[must_use]
2901 pub fn stream_chunk_timeout_secs(&self) -> u64 {
2902 let raw = self
2903 .extras
2904 .get("tui")
2905 .and_then(toml::Value::as_table)
2906 .and_then(|table| table.get("stream_chunk_timeout_secs"))
2907 .and_then(toml_value_as_u64)
2908 .or_else(|| {
2909 self.extras
2910 .get("tui.stream_chunk_timeout_secs")
2911 .and_then(toml_value_as_u64)
2912 })
2913 .or_else(|| {
2914 self.extras
2915 .get("stream_chunk_timeout_secs")
2916 .and_then(toml_value_as_u64)
2917 })
2918 .unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS);
2919 if raw == 0 {
2920 DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
2921 } else {
2922 raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS)
2923 }
2924 }
2925
2926 pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
2927 if let Some((provider, field)) = parse_provider_config_key(key) {
2928 return set_provider_config_value(self, provider, field, value);
2929 }
2930 if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
2931 return self.set_custom_provider_value(provider_id, field_key, value);
2932 }
2933
2934 match key {
2935 "provider" => {
2936 if let Some(provider) = ProviderKind::parse_config_identity(value) {
2937 self.provider = provider;
2938 self.selected_provider_id = None;
2939 } else {
2940 let provider_id = value.trim();
2941 self.named_custom_provider_table(provider_id)
2942 .with_context(|| {
2943 format!(
2944 "unknown provider '{value}': expected {} or a configured custom provider",
2945 ProviderKind::names_hint()
2946 )
2947 })?;
2948 self.provider = ProviderKind::Custom;
2949 self.selected_provider_id = Some(provider_id.to_string());
2950 }
2951 }
2952 "api_key" => self.api_key = Some(value.to_string()),
2953 "base_url" => self.base_url = Some(value.to_string()),
2954 "http_headers" => self.http_headers = parse_http_headers(value)?,
2955 "default_text_model" => self.default_text_model = Some(value.to_string()),
2956 "model" => self.model = Some(value.to_string()),
2957 "auth.mode" => self.auth_mode = Some(value.to_string()),
2958 "output_mode" => self.output_mode = Some(value.to_string()),
2959 "verbosity" => self.verbosity = Some(value.to_string()),
2960 "log_level" => self.log_level = Some(value.to_string()),
2961 "telemetry" => {
2962 self.telemetry = Some(parse_bool(value)?);
2963 }
2964 "telemetry_endpoint" => self.telemetry_endpoint = Some(value.to_string()),
2968 "approval_policy" => self.approval_policy = Some(value.to_string()),
2969 "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
2970 "hook_sinks.unix_socket_path" => {
2971 self.hook_sinks
2972 .get_or_insert_with(HookSinksToml::default)
2973 .unix_socket_path = Some(PathBuf::from(value));
2974 }
2975 _ => {
2976 self.extras
2977 .insert(key.to_string(), toml::Value::String(value.to_string()));
2978 }
2979 }
2980 Ok(())
2981 }
2982
2983 pub fn unset_value(&mut self, key: &str) -> Result<()> {
2984 if let Some((provider, field)) = parse_provider_config_key(key) {
2985 unset_provider_config_value(self, provider, field);
2986 return Ok(());
2987 }
2988 if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
2989 self.unset_custom_provider_value(provider_id, field_key);
2990 return Ok(());
2991 }
2992
2993 match key {
2994 "provider" => {
2995 self.provider = ProviderKind::Deepseek;
2996 self.selected_provider_id = None;
2997 }
2998 "api_key" => self.api_key = None,
2999 "base_url" => self.base_url = None,
3000 "http_headers" => self.http_headers.clear(),
3001 "default_text_model" => self.default_text_model = None,
3002 "model" => self.model = None,
3003 "auth.mode" => self.auth_mode = None,
3004 "output_mode" => self.output_mode = None,
3005 "verbosity" => self.verbosity = None,
3006 "log_level" => self.log_level = None,
3007 "telemetry" => self.telemetry = None,
3008 "telemetry_endpoint" => self.telemetry_endpoint = None,
3009 "approval_policy" => self.approval_policy = None,
3010 "sandbox_mode" => self.sandbox_mode = None,
3011 "hook_sinks.unix_socket_path" => {
3012 if let Some(sinks) = self.hook_sinks.as_mut() {
3013 sinks.unix_socket_path = None;
3014 }
3015 }
3016 _ => {
3017 self.extras.remove(key);
3018 }
3019 }
3020 Ok(())
3021 }
3022
3023 #[must_use]
3024 pub fn list_values(&self) -> BTreeMap<String, String> {
3025 let mut out = BTreeMap::new();
3026 out.insert("provider".to_string(), self.provider_id().to_string());
3027
3028 if let Some(v) = self.api_key.as_ref() {
3029 out.insert("api_key".to_string(), redact_secret(v));
3030 }
3031 if let Some(v) = self.base_url.as_ref() {
3032 out.insert("base_url".to_string(), v.clone());
3033 }
3034 if let Some(v) = serialize_http_headers_for_display(&self.http_headers) {
3035 out.insert("http_headers".to_string(), v);
3036 }
3037 if let Some(v) = self.default_text_model.as_ref() {
3038 out.insert("default_text_model".to_string(), v.clone());
3039 }
3040 if let Some(v) = self.model.as_ref() {
3041 out.insert("model".to_string(), v.clone());
3042 }
3043 if let Some(v) = self.auth_mode.as_ref() {
3044 out.insert("auth.mode".to_string(), v.clone());
3045 }
3046 if let Some(v) = self.output_mode.as_ref() {
3047 out.insert("output_mode".to_string(), v.clone());
3048 }
3049 if let Some(v) = self.verbosity.as_ref() {
3050 out.insert("verbosity".to_string(), v.clone());
3051 }
3052 if let Some(v) = self.log_level.as_ref() {
3053 out.insert("log_level".to_string(), v.clone());
3054 }
3055 if let Some(v) = self.telemetry {
3056 out.insert("telemetry".to_string(), v.to_string());
3057 }
3058 if let Some(v) = self.telemetry_endpoint.as_ref() {
3059 out.insert("telemetry_endpoint".to_string(), v.clone());
3060 }
3061 if let Some(v) = self.approval_policy.as_ref() {
3062 out.insert("approval_policy".to_string(), v.clone());
3063 }
3064 if let Some(v) = self.sandbox_mode.as_ref() {
3065 out.insert("sandbox_mode".to_string(), v.clone());
3066 }
3067 if let Some(v) = self
3068 .hook_sinks
3069 .as_ref()
3070 .and_then(|sinks| sinks.unix_socket_path.as_ref())
3071 {
3072 out.insert(
3073 "hook_sinks.unix_socket_path".to_string(),
3074 v.display().to_string(),
3075 );
3076 }
3077
3078 for provider in provider::all_providers().iter().map(|p| p.kind()) {
3079 insert_provider_config_values(
3080 &mut out,
3081 provider,
3082 self.providers.for_provider(provider),
3083 );
3084 }
3085
3086 for (k, v) in &self.extras {
3087 out.insert(k.clone(), redact_toml_value_for_display(k, v));
3088 }
3089 out
3090 }
3091
3092 #[must_use]
3099 pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
3100 let no_keyring = Secrets::new(std::sync::Arc::new(
3101 codewhale_secrets::InMemoryKeyringStore::new(),
3102 ));
3103 self.resolve_runtime_options_with_secrets(cli, &no_keyring)
3104 }
3105
3106 #[must_use]
3110 pub fn resolve_runtime_options_with_secrets(
3111 &self,
3112 cli: &CliRuntimeOverrides,
3113 secrets: &Secrets,
3114 ) -> ResolvedRuntimeOptions {
3115 let env = EnvRuntimeOverrides::load();
3116 let (provider, provider_source) = if let Some(provider) = cli.provider {
3117 (provider, ProviderSource::Cli)
3118 } else if let Some(provider) = env.provider {
3119 (
3120 provider,
3121 ProviderSource::Env(env.provider_source.unwrap_or("CODEWHALE_PROVIDER")),
3122 )
3123 } else {
3124 (self.provider, ProviderSource::Config)
3125 };
3126
3127 let mut provider_cfg = if provider == ProviderKind::Custom
3128 && matches!(provider_source, ProviderSource::Config)
3129 {
3130 self.named_custom_provider_config()
3131 .unwrap_or_else(|| self.providers.for_provider(provider).clone())
3132 } else {
3133 self.providers.for_provider(provider).clone()
3134 };
3135 if provider == ProviderKind::SiliconflowCN {
3136 let fb = &self.providers.siliconflow;
3137 if provider_cfg.api_key.is_none() {
3138 provider_cfg.api_key = fb.api_key.clone();
3139 }
3140 if provider_cfg.base_url.is_none() {
3141 provider_cfg.base_url = fb.base_url.clone();
3142 }
3143 if provider_cfg.model.is_none() {
3144 provider_cfg.model = fb.model.clone();
3145 }
3146 }
3147 let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
3148 .then(|| self.api_key.clone())
3149 .flatten();
3150 let root_base_url = matches!(
3156 provider,
3157 ProviderKind::Deepseek | ProviderKind::XiaomiMimo | ProviderKind::OpenaiCodex
3158 )
3159 .then(|| self.base_url.clone())
3160 .flatten();
3161 let auth_mode = cli
3162 .auth_mode
3163 .clone()
3164 .or_else(|| env.auth_mode.clone())
3165 .or_else(|| provider_cfg.auth_mode.clone())
3166 .or_else(|| self.auth_mode.clone());
3167 let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
3168 let cli_base_url = cli.base_url.clone();
3169 let env_base_url = env.base_url_for(provider);
3170 let file_base_url = provider_cfg.base_url.clone().or(root_base_url);
3171 let base_url_from_file =
3172 cli_base_url.is_none() && env_base_url.is_none() && file_base_url.is_some();
3173 let configured_base_url = cli_base_url.or(env_base_url).or(file_base_url);
3174 let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo {
3175 env.xiaomi_mimo_mode
3176 .clone()
3177 .or_else(|| provider_cfg.mode.clone())
3178 } else {
3179 None
3180 };
3181 let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo {
3182 xiaomi_mimo_env_api_key_for_runtime(
3183 xiaomi_mimo_mode.as_deref(),
3184 configured_base_url.as_deref(),
3185 )
3186 } else {
3187 None
3188 };
3189 let explicit_api_key_for_endpoint = cli
3190 .api_key
3191 .as_deref()
3192 .or(from_file.as_deref().filter(|value| {
3193 classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal
3194 }))
3195 .or(xiaomi_mimo_env_api_key.as_deref());
3196 let provider_wire = provider_cfg.wire.as_deref();
3197 let base_url = if provider == ProviderKind::XiaomiMimo {
3198 resolve_xiaomi_mimo_base_url(
3199 configured_base_url,
3200 explicit_api_key_for_endpoint,
3201 xiaomi_mimo_mode.as_deref(),
3202 )
3203 } else if is_modelstudio_family(provider) {
3204 resolve_modelstudio_base_url(
3205 configured_base_url,
3206 provider,
3207 provider_cfg.mode.as_deref(),
3208 provider_wire,
3209 )
3210 } else if matches!(
3211 provider,
3212 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic
3213 ) {
3214 resolve_minimax_base_url(configured_base_url, provider, provider_wire)
3215 } else if matches!(
3216 provider,
3217 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
3218 ) {
3219 resolve_deepseek_base_url(configured_base_url, provider, provider_wire)
3220 } else {
3221 configured_base_url.unwrap_or_else(|| match provider {
3222 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
3223 ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string(),
3224 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
3225 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
3226 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
3227 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
3228 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
3229 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
3230 ProviderKind::Orcarouter => DEFAULT_ORCAROUTER_BASE_URL.to_string(),
3231 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
3232 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
3233 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
3234 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
3235 ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL.to_string(),
3236 ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL.to_string(),
3237 ProviderKind::Moonshot => {
3238 if auth_mode
3239 .as_deref()
3240 .is_some_and(auth_mode_uses_kimi_imported_token)
3241 {
3242 DEFAULT_KIMI_CODE_BASE_URL.to_string()
3243 } else {
3244 DEFAULT_MOONSHOT_BASE_URL.to_string()
3245 }
3246 }
3247 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
3248 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
3249 ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
3250 ProviderKind::OllamaCloud => DEFAULT_OLLAMA_CLOUD_BASE_URL.to_string(),
3251 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(),
3252 ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL.to_string(),
3253 ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL.to_string(),
3254 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
3255 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(),
3256 ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL.to_string(),
3257 ProviderKind::Zai => DEFAULT_ZAI_BASE_URL.to_string(),
3258 ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL.to_string(),
3259 ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL.to_string(),
3260 ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string(),
3261 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL.to_string(),
3262 ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL.to_string(),
3263 ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL.to_string(),
3264 ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL.to_string(),
3265 ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL.to_string(),
3266 ProviderKind::Meta => DEFAULT_META_BASE_URL.to_string(),
3267 ProviderKind::Xai => DEFAULT_XAI_BASE_URL.to_string(),
3268 ProviderKind::Mistral => DEFAULT_MISTRAL_BASE_URL.to_string(),
3269 ProviderKind::Google => DEFAULT_GOOGLE_BASE_URL.to_string(),
3270 ProviderKind::Antigravity => DEFAULT_ANTIGRAVITY_BASE_URL.to_string(),
3271 ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL.to_string(),
3272 ProviderKind::Edenai => DEFAULT_EDENAI_BASE_URL.to_string(),
3273 ProviderKind::ModelstudioTokenPlan
3274 | ProviderKind::ModelstudioTokenPlanAnthropic
3275 | ProviderKind::ModelstudioCodingPlan
3276 | ProviderKind::ModelstudioCodingPlanAnthropic => {
3277 DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL.to_string()
3278 }
3279 ProviderKind::Custom => provider.provider().default_base_url().to_string(),
3283 })
3284 };
3285 let legacy_ollama_cloud = provider::migrates_legacy_ollama_cloud_route(provider, &base_url);
3290 let provider = if legacy_ollama_cloud {
3291 ProviderKind::OllamaCloud
3292 } else {
3293 provider
3294 };
3295 let uses_kimi_imported_token = provider == ProviderKind::Moonshot
3303 && auth_mode
3304 .as_deref()
3305 .is_some_and(auth_mode_uses_kimi_imported_token);
3306 let auth_disabled = auth_mode_disables_api_key(auth_mode.as_deref());
3307 let custom_endpoint = provider_preserves_custom_base_url_model(provider, &base_url);
3308 let (api_key, api_key_source) = if auth_disabled {
3309 (None, None)
3310 } else if let Some(value) = cli.api_key.clone() {
3311 (Some(value), Some(RuntimeApiKeySource::Cli))
3312 } else if uses_kimi_imported_token && !custom_endpoint {
3313 (None, None)
3314 } else if (!custom_endpoint || base_url_from_file)
3315 && let Some(value) = from_file.clone().filter(|value| {
3316 classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal
3317 })
3318 {
3319 (Some(value), Some(RuntimeApiKeySource::ConfigFile))
3320 } else if !custom_endpoint
3321 && let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty())
3322 {
3323 (Some(value), Some(RuntimeApiKeySource::Env))
3324 } else if custom_endpoint {
3325 (None, None)
3326 } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
3327 match env_api_key_for_provider(provider) {
3328 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
3329 None => (None, None),
3330 }
3331 } else {
3332 match stored_api_key_for_provider(secrets, provider, legacy_ollama_cloud) {
3333 Some((value, source)) => {
3334 let source = match source {
3335 SecretSource::Keyring => RuntimeApiKeySource::Keyring,
3336 SecretSource::Env => RuntimeApiKeySource::Env,
3337 };
3338 (Some(value), Some(source))
3339 }
3340 None => match env_api_key_for_provider(provider) {
3341 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
3342 None => (None, None),
3343 },
3344 }
3345 };
3346
3347 let env_provider_model = env.model_for(provider, &base_url);
3348 let root_default_model = self
3361 .default_text_model
3362 .clone()
3363 .filter(|model| !root_default_model_is_foreign_to_provider(provider, model, &base_url));
3364 let model_source = if cli.model.is_some() {
3367 ModelSource::Cli
3368 } else if env.model.is_some() || env_provider_model.is_some() {
3369 ModelSource::Env
3370 } else if provider_cfg.model.is_some() {
3371 ModelSource::ProviderConfig
3372 } else if root_default_model.is_some() {
3373 ModelSource::RootDefaultTextModel
3374 } else if self.model.is_some() {
3375 ModelSource::RootModel
3376 } else {
3377 ModelSource::ProviderDefault
3378 };
3379 let explicit_model = model_source.is_explicit();
3380 let model = cli
3381 .model
3382 .clone()
3383 .or_else(|| env.model.clone())
3384 .or(env_provider_model)
3385 .or_else(|| provider_cfg.model.clone())
3386 .or(root_default_model)
3387 .or_else(|| self.model.clone())
3388 .unwrap_or_else(|| {
3389 if provider == ProviderKind::Moonshot
3390 && (auth_mode
3391 .as_deref()
3392 .is_some_and(auth_mode_uses_kimi_imported_token)
3393 || moonshot_base_url_uses_kimi_code(&base_url))
3394 {
3395 DEFAULT_KIMI_CODE_MODEL.to_string()
3396 } else {
3397 default_model_for_provider(provider).to_string()
3398 }
3399 });
3400 let model = if provider == ProviderKind::OpencodeGo {
3401 normalize_model_for_provider(provider, &model)
3406 } else if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
3407 model.trim().to_string()
3408 } else {
3409 normalize_model_for_provider(provider, &model)
3410 };
3411
3412 let mut http_headers = self.http_headers.clone();
3413 http_headers.extend(provider_cfg.http_headers.clone());
3414 if let Some(env_headers) = env.http_headers {
3415 http_headers.extend(env_headers);
3416 }
3417 http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
3418 if auth_disabled {
3419 http_headers.retain(|name, _| !is_upstream_auth_header(name));
3420 }
3421
3422 let output_mode = cli
3423 .output_mode
3424 .clone()
3425 .or_else(|| env.output_mode.clone())
3426 .or_else(|| self.output_mode.clone());
3427 let log_level = cli
3428 .log_level
3429 .clone()
3430 .or_else(|| env.log_level.clone())
3431 .or_else(|| self.log_level.clone());
3432 let (telemetry_env_file, telemetry_source_env_file) = telemetry_consent_from_env(
3442 env.telemetry,
3443 env.telemetry_env_invalid,
3444 env.telemetry_floor,
3445 self.telemetry,
3446 );
3447 let telemetry = telemetry_env_file && cli.telemetry != Some(false);
3452 let telemetry_source = if cli.telemetry == Some(false)
3453 || (cli.telemetry == Some(true) && telemetry_env_file)
3454 {
3455 TelemetrySource::Cli
3456 } else {
3457 telemetry_source_env_file
3458 };
3459 let telemetry_persisted_off = self.telemetry == Some(false);
3460 let telemetry_explicit_off = telemetry_persisted_off;
3468 let telemetry_endpoint = match env
3478 .telemetry_endpoint
3479 .clone()
3480 .or_else(|| self.telemetry_endpoint.clone())
3481 {
3482 Some(configured) if configured.trim().is_empty() => None,
3483 Some(configured) => Some(configured),
3484 None => Some(DEFAULT_TELEMETRY_ENDPOINT.to_string()),
3485 };
3486 let approval_policy = cli
3487 .approval_policy
3488 .clone()
3489 .or_else(|| env.approval_policy.clone())
3490 .or_else(|| self.approval_policy.clone());
3491 let sandbox_mode = cli
3492 .sandbox_mode
3493 .clone()
3494 .or_else(|| env.sandbox_mode.clone())
3495 .or_else(|| self.sandbox_mode.clone());
3496 let yolo = cli.yolo.or(env.yolo);
3497 let verbosity = cli
3498 .verbosity
3499 .clone()
3500 .or_else(|| env.verbosity.clone())
3501 .or_else(|| self.verbosity.clone());
3502
3503 ResolvedRuntimeOptions {
3504 provider,
3505 provider_source,
3506 model,
3507 model_source,
3508 api_key,
3509 api_key_source,
3510 base_url,
3511 auth_mode,
3512 insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false),
3513 output_mode,
3514 log_level,
3515 telemetry,
3516 telemetry_source,
3517 telemetry_explicit_off,
3518 telemetry_endpoint,
3519 approval_policy,
3520 sandbox_mode,
3521 yolo,
3522 verbosity,
3523 http_headers,
3524 }
3525 }
3526}
3527
3528fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
3529 if source.model.is_some() {
3530 target.model = source.model.clone();
3531 }
3532}
3533
3534pub const DEFAULT_TELEMETRY_ENDPOINT: &str = "https://telemetry.codewhale.net/v1/telemetry";
3550
3551pub const TELEMETRY_FLOOR_ENV: &str = "CODEWHALE_TELEMETRY_FLOOR";
3556
3557#[must_use]
3570pub fn telemetry_floor_in_force() -> bool {
3571 if let Ok(raw) = std::env::var(TELEMETRY_FLOOR_ENV)
3572 && let Ok(declared) = parse_bool(&raw)
3573 {
3574 return declared;
3575 }
3576 let Ok(raw) =
3577 std::env::var("CODEWHALE_TELEMETRY").or_else(|_| std::env::var("DEEPSEEK_TELEMETRY"))
3578 else {
3579 return false;
3580 };
3581 !matches!(parse_bool(&raw), Ok(true))
3582}
3583
3584#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3586pub enum TelemetrySource {
3587 Cli,
3589 Env,
3592 Config,
3594 Default,
3596}
3597
3598impl TelemetrySource {
3599 #[must_use]
3601 pub const fn as_str(self) -> &'static str {
3602 match self {
3603 Self::Cli => "cli",
3604 Self::Env => "env",
3605 Self::Config => "config",
3606 Self::Default => "default",
3607 }
3608 }
3609}
3610
3611fn read_telemetry_env() -> (Option<bool>, bool) {
3619 let Some(raw) = std::env::var("CODEWHALE_TELEMETRY")
3620 .or_else(|_| std::env::var("DEEPSEEK_TELEMETRY"))
3621 .ok()
3622 else {
3623 return (None, false);
3624 };
3625 match parse_bool(&raw) {
3626 Ok(value) => (Some(value), false),
3627 Err(_) => {
3628 tracing::warn!(
3629 "Invalid CODEWHALE_TELEMETRY/DEEPSEEK_TELEMETRY value '{raw}'; expected one of \
3630 1/0, true/false, yes/no, on/off, enabled/disabled. Telemetry is forced off."
3631 );
3632 (None, true)
3633 }
3634 }
3635}
3636
3637#[must_use]
3648pub fn resolved_telemetry_consent(file_telemetry: Option<bool>) -> (bool, TelemetrySource) {
3649 let (env_telemetry, env_invalid) = read_telemetry_env();
3650 telemetry_consent_from_env(
3651 env_telemetry,
3652 env_invalid,
3653 telemetry_floor_in_force(),
3654 file_telemetry,
3655 )
3656}
3657
3658#[must_use]
3661fn telemetry_consent_from_env(
3662 env_telemetry: Option<bool>,
3663 env_invalid: bool,
3664 floor: bool,
3665 file_telemetry: Option<bool>,
3666) -> (bool, TelemetrySource) {
3667 let persisted_off = file_telemetry == Some(false);
3668 let allowed = env_telemetry.or(file_telemetry).unwrap_or(true);
3669 let on = allowed && env_telemetry != Some(false) && !env_invalid && !floor && !persisted_off;
3670 let source = if !on && (env_telemetry == Some(false) || env_invalid || floor) {
3671 TelemetrySource::Env
3673 } else if !on && persisted_off {
3674 TelemetrySource::Config
3676 } else if env_telemetry.is_some() {
3677 TelemetrySource::Env
3678 } else if file_telemetry.is_some() {
3679 TelemetrySource::Config
3680 } else {
3681 TelemetrySource::Default
3682 };
3683 (on, source)
3684}
3685
3686#[must_use]
3687pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
3688 let Some(project_rank) = approval_policy_rank(project) else {
3689 return false;
3690 };
3691 match current.and_then(approval_policy_rank) {
3692 Some(current_rank) => project_rank >= current_rank,
3693 None => project_rank >= 2,
3694 }
3695}
3696
3697#[must_use]
3698pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
3699 let normalized_project = project.trim().to_ascii_lowercase();
3700 if normalized_project == "external-sandbox" {
3701 return current
3702 .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
3703 .unwrap_or(false);
3704 }
3705
3706 let Some(project_rank) = sandbox_mode_rank(project) else {
3707 return false;
3708 };
3709 match current.and_then(sandbox_mode_rank) {
3710 Some(current_rank) => project_rank >= current_rank,
3711 None => project_rank >= 2,
3712 }
3713}
3714
3715fn approval_policy_rank(value: &str) -> Option<u8> {
3716 match value.trim().to_ascii_lowercase().as_str() {
3717 "auto" => Some(0),
3718 "suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
3719 "never" | "deny" | "denied" => Some(2),
3720 _ => None,
3721 }
3722}
3723
3724fn sandbox_mode_rank(value: &str) -> Option<u8> {
3725 match value.trim().to_ascii_lowercase().as_str() {
3726 "danger-full-access" => Some(0),
3727 "external-sandbox" => Some(0),
3728 "workspace-write" => Some(1),
3729 "read-only" => Some(2),
3730 _ => None,
3731 }
3732}
3733
3734#[derive(Debug, Clone)]
3743pub enum ProjectConfigOutcome {
3744 Missing,
3746 Loaded(Box<ConfigToml>),
3748 Invalid {
3751 path: PathBuf,
3753 reason: String,
3755 },
3756}
3757
3758impl ProjectConfigOutcome {
3759 #[must_use]
3761 pub fn into_config(self) -> Option<ConfigToml> {
3762 match self {
3763 Self::Loaded(config) => Some(*config),
3764 Self::Missing | Self::Invalid { .. } => None,
3765 }
3766 }
3767
3768 #[must_use]
3770 pub fn invalid(&self) -> Option<(&Path, &str)> {
3771 match self {
3772 Self::Invalid { path, reason } => Some((path.as_path(), reason.as_str())),
3773 Self::Missing | Self::Loaded(_) => None,
3774 }
3775 }
3776}
3777
3778pub fn load_project_config_outcome(workspace: &Path) -> ProjectConfigOutcome {
3784 for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
3785 let path = workspace.join(dir).join(CONFIG_FILE_NAME);
3786 if !project_config_candidate_exists(&path) {
3787 continue;
3788 }
3789 let raw = match read_checked_config_file(&path) {
3790 Ok(raw) => raw,
3791 Err(e) => {
3792 tracing::warn!("Failed to read project config {}: {e:#}", path.display());
3793 return ProjectConfigOutcome::Invalid {
3794 path,
3795 reason: format!("could not be read: {e}"),
3796 };
3797 }
3798 };
3799 match toml::from_str::<ConfigToml>(&raw) {
3800 Ok(config) => {
3801 let raw_provider = toml::from_str::<toml::Value>(&raw)
3802 .ok()
3803 .and_then(|document| document.get("provider").cloned())
3804 .and_then(|provider| provider.as_str().map(str::to_string));
3805 if config.provider == ProviderKind::Custom
3806 && raw_provider.as_deref() != Some(ProviderKind::Custom.as_str())
3807 {
3808 tracing::warn!(
3812 "Failed to parse project config {}; file contents were omitted",
3813 quote_os_path(&path)
3814 );
3815 return ProjectConfigOutcome::Invalid {
3816 path,
3817 reason: match raw_provider {
3818 Some(name) => format!("unknown provider '{name}'"),
3819 None => "unknown provider".to_string(),
3820 },
3821 };
3822 }
3823 return ProjectConfigOutcome::Loaded(Box::new(config));
3824 }
3825 Err(err) => {
3826 tracing::warn!(
3827 "Failed to parse project config {}; file contents were omitted",
3828 quote_os_path(&path)
3829 );
3830 return ProjectConfigOutcome::Invalid {
3831 path,
3832 reason: err.message().to_string(),
3835 };
3836 }
3837 }
3838 }
3839 ProjectConfigOutcome::Missing
3840}
3841
3842pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
3850 load_project_config_outcome(workspace).into_config()
3851}
3852
3853fn project_config_candidate_exists(path: &Path) -> bool {
3854 fs::symlink_metadata(path).is_ok_and(|metadata| {
3855 let file_type = metadata.file_type();
3856 file_type.is_file() || file_type.is_symlink()
3857 })
3858}
3859
3860fn deepseek_family_model_id(model: &str) -> Option<String> {
3867 let trimmed = model.trim();
3868 if trimmed.is_empty() {
3869 return None;
3870 }
3871 match trimmed.to_ascii_lowercase().as_str() {
3872 "pro" | "deepseek-v4pro" => return Some("deepseek-v4-pro".to_string()),
3873 "flash" | "deepseek-v4flash" => return Some("deepseek-v4-flash".to_string()),
3874 _ => {}
3875 }
3876
3877 let normalized = trimmed.to_ascii_lowercase();
3878 if !normalized.starts_with("deepseek") && !normalized.contains("/deepseek") {
3879 return None;
3880 }
3881 if trimmed
3882 .chars()
3883 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))
3884 {
3885 return Some(trimmed.to_string());
3886 }
3887 None
3888}
3889
3890fn provider_passes_model_through(provider: ProviderKind) -> bool {
3895 matches!(
3896 provider,
3897 ProviderKind::Openai
3898 | ProviderKind::Atlascloud
3899 | ProviderKind::WanjieArk
3900 | ProviderKind::Volcengine
3901 | ProviderKind::XiaomiMimo
3902 | ProviderKind::Moonshot
3903 | ProviderKind::Qianfan
3904 | ProviderKind::Openmodel
3905 | ProviderKind::Ollama
3906 | ProviderKind::OllamaCloud
3907 | ProviderKind::Huggingface
3908 | ProviderKind::Meta
3909 | ProviderKind::Xai
3910 | ProviderKind::Telecomjs
3911 | ProviderKind::Edenai
3912 | ProviderKind::ModelstudioTokenPlan
3913 | ProviderKind::ModelstudioTokenPlanAnthropic
3914 | ProviderKind::ModelstudioCodingPlan
3915 | ProviderKind::ModelstudioCodingPlanAnthropic
3916 | ProviderKind::Custom
3917 )
3918}
3919
3920fn root_default_model_is_foreign_to_provider(
3932 provider: ProviderKind,
3933 model: &str,
3934 base_url: &str,
3935) -> bool {
3936 if deepseek_family_model_id(model).is_none() {
3940 return false;
3941 }
3942 if matches!(
3944 provider,
3945 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
3946 ) {
3947 return false;
3948 }
3949 if provider_preserves_custom_base_url_model(provider, base_url) {
3952 return false;
3953 }
3954 if matches!(
3958 provider,
3959 ProviderKind::Xai | ProviderKind::Openai | ProviderKind::Moonshot
3960 ) {
3961 return true;
3962 }
3963 if provider_passes_model_through(provider) {
3966 return false;
3967 }
3968 if matches!(
3971 provider,
3972 ProviderKind::NvidiaNim
3973 | ProviderKind::Openrouter
3974 | ProviderKind::Orcarouter
3975 | ProviderKind::Novita
3976 | ProviderKind::Fireworks
3977 | ProviderKind::Siliconflow
3978 | ProviderKind::SiliconflowCN
3979 | ProviderKind::Deepinfra
3980 | ProviderKind::Together
3981 | ProviderKind::Sglang
3982 | ProviderKind::Vllm
3983 | ProviderKind::Volcengine
3984 | ProviderKind::Atlascloud
3985 | ProviderKind::OpencodeGo
3986 | ProviderKind::WanjieArk
3987 ) {
3988 return false;
3989 }
3990 true
3993}
3994
3995#[must_use]
4003pub fn known_foreign_model_owner(
4004 provider: ProviderKind,
4005 model: &str,
4006 base_url: &str,
4007) -> Option<ProviderKind> {
4008 root_default_model_is_foreign_to_provider(provider, model, base_url)
4009 .then_some(ProviderKind::Deepseek)
4010}
4011
4012fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
4013 if matches!(provider, ProviderKind::OpencodeGo) {
4014 return opencode_go_chat_model_id(model)
4019 .map(str::to_string)
4020 .unwrap_or_else(|| model.trim().to_string());
4021 }
4022 if matches!(provider, ProviderKind::XiaomiMimo)
4023 && let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
4024 {
4025 return canonical.to_string();
4026 }
4027 if matches!(
4028 provider,
4029 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic
4030 ) && let Some(canonical) = canonical_minimax_model_id(model)
4031 {
4032 return canonical.to_string();
4033 }
4034 if matches!(provider, ProviderKind::Zai)
4035 && let Some(canonical) = canonical_zai_model_id(model)
4036 {
4037 return canonical.to_string();
4038 }
4039
4040 if matches!(
4041 provider,
4042 ProviderKind::Atlascloud
4043 | ProviderKind::WanjieArk
4044 | ProviderKind::Volcengine
4045 | ProviderKind::XiaomiMimo
4046 | ProviderKind::Zai
4047 | ProviderKind::Stepfun
4048 | ProviderKind::Minimax
4049 | ProviderKind::MinimaxAnthropic
4050 | ProviderKind::Qianfan
4051 | ProviderKind::Ollama
4052 | ProviderKind::OllamaCloud
4053 | ProviderKind::Meta
4054 | ProviderKind::Xai
4055 ) {
4056 return model.to_string();
4057 }
4058
4059 let normalized = model.trim().to_ascii_lowercase();
4060 if provider == ProviderKind::Openrouter
4061 && let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
4062 {
4063 return canonical.to_string();
4064 }
4065 if provider == ProviderKind::Orcarouter
4066 && let Some(canonical) = canonical_orcarouter_recent_model_id(&normalized)
4067 {
4068 return canonical.to_string();
4069 }
4070 match (provider, normalized.as_str()) {
4071 (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
4072 DEFAULT_NVIDIA_NIM_MODEL.to_string()
4073 }
4074 (
4075 ProviderKind::NvidiaNim,
4076 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4077 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4078 ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
4079 (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
4080 DEFAULT_OPENROUTER_MODEL.to_string()
4081 }
4082 (
4083 ProviderKind::Openrouter,
4084 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4085 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4086 ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
4087 (ProviderKind::Orcarouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
4088 DEFAULT_ORCAROUTER_MODEL.to_string()
4089 }
4090 (
4091 ProviderKind::Orcarouter,
4092 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4093 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4094 ) => DEFAULT_ORCAROUTER_FLASH_MODEL.to_string(),
4095 (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
4096 DEFAULT_NOVITA_MODEL.to_string()
4097 }
4098 (
4099 ProviderKind::Novita,
4100 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4101 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4102 ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
4103 (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
4104 DEFAULT_FIREWORKS_MODEL.to_string()
4105 }
4106 (
4107 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
4108 "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
4109 ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
4110 (
4111 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
4112 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
4113 ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
4114 (
4115 ProviderKind::Arcee,
4116 "trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking",
4117 ) => DEFAULT_ARCEE_MODEL.to_string(),
4118 (ProviderKind::Arcee, "trinity-mini" | "arcee-trinity-mini") => {
4119 ARCEE_TRINITY_MINI_MODEL.to_string()
4120 }
4121 (ProviderKind::Arcee, "arcee-trinity-large-preview") => {
4122 ARCEE_TRINITY_LARGE_PREVIEW_MODEL.to_string()
4123 }
4124 (
4125 ProviderKind::Moonshot,
4126 "kimi"
4127 | "kimi-k2"
4128 | "kimi-k2.7"
4129 | "kimi-k2-7"
4130 | "kimi-k2.7-code"
4131 | "kimi-k2-7-code"
4132 | "kimi-code"
4133 | "moonshot-kimi-k2.7-code",
4134 ) => DEFAULT_MOONSHOT_MODEL.to_string(),
4135 (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
4136 MOONSHOT_KIMI_K2_6_MODEL.to_string()
4137 }
4138 (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
4139 DEFAULT_SGLANG_MODEL.to_string()
4140 }
4141 (
4142 ProviderKind::Sglang,
4143 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4144 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4145 ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
4146 (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
4147 DEFAULT_VLLM_MODEL.to_string()
4148 }
4149 (
4150 ProviderKind::Vllm,
4151 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4152 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4153 ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
4154 (ProviderKind::Huggingface, "deepseek-v4-pro" | "deepseek-v4pro") => {
4155 DEFAULT_HUGGINGFACE_MODEL.to_string()
4156 }
4157 (
4158 ProviderKind::Huggingface,
4159 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4160 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4161 ) => DEFAULT_HUGGINGFACE_FLASH_MODEL.to_string(),
4162 (ProviderKind::Together, "deepseek-v4-pro" | "deepseek-v4pro") => {
4163 DEFAULT_TOGETHER_MODEL.to_string()
4164 }
4165 (
4166 ProviderKind::Together,
4167 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4168 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4169 ) => DEFAULT_TOGETHER_FLASH_MODEL.to_string(),
4170 (ProviderKind::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
4171 DEFAULT_DEEPINFRA_MODEL.to_string()
4172 }
4173 (
4174 ProviderKind::Deepinfra,
4175 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4176 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4177 ) => DEFAULT_DEEPINFRA_FLASH_MODEL.to_string(),
4178 _ => model.to_string(),
4179 }
4180}
4181
4182pub const OPENCODE_GO_CHAT_MODELS: &[&str] = &[
4195 DEFAULT_OPENCODE_GO_MODEL,
4196 OPENCODE_GO_GROK_4_5_MODEL,
4197 OPENCODE_GO_GLM_5_2_MODEL,
4198 OPENCODE_GO_GLM_5_1_MODEL,
4199 OPENCODE_GO_KIMI_K3_MODEL,
4200 OPENCODE_GO_KIMI_K2_7_CODE_MODEL,
4201 OPENCODE_GO_KIMI_K2_6_MODEL,
4202 OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL,
4203 OPENCODE_GO_MIMO_V2_5_MODEL,
4204 OPENCODE_GO_MIMO_V2_5_PRO_MODEL,
4205];
4206
4207#[must_use]
4212pub fn opencode_go_chat_model_id(model: &str) -> Option<&'static str> {
4213 let normalized = model.trim().to_ascii_lowercase().replace(['_', ' '], "-");
4214 let normalized = normalized
4215 .strip_prefix("opencode-go/")
4216 .unwrap_or(&normalized);
4217 let familiar_alias = match normalized {
4218 "grok-4-5" => Some(OPENCODE_GO_GROK_4_5_MODEL),
4219 "glm-5-2" => Some(OPENCODE_GO_GLM_5_2_MODEL),
4220 "glm-5-1" => Some(OPENCODE_GO_GLM_5_1_MODEL),
4221 "kimi-k2-7-code" => Some(OPENCODE_GO_KIMI_K2_7_CODE_MODEL),
4222 "kimi-k2-6" => Some(OPENCODE_GO_KIMI_K2_6_MODEL),
4223 "deepseek-v4pro" => Some(DEFAULT_OPENCODE_GO_MODEL),
4224 "deepseek-v4flash" => Some(OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL),
4225 "mimo-v2-5" => Some(OPENCODE_GO_MIMO_V2_5_MODEL),
4226 "mimo-v2-5-pro" => Some(OPENCODE_GO_MIMO_V2_5_PRO_MODEL),
4227 _ => None,
4228 };
4229 familiar_alias.or_else(|| {
4230 OPENCODE_GO_CHAT_MODELS
4231 .iter()
4232 .copied()
4233 .find(|candidate| *candidate == normalized)
4234 })
4235}
4236
4237fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> {
4238 let normalized = model.trim().to_ascii_lowercase();
4239 let normalized = normalized.replace(['_', ' '], "-");
4240 match normalized.as_str() {
4241 "mimo"
4242 | DEFAULT_XIAOMI_MIMO_MODEL
4243 | "mimo-v2-5-pro"
4244 | "xiaomi-mimo-v2.5-pro"
4245 | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL),
4246 XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL
4247 | "mimo-v2-5-pro-ultraspeed"
4248 | "xiaomi-mimo-v2.5-pro-ultraspeed"
4249 | "xiaomi-mimo-v2-5-pro-ultraspeed"
4250 | "ultraspeed"
4251 | "pro-ultraspeed" => Some(XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL),
4252 "omni"
4253 | "mimo-omni"
4254 | "v2.5-omni"
4255 | "v25-omni"
4256 | "mimo-v2.5"
4257 | "mimo-v25"
4258 | "mimo-v2-5"
4259 | "mimo-v2.5-omni"
4260 | "mimo-v25-omni"
4261 | "mimo-v2-5-omni"
4262 | "xiaomi-mimo-v2.5"
4263 | "xiaomi-mimo-v2-5"
4264 | "xiaomi-mimo-v2.5-omni"
4265 | "xiaomi-mimo-v2-5-omni" => Some(XIAOMI_MIMO_V2_5_OMNI_MODEL),
4266 "asr" | "mimo-asr" | "mimo-v2.5-asr" | "speech-to-text" | "transcribe" => {
4267 Some(XIAOMI_MIMO_ASR_MODEL)
4268 }
4269 "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => {
4270 Some(XIAOMI_MIMO_TTS_MODEL)
4271 }
4272 "mimo-tts-voicedesign"
4273 | "mimo-voice-design"
4274 | "mimo-v25-tts-voicedesign"
4275 | "mimo-v2.5-tts-voicedesign"
4276 | "voicedesign"
4277 | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL),
4278 "mimo-tts-voiceclone"
4279 | "mimo-voice-clone"
4280 | "mimo-v25-tts-voiceclone"
4281 | "mimo-v2.5-tts-voiceclone"
4282 | "voiceclone"
4283 | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL),
4284 "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL),
4285 _ => None,
4286 }
4287}
4288
4289fn canonical_minimax_model_id(model: &str) -> Option<&'static str> {
4290 let normalized = model.trim().to_ascii_lowercase();
4291 let normalized = normalized.replace(['_', ' '], "-");
4292 match normalized.as_str() {
4293 "minimax" | "minimax-m3" | "minimax-m-3" | "minimax-m-3-thinking" => {
4294 Some(DEFAULT_MINIMAX_MODEL)
4295 }
4296 "minimax-m2.7" | "minimax-m2-7" | "minimax-m-2.7" | "minimax-m-2-7" => {
4297 Some(MINIMAX_M2_7_MODEL)
4298 }
4299 "minimax-m2.7-highspeed"
4300 | "minimax-m2-7-highspeed"
4301 | "minimax-m-2.7-highspeed"
4302 | "minimax-m-2-7-highspeed" => Some(MINIMAX_M2_7_HIGHSPEED_MODEL),
4303 "minimax-m2.5" | "minimax-m2-5" | "minimax-m-2.5" | "minimax-m-2-5" => {
4304 Some(MINIMAX_M2_5_MODEL)
4305 }
4306 "minimax-m2.5-highspeed"
4307 | "minimax-m2-5-highspeed"
4308 | "minimax-m-2.5-highspeed"
4309 | "minimax-m-2-5-highspeed" => Some(MINIMAX_M2_5_HIGHSPEED_MODEL),
4310 "minimax-m2.1" | "minimax-m2-1" | "minimax-m-2.1" | "minimax-m-2-1" => {
4311 Some(MINIMAX_M2_1_MODEL)
4312 }
4313 "minimax-m2.1-highspeed"
4314 | "minimax-m2-1-highspeed"
4315 | "minimax-m-2.1-highspeed"
4316 | "minimax-m-2-1-highspeed" => Some(MINIMAX_M2_1_HIGHSPEED_MODEL),
4317 "minimax-m2" | "minimax-m-2" => Some(MINIMAX_M2_MODEL),
4318 _ => None,
4319 }
4320}
4321
4322fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
4323 let normalized = model.trim().to_ascii_lowercase();
4324 let normalized = normalized.replace(['_', ' '], "-");
4325 match normalized.as_str() {
4326 "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
4327 "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(ZAI_GLM_5_2_MODEL),
4331 "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => Some(ZAI_GLM_5_3_MODEL),
4332 "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
4333 _ => None,
4334 }
4335}
4336
4337fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
4338 let normalized = model.trim().to_ascii_lowercase();
4339 let normalized = normalized.replace(['_', ' '], "-");
4340 match normalized.as_str() {
4341 OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
4342 | "trinity"
4343 | "trinity-large-thinking"
4344 | "arcee-trinity"
4345 | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
4346 OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
4347 Some(OPENROUTER_GEMMA_4_31B_MODEL)
4348 }
4349 OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
4350 Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
4351 }
4352 OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
4353 Some(OPENROUTER_GLM_5_1_MODEL)
4354 }
4355 OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
4356 Some(OPENROUTER_GLM_5_2_MODEL)
4357 }
4358 OPENROUTER_GLM_5_3_MODEL | "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => {
4359 Some(OPENROUTER_GLM_5_3_MODEL)
4360 }
4361 OPENROUTER_KIMI_K2_7_CODE_MODEL
4362 | "kimi"
4363 | "kimi-k2"
4364 | "kimi-k2.7"
4365 | "kimi-k2-7"
4366 | "kimi-k2.7-code"
4367 | "kimi-k2-7-code"
4368 | "kimi-code"
4369 | "moonshot-kimi-k2.7-code"
4370 | "openrouter-kimi-k2.7-code" => Some(OPENROUTER_KIMI_K2_7_CODE_MODEL),
4371 OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
4372 Some(OPENROUTER_KIMI_K2_6_MODEL)
4373 }
4374 OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => {
4375 Some(OPENROUTER_MINIMAX_M3_MODEL)
4376 }
4377 OPENROUTER_MINIMAX_M2_7_MODEL
4378 | "minimax-2.7"
4379 | "minimax-2-7"
4380 | "minimax-m2.7"
4381 | "minimax-m2-7"
4382 | "minimax-m-2.7"
4383 | "minimax-m-2-7" => Some(OPENROUTER_MINIMAX_M2_7_MODEL),
4384 OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
4385 | "nemotron-3-nano-omni"
4386 | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
4387 OPENROUTER_QWEN_3_6_35B_A3B_MODEL
4388 | "qwen3.6-35b-a3b"
4389 | "qwen-3.6-35b-a3b"
4390 | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
4391 OPENROUTER_QWEN_3_6_FLASH_MODEL | "qwen3.6-flash" | "qwen-3.6-flash" => {
4392 Some(OPENROUTER_QWEN_3_6_FLASH_MODEL)
4393 }
4394 OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL
4395 | "qwen3.6-max-preview"
4396 | "qwen-3.6-max-preview"
4397 | "qwen-max-preview" => Some(OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
4398 OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
4399 Some(OPENROUTER_QWEN_3_6_27B_MODEL)
4400 }
4401 OPENROUTER_QWEN_3_6_PLUS_MODEL | "qwen3.6-plus" | "qwen-3.6-plus" => {
4402 Some(OPENROUTER_QWEN_3_6_PLUS_MODEL)
4403 }
4404 OPENROUTER_QWEN_3_7_PLUS_MODEL | "qwen3.7-plus" | "qwen-3.7-plus" => {
4405 Some(OPENROUTER_QWEN_3_7_PLUS_MODEL)
4406 }
4407 OPENROUTER_QWEN_3_7_MAX_MODEL | "qwen3.7-max" | "qwen-3.7-max" => {
4408 Some(OPENROUTER_QWEN_3_7_MAX_MODEL)
4409 }
4410 OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
4411 Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
4412 }
4413 OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
4414 | "mimo-v2.5-pro"
4415 | "mimo-v2-5-pro"
4416 | "xiaomi-mimo-v2.5-pro"
4417 | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
4418 OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
4419 | "mimo-v2.5"
4420 | "mimo-v2-5"
4421 | "xiaomi-mimo-v2.5"
4422 | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
4423 _ => None,
4424 }
4425}
4426
4427fn canonical_orcarouter_recent_model_id(model: &str) -> Option<&'static str> {
4435 let normalized = model.trim().to_ascii_lowercase();
4436 let normalized = normalized.replace(['_', ' '], "-");
4437 match normalized.as_str() {
4438 ORCAROUTER_AUTO_MODEL | "auto" | "orcarouter-auto" | "orca-auto" => {
4439 Some(ORCAROUTER_AUTO_MODEL)
4440 }
4441 _ => None,
4442 }
4443}
4444
4445fn default_model_for_provider(provider: ProviderKind) -> &'static str {
4446 match provider {
4447 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
4448 ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_MODEL,
4449 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
4450 ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
4451 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
4452 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
4453 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
4454 ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
4455 ProviderKind::Orcarouter => DEFAULT_ORCAROUTER_MODEL,
4456 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
4457 ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
4458 ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
4459 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_MODEL,
4460 ProviderKind::Arcee => DEFAULT_ARCEE_MODEL,
4461 ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
4462 ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
4463 ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
4464 ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
4465 ProviderKind::OllamaCloud => DEFAULT_OLLAMA_CLOUD_MODEL,
4466 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
4467 ProviderKind::Together => DEFAULT_TOGETHER_MODEL,
4468 ProviderKind::Qianfan => DEFAULT_QIANFAN_MODEL,
4469 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL,
4470 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_MODEL,
4471 ProviderKind::Openmodel => DEFAULT_OPENMODEL_MODEL,
4472 ProviderKind::Zai => DEFAULT_ZAI_MODEL,
4473 ProviderKind::Stepfun => DEFAULT_STEPFUN_MODEL,
4474 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_MODEL,
4475 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
4476 ProviderKind::Sakana => DEFAULT_SAKANA_MODEL,
4477 ProviderKind::LongCat => DEFAULT_LONGCAT_MODEL,
4478 ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_MODEL,
4479 ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_MODEL,
4480 ProviderKind::Meta => DEFAULT_META_MODEL,
4481 ProviderKind::Xai => DEFAULT_XAI_MODEL,
4482 ProviderKind::Mistral => DEFAULT_MISTRAL_MODEL,
4483 ProviderKind::Google => DEFAULT_GOOGLE_MODEL,
4484 ProviderKind::Antigravity => DEFAULT_ANTIGRAVITY_MODEL,
4485 ProviderKind::Telecomjs => DEFAULT_TELECOMJS_MODEL,
4486 ProviderKind::Edenai => DEFAULT_EDENAI_MODEL,
4487 ProviderKind::ModelstudioTokenPlan
4488 | ProviderKind::ModelstudioTokenPlanAnthropic
4489 | ProviderKind::ModelstudioCodingPlan
4490 | ProviderKind::ModelstudioCodingPlanAnthropic => DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL,
4491 ProviderKind::Custom => provider.provider().default_model(),
4493 }
4494}
4495
4496fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
4497 match provider {
4498 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
4499 ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
4500 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
4501 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
4502 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
4503 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
4504 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
4505 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
4506 ProviderKind::Orcarouter => DEFAULT_ORCAROUTER_BASE_URL,
4507 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
4508 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
4509 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
4510 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
4511 ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL,
4512 ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL,
4513 ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
4514 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
4515 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
4516 ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
4517 ProviderKind::OllamaCloud => DEFAULT_OLLAMA_CLOUD_BASE_URL,
4518 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
4519 ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL,
4520 ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL,
4521 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
4522 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
4523 ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL,
4524 ProviderKind::Zai => DEFAULT_ZAI_BASE_URL,
4525 ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL,
4526 ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL,
4527 ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
4528 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
4529 ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL,
4530 ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL,
4531 ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL,
4532 ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL,
4533 ProviderKind::Meta => DEFAULT_META_BASE_URL,
4534 ProviderKind::Xai => DEFAULT_XAI_BASE_URL,
4535 ProviderKind::Mistral => DEFAULT_MISTRAL_BASE_URL,
4536 ProviderKind::Google => DEFAULT_GOOGLE_BASE_URL,
4537 ProviderKind::Antigravity => DEFAULT_ANTIGRAVITY_BASE_URL,
4538 ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL,
4539 ProviderKind::Edenai => DEFAULT_EDENAI_BASE_URL,
4540 ProviderKind::ModelstudioTokenPlan => DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
4541 ProviderKind::ModelstudioTokenPlanAnthropic => MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL,
4542 ProviderKind::ModelstudioCodingPlan => DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL,
4543 ProviderKind::ModelstudioCodingPlanAnthropic => MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL,
4544 ProviderKind::Custom => provider.provider().default_base_url(),
4546 }
4547}
4548
4549fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
4550 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
4551 normalized == DEFAULT_KIMI_CODE_BASE_URL
4552 || normalized == "https://api.kimi.com/coding"
4553 || normalized.starts_with("https://api.kimi.com/coding/")
4554}
4555
4556fn wire_prefers_anthropic(kind: ProviderKind, wire: Option<&str>) -> bool {
4558 if matches!(
4559 kind,
4560 ProviderKind::DeepseekAnthropic
4561 | ProviderKind::MinimaxAnthropic
4562 | ProviderKind::ModelstudioTokenPlanAnthropic
4563 | ProviderKind::ModelstudioCodingPlanAnthropic
4564 ) {
4565 return true;
4566 }
4567 let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else {
4568 return false;
4569 };
4570 let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-");
4571 matches!(
4572 normalized.as_str(),
4573 "anthropic"
4574 | "anthropic-messages"
4575 | "messages"
4576 | "claude"
4577 | "anthropic-compatible"
4578 | "anthropic-compat"
4579 )
4580}
4581
4582fn modelstudio_mode_is_coding_plan(kind: ProviderKind, mode: Option<&str>) -> bool {
4583 if matches!(
4584 kind,
4585 ProviderKind::ModelstudioCodingPlan | ProviderKind::ModelstudioCodingPlanAnthropic
4586 ) {
4587 return true;
4588 }
4589 let Some(raw) = mode.map(str::trim).filter(|value| !value.is_empty()) else {
4590 return false;
4591 };
4592 let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-");
4593 matches!(
4594 normalized.as_str(),
4595 "coding-plan" | "coding" | "codingplan" | "dashscope-coding" | "code"
4596 )
4597}
4598
4599fn is_modelstudio_family(kind: ProviderKind) -> bool {
4600 matches!(
4601 kind,
4602 ProviderKind::ModelstudioTokenPlan
4603 | ProviderKind::ModelstudioTokenPlanAnthropic
4604 | ProviderKind::ModelstudioCodingPlan
4605 | ProviderKind::ModelstudioCodingPlanAnthropic
4606 )
4607}
4608
4609fn resolve_modelstudio_base_url(
4610 configured: Option<String>,
4611 kind: ProviderKind,
4612 mode: Option<&str>,
4613 wire: Option<&str>,
4614) -> String {
4615 if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
4616 return url;
4617 }
4618 let coding = modelstudio_mode_is_coding_plan(kind, mode);
4619 let anthropic = wire_prefers_anthropic(kind, wire);
4620 match (coding, anthropic) {
4621 (true, true) => MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL.to_string(),
4622 (true, false) => DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL.to_string(),
4623 (false, true) => MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL.to_string(),
4624 (false, false) => DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL.to_string(),
4625 }
4626}
4627
4628fn resolve_minimax_base_url(
4629 configured: Option<String>,
4630 kind: ProviderKind,
4631 wire: Option<&str>,
4632) -> String {
4633 if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
4634 return url;
4635 }
4636 if wire_prefers_anthropic(kind, wire) {
4637 DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string()
4638 } else {
4639 DEFAULT_MINIMAX_BASE_URL.to_string()
4640 }
4641}
4642
4643fn resolve_deepseek_base_url(
4644 configured: Option<String>,
4645 kind: ProviderKind,
4646 wire: Option<&str>,
4647) -> String {
4648 if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
4649 return url;
4650 }
4651 if wire_prefers_anthropic(kind, wire) {
4652 DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string()
4653 } else {
4654 DEFAULT_DEEPSEEK_BASE_URL.to_string()
4655 }
4656}
4657
4658fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
4659 let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
4660 if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
4661 return None;
4662 }
4663 Some(match normalized.as_str() {
4664 "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => {
4665 DEFAULT_XIAOMI_MIMO_BASE_URL
4666 }
4667 "token-plan-cn"
4668 | "token-plan-china"
4669 | "token-plan-mainland"
4670 | "token-plan-mainland-china"
4671 | "cn"
4672 | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
4673 "token-plan-sgp"
4674 | "token-plan-sg"
4675 | "token-plan-singapore"
4676 | "sgp"
4677 | "sg"
4678 | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
4679 "token-plan-ams"
4680 | "token-plan-eu"
4681 | "token-plan-europe"
4682 | "token-plan-amsterdam"
4683 | "ams"
4684 | "eu"
4685 | "europe"
4686 | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
4687 _ => DEFAULT_XIAOMI_MIMO_BASE_URL,
4688 })
4689}
4690
4691fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
4692 matches!(
4693 normalized_mode,
4694 "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
4695 )
4696}
4697
4698fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
4699 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
4700 normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
4701 || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL
4702 || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
4703}
4704
4705fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option<String> {
4706 candidates.iter().find_map(|name| {
4707 std::env::var(name)
4708 .ok()
4709 .filter(|value| !value.trim().is_empty())
4710 })
4711}
4712
4713fn xiaomi_mimo_env_api_key_for_runtime(
4714 mode: Option<&str>,
4715 base_url: Option<&str>,
4716) -> Option<String> {
4717 const TOKEN_PLAN_ENV_VARS: &[&str] =
4718 &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"];
4719 const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"];
4720
4721 let normalized_mode =
4722 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
4723 let standard_selected = normalized_mode
4724 .as_deref()
4725 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint)
4726 || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go);
4727 if standard_selected {
4728 return xiaomi_mimo_env_var(STANDARD_ENV_VARS);
4729 }
4730
4731 let token_plan_selected = normalized_mode
4732 .as_deref()
4733 .and_then(xiaomi_mimo_base_url_for_mode)
4734 .is_some()
4735 || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan);
4736 if token_plan_selected {
4737 return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS);
4738 }
4739
4740 xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS))
4741}
4742
4743fn resolve_xiaomi_mimo_base_url(
4744 configured: Option<String>,
4745 api_key: Option<&str>,
4746 mode: Option<&str>,
4747) -> String {
4748 let normalized_mode =
4749 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
4750 let uses_standard_mode = normalized_mode
4751 .as_deref()
4752 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint);
4753 let mode_base_url = normalized_mode
4754 .as_deref()
4755 .and_then(xiaomi_mimo_base_url_for_mode);
4756 let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key);
4757 match configured {
4758 Some(base_url) if uses_standard_mode => base_url,
4759 Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => {
4760 mode_base_url
4761 .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL)
4762 .to_string()
4763 }
4764 Some(base_url) => base_url,
4765 None => {
4766 if let Some(base_url) = mode_base_url {
4767 base_url.to_string()
4768 } else if uses_standard_mode {
4769 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
4770 } else if uses_token_plan || api_key.is_none() {
4771 DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()
4772 } else {
4773 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
4774 }
4775 }
4776 }
4777}
4778
4779fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
4780 api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
4781}
4782
4783fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool {
4784 matches!(
4785 base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
4786 "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
4787 )
4788}
4789
4790#[must_use]
4796pub fn provider_base_url_is_official(provider: ProviderKind, base_url: &str) -> bool {
4797 let normalized = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
4798 match provider {
4799 ProviderKind::Deepseek => matches!(
4800 normalized.as_str(),
4801 "https://api.deepseek.com"
4802 | "https://api.deepseek.com/v1"
4803 | "https://api.deepseek.com/beta"
4804 ),
4805 ProviderKind::DeepseekAnthropic => matches!(
4806 normalized.as_str(),
4807 "https://api.deepseek.com/anthropic" | "https://api.deepseek.com/anthropic/v1"
4808 ),
4809 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => matches!(
4810 normalized.as_str(),
4811 "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
4812 ),
4813 ProviderKind::Moonshot => {
4814 normalized == DEFAULT_MOONSHOT_BASE_URL || moonshot_base_url_uses_kimi_code(base_url)
4815 }
4816 ProviderKind::XiaomiMimo => {
4817 xiaomi_mimo_base_url_uses_token_plan(base_url)
4818 || xiaomi_mimo_base_url_is_pay_as_you_go(base_url)
4819 }
4820 ProviderKind::Ollama => {
4821 normalized == DEFAULT_OLLAMA_BASE_URL
4822 || provider::is_exact_ollama_cloud_route(provider, base_url)
4823 }
4824 ProviderKind::OllamaCloud => provider::is_exact_ollama_cloud_route(provider, base_url),
4825 ProviderKind::Edenai => matches!(
4826 normalized.as_str(),
4827 "https://api.edenai.run/v3" | "https://api.eu.edenai.run/v3"
4828 ),
4829 ProviderKind::Custom => false,
4832 _ => {
4833 normalized
4834 == default_base_url_for_provider(provider)
4835 .trim()
4836 .trim_end_matches('/')
4837 .to_ascii_lowercase()
4838 }
4839 }
4840}
4841
4842fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
4843 !provider_base_url_is_official(provider, base_url)
4844}
4845
4846#[must_use]
4853pub fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
4854 base_url_is_custom_for_provider(provider, base_url)
4855}
4856
4857fn should_skip_secret_store_for_provider(
4858 provider: ProviderKind,
4859 base_url: &str,
4860 auth_mode: Option<&str>,
4861) -> bool {
4862 if auth_mode_disables_api_key(auth_mode) {
4863 return true;
4864 }
4865 if base_url_is_custom_for_provider(provider, base_url) {
4866 return true;
4867 }
4868 if auth_mode_requires_api_key(auth_mode) {
4869 return false;
4870 }
4871
4872 matches!(provider, ProviderKind::Sglang | ProviderKind::Vllm)
4873 || (provider == ProviderKind::Ollama
4874 && !provider::is_exact_ollama_cloud_route(provider, base_url))
4875 || base_url_uses_local_host(base_url)
4876}
4877
4878fn stored_api_key_for_provider(
4883 secrets: &Secrets,
4884 provider: ProviderKind,
4885 legacy_ollama_cloud: bool,
4886) -> Option<(String, SecretSource)> {
4887 let mut slots = vec![provider.secret_store_slot()];
4888 if provider == ProviderKind::OllamaCloud && legacy_ollama_cloud {
4889 slots.push(ProviderKind::Ollama.secret_store_slot());
4890 }
4891 slots.into_iter().find_map(|slot| {
4892 secrets
4893 .get(slot)
4894 .ok()
4895 .flatten()
4896 .filter(|value| !value.trim().is_empty())
4897 .map(|value| (value, SecretSource::Keyring))
4898 })
4899}
4900
4901fn env_api_key_for_provider(provider: ProviderKind) -> Option<String> {
4902 if provider == ProviderKind::Huggingface {
4903 return std::env::var("HUGGINGFACE_API_KEY")
4904 .ok()
4905 .filter(|value| !value.trim().is_empty())
4906 .or_else(|| {
4907 std::env::var("HF_TOKEN")
4908 .ok()
4909 .filter(|value| !value.trim().is_empty())
4910 });
4911 }
4912
4913 codewhale_secrets::env_for(provider.as_str())
4914}
4915
4916#[must_use]
4918pub fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
4919 matches!(
4920 auth_mode
4921 .map(str::trim)
4922 .filter(|value| !value.is_empty())
4923 .map(|value| value.to_ascii_lowercase()),
4924 Some(value)
4925 if matches!(
4926 value.as_str(),
4927 "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
4928 )
4929 )
4930}
4931
4932#[must_use]
4934pub fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
4935 matches!(
4936 auth_mode
4937 .map(str::trim)
4938 .filter(|value| !value.is_empty())
4939 .map(|value| value.to_ascii_lowercase()),
4940 Some(value)
4941 if matches!(
4942 value.as_str(),
4943 "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
4944 )
4945 )
4946}
4947
4948#[must_use]
4950pub fn auth_mode_uses_kimi_imported_token(auth_mode: &str) -> bool {
4951 matches!(
4952 auth_mode
4953 .trim()
4954 .to_ascii_lowercase()
4955 .replace('-', "_")
4956 .as_str(),
4957 "kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
4958 )
4959}
4960
4961fn base_url_uses_local_host(base_url: &str) -> bool {
4962 let Some(host) = base_url_host(base_url) else {
4963 return false;
4964 };
4965 let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
4966 if matches!(host.as_str(), "localhost" | "0.0.0.0") {
4967 return true;
4968 }
4969 host.parse::<std::net::IpAddr>()
4970 .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
4971}
4972
4973fn base_url_host(base_url: &str) -> Option<&str> {
4974 let without_scheme = base_url
4975 .split_once("://")
4976 .map_or(base_url, |(_, rest)| rest);
4977 let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
4978 if let Some(rest) = authority.strip_prefix('[') {
4979 return rest.split_once(']').map(|(host, _)| host);
4980 }
4981 authority.split(':').next().filter(|host| !host.is_empty())
4982}
4983
4984#[derive(Debug, Clone, Default)]
4985pub struct CliRuntimeOverrides {
4986 pub provider: Option<ProviderKind>,
4987 pub model: Option<String>,
4988 pub api_key: Option<String>,
4989 pub base_url: Option<String>,
4990 pub auth_mode: Option<String>,
4991 pub output_mode: Option<String>,
4992 pub log_level: Option<String>,
4993 pub telemetry: Option<bool>,
4994 pub approval_policy: Option<String>,
4995 pub sandbox_mode: Option<String>,
4996 pub yolo: Option<bool>,
4997 pub verbosity: Option<String>,
4998}
4999
5000#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5001pub enum RuntimeApiKeySource {
5002 Cli,
5003 ConfigFile,
5004 Keyring,
5005 Env,
5006}
5007
5008impl RuntimeApiKeySource {
5009 #[must_use]
5010 pub fn as_env_value(self) -> &'static str {
5011 match self {
5012 Self::Cli => "cli",
5013 Self::ConfigFile => "config",
5014 Self::Keyring => "keyring",
5015 Self::Env => "env",
5016 }
5017 }
5018}
5019
5020#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5021pub enum ProviderSource {
5022 Cli,
5023 Env(&'static str),
5024 Config,
5025}
5026
5027#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5035pub enum ModelSource {
5036 Cli,
5038 Env,
5040 ProviderConfig,
5042 RootDefaultTextModel,
5044 RootModel,
5046 ProviderDefault,
5048}
5049
5050impl ModelSource {
5051 #[must_use]
5053 pub fn is_explicit(self) -> bool {
5054 !matches!(self, Self::ProviderDefault)
5055 }
5056
5057 #[must_use]
5058 pub fn as_str(self) -> &'static str {
5059 match self {
5060 Self::Cli => "--model",
5061 Self::Env => "environment",
5062 Self::ProviderConfig => "config [providers.*].model",
5063 Self::RootDefaultTextModel => "config default_text_model",
5064 Self::RootModel => "config model",
5065 Self::ProviderDefault => "provider default",
5066 }
5067 }
5068}
5069
5070#[derive(Debug, Clone)]
5071pub struct ResolvedRuntimeOptions {
5072 pub provider: ProviderKind,
5073 pub provider_source: ProviderSource,
5074 pub model: String,
5075 pub model_source: ModelSource,
5076 pub api_key: Option<String>,
5077 pub api_key_source: Option<RuntimeApiKeySource>,
5078 pub base_url: String,
5079 pub auth_mode: Option<String>,
5080 pub insecure_skip_tls_verify: bool,
5081 pub output_mode: Option<String>,
5082 pub log_level: Option<String>,
5083 pub telemetry: bool,
5084 pub telemetry_source: TelemetrySource,
5088 pub telemetry_explicit_off: bool,
5098 pub telemetry_endpoint: Option<String>,
5106 pub approval_policy: Option<String>,
5107 pub sandbox_mode: Option<String>,
5108 pub yolo: Option<bool>,
5109 pub verbosity: Option<String>,
5110 pub http_headers: BTreeMap<String, String>,
5111}
5112
5113#[derive(Debug, Clone)]
5114pub struct ConfigStore {
5115 path: PathBuf,
5116 pub config: ConfigToml,
5117 permissions: PermissionsToml,
5118 original_raw: Option<String>,
5121}
5122
5123impl ConfigStore {
5124 pub fn load(path: Option<PathBuf>) -> Result<Self> {
5125 let path = resolve_config_path(path)?;
5126 let (config, original_raw) = if checked_path_exists(&path)? {
5127 let raw = read_checked_config_file(&path)?;
5128 let mut parsed: ConfigToml = toml::from_str(&raw).map_err(|_| {
5129 anyhow::anyhow!(
5130 "failed to parse config at {}; file contents were omitted",
5131 quote_os_path(&path)
5132 )
5133 })?;
5134 let raw_document: toml::Value = toml::from_str(&raw).map_err(|_| {
5135 anyhow::anyhow!(
5136 "failed to parse config at {}; file contents were omitted",
5137 quote_os_path(&path)
5138 )
5139 })?;
5140 if let Some(provider_id) = raw_document.get("provider").and_then(toml::Value::as_str) {
5141 parsed
5142 .bind_persisted_provider_id(provider_id)
5143 .with_context(|| {
5144 format!("failed to parse config at {}", quote_os_path(&path))
5145 })?;
5146 }
5147 (parsed, Some(raw))
5148 } else {
5149 (ConfigToml::default(), None)
5150 };
5151 let permissions = load_sibling_permissions(&path)?;
5152
5153 Ok(Self {
5154 path,
5155 config,
5156 permissions,
5157 original_raw,
5158 })
5159 }
5160
5161 pub fn rendered_body(&self) -> Result<String> {
5167 let mut serialized =
5168 toml::to_string_pretty(&self.config).context("failed to serialize config")?;
5169 if let Some(provider_id) = self.config.named_custom_provider_id() {
5170 let mut document = serialized
5171 .parse::<toml_edit::DocumentMut>()
5172 .context("failed to edit serialized config")?;
5173 document["provider"] = toml_edit::value(provider_id);
5174 serialized = document.to_string();
5175 }
5176 if let Some(ref original_raw) = self.original_raw {
5177 merge_and_preserve_comments(&serialized, original_raw).with_context(|| {
5178 format!(
5179 "cannot safely preserve config at {}; reload it and retry instead of replacing an unmergeable snapshot",
5180 quote_os_path(&self.path)
5181 )
5182 })
5183 } else {
5184 Ok(serialized)
5185 }
5186 }
5187
5188 pub fn save(&mut self) -> Result<()> {
5189 let path = normalize_config_file_path(self.path.clone())?;
5190 let body = self.rendered_body()?;
5191 replace_config_document_if_unchanged(&path, self.original_raw.as_deref(), &body)?;
5192 self.original_raw = Some(body);
5193 Ok(())
5194 }
5195
5196 pub fn reload(&mut self) -> Result<()> {
5200 *self = Self::load(Some(self.path.clone()))?;
5201 Ok(())
5202 }
5203
5204 #[must_use]
5205 pub fn path(&self) -> &Path {
5206 &self.path
5207 }
5208
5209 #[must_use]
5210 pub fn permissions(&self) -> &PermissionsToml {
5211 &self.permissions
5212 }
5213
5214 #[must_use]
5215 pub fn permissions_path(&self) -> PathBuf {
5216 checked_permissions_path_for_config_path(&self.path)
5217 .expect("ConfigStore path is validated before construction")
5218 }
5219
5220 #[must_use]
5221 pub fn exec_policy_engine(&self) -> ExecPolicyEngine {
5222 if self.permissions.is_empty() {
5223 ExecPolicyEngine::new(Vec::new(), Vec::new())
5224 } else {
5225 ExecPolicyEngine::with_rulesets(vec![self.permissions.ruleset()])
5226 }
5227 }
5228
5229 pub fn append_ask_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
5236 self.append_permission_rules(rules, PermissionAction::Ask)
5237 }
5238
5239 pub fn append_allow_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
5246 for rule in rules {
5247 if rule.action != PermissionAction::Allow {
5248 bail!("append_allow_rules only accepts action = \"allow\"");
5249 }
5250 let Some(workspace) = rule
5251 .workspace
5252 .as_deref()
5253 .and_then(codewhale_execpolicy::normalize_workspace_scope)
5254 else {
5255 bail!("persistent allow rules must be scoped to a workspace");
5256 };
5257 if rule.command.is_some() && !rule.command_exact {
5258 bail!("persistent command allow rules must use exact matching");
5259 }
5260 if rule.command.is_none() && rule.path.is_none() {
5261 bail!("persistent allow rules must match an exact command or path");
5262 }
5263 if let Some(command) = rule.command.as_deref()
5264 && command.trim().is_empty()
5265 {
5266 bail!("persistent command allow rules must not be empty");
5267 }
5268 if let Some(path) = rule.path.as_deref()
5269 && codewhale_execpolicy::normalize_workspace_relative_path(path, &workspace)
5270 .is_none_or(|path| path.is_empty())
5271 {
5272 bail!("persistent path allow rules must stay within the workspace");
5273 }
5274 }
5275 self.append_permission_rules(rules, PermissionAction::Allow)
5276 }
5277
5278 fn append_permission_rules(
5279 &mut self,
5280 rules: &[ToolAskRule],
5281 expected_action: PermissionAction,
5282 ) -> Result<usize> {
5283 if rules.is_empty() {
5284 return Ok(0);
5285 }
5286 if rules.iter().any(|rule| rule.action != expected_action) {
5287 bail!(
5288 "permission rule action does not match requested {:?} persistence",
5289 expected_action
5290 );
5291 }
5292
5293 let path = checked_permissions_path_for_config_path(&self.path)?;
5294 let (added, persisted) = config_document::with_config_write_lock(&path, |path| {
5295 let (_, raw, mut permissions) = read_permissions_state(path)?;
5296 let mut document = parse_permissions_document(path, &raw)?;
5297
5298 if !document.contains_key("rules") {
5299 document["rules"] = toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
5300 }
5301 let rules_item = document
5302 .get_mut("rules")
5303 .expect("rules entry was inserted above");
5304
5305 let mut added = 0;
5306 for rule in rules {
5307 if permissions.rules.contains(rule) {
5308 continue;
5309 }
5310 append_permission_rule(rules_item, rule)?;
5311 permissions.rules.push(rule.clone());
5312 added += 1;
5313 }
5314 if added == 0 {
5315 return Ok((0, permissions));
5316 }
5317
5318 let body = document.to_string();
5319 let persisted = parse_generated_permissions(path, &body)?;
5320 write_permissions_atomic(path, body.as_bytes())?;
5321 Ok((added, persisted))
5322 })?;
5323 self.permissions = persisted;
5324 Ok(added)
5325 }
5326}
5327
5328fn config_backup_file_name(path: &Path) -> OsString {
5329 let mut file_name = path
5330 .file_name()
5331 .map(OsString::from)
5332 .unwrap_or_else(|| OsString::from(CONFIG_FILE_NAME));
5333 file_name.push(".bak");
5334 file_name
5335}
5336
5337fn config_sibling_path_unchecked(config_path: &Path, file_name: &OsStr) -> PathBuf {
5338 config_path
5339 .parent()
5340 .unwrap_or_else(|| Path::new("."))
5341 .join(file_name)
5342}
5343
5344fn checked_config_sibling_path(config_path: &Path, file_name: &OsStr) -> Result<PathBuf> {
5345 let config_path = normalize_config_file_path(config_path.to_path_buf())?;
5346 let parent = config_path
5347 .parent()
5348 .context("config path must include a parent directory")?;
5349 let path = parent.join(file_name);
5350 reject_path_symlink(&path)?;
5351 Ok(path)
5352}
5353
5354#[cfg(test)]
5355fn config_backup_path(path: &Path) -> PathBuf {
5356 config_sibling_path_unchecked(path, &config_backup_file_name(path))
5357}
5358
5359fn checked_config_backup_path(path: &Path) -> Result<PathBuf> {
5360 checked_config_sibling_path(path, &config_backup_file_name(path))
5361}
5362
5363pub fn scrub_plaintext_api_keys_from_config_backup(path: &Path) -> Result<()> {
5370 let backup = checked_config_backup_path(path)?;
5371 if !backup.exists() {
5372 return Ok(());
5373 }
5374
5375 let raw = read_checked_toml_file(&backup, "config backup")?;
5376 let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
5377 format!(
5378 "failed to scrub plaintext API keys from config backup {}",
5379 backup.display()
5380 )
5381 })?;
5382 if scrubbed != raw {
5383 persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
5384 format!(
5385 "failed to write credential-free config backup {}",
5386 backup.display()
5387 )
5388 })?;
5389 }
5390 Ok(())
5391}
5392
5393fn write_one_time_config_backup(path: &Path) -> Result<()> {
5394 let backup = checked_config_backup_path(path)?;
5395 if backup.exists() {
5396 return scrub_plaintext_api_keys_from_config_backup(path);
5397 }
5398
5399 let raw = read_checked_config_file(path)?;
5400 let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
5401 format!(
5402 "failed to scrub plaintext API keys while creating config backup {}",
5403 backup.display()
5404 )
5405 })?;
5406 persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
5407 format!(
5408 "failed to create credential-free config backup {} from {}",
5409 backup.display(),
5410 path.display()
5411 )
5412 })?;
5413 Ok(())
5414}
5415
5416fn config_toml_without_plaintext_api_keys(raw: &str) -> Result<String> {
5417 let mut document = raw
5418 .parse::<toml_edit::DocumentMut>()
5419 .map_err(|_| {
5420 anyhow::anyhow!(
5421 "failed to parse config TOML while removing plaintext API keys; file contents were omitted"
5422 )
5423 })?;
5424 remove_plaintext_api_keys_recursive(document.as_table_mut());
5425 Ok(document.to_string())
5426}
5427
5428fn remove_plaintext_api_keys_recursive(table: &mut dyn toml_edit::TableLike) {
5429 table.remove("api_key");
5430 for (_, item) in table.iter_mut() {
5431 if let toml_edit::Item::ArrayOfTables(tables) = item {
5432 for nested in tables.iter_mut() {
5433 remove_plaintext_api_keys_recursive(nested);
5434 }
5435 } else if let Some(nested) = item.as_table_like_mut() {
5436 remove_plaintext_api_keys_recursive(nested);
5437 }
5438 }
5439}
5440
5441pub fn merge_and_preserve_comments(serialized: &str, original_raw: &str) -> Result<String> {
5449 let original = original_raw
5450 .parse::<toml_edit::DocumentMut>()
5451 .map_err(|_| {
5452 anyhow::anyhow!(
5453 "failed to parse original config for comment merge; file contents were omitted"
5454 )
5455 })?;
5456
5457 let mut new_doc = serialized.parse::<toml_edit::DocumentMut>().map_err(|_| {
5458 anyhow::anyhow!(
5459 "failed to parse serialized config for comment merge; file contents were omitted"
5460 )
5461 })?;
5462
5463 new_doc.set_trailing(original.trailing().clone());
5466
5467 *new_doc.as_table_mut().decor_mut() = original.as_table().decor().clone();
5470
5471 merge_decor_table(new_doc.as_table_mut(), original.as_table());
5472
5473 Ok(new_doc.to_string())
5474}
5475
5476fn merge_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
5479 let keys: Vec<String> = source.iter().map(|(k, _)| k.to_owned()).collect();
5482 for key in &keys {
5483 let Some((source_key, source_item)) = source.get_key_value(key) else {
5484 continue;
5485 };
5486 let Some((mut target_key_mut, target_item)) = target.get_key_value_mut(key) else {
5487 continue;
5488 };
5489
5490 *target_key_mut.leaf_decor_mut() = source_key.leaf_decor().clone();
5492
5493 copy_item_decor(target_item, source_item);
5494
5495 if let (Some(tt), Some(st)) = (target_item.as_table_mut(), source_item.as_table()) {
5496 merge_decor_table(tt, st);
5497 }
5498
5499 if let (Some(ta), Some(sa)) = (
5500 target_item.as_array_of_tables_mut(),
5501 source_item.as_array_of_tables(),
5502 ) {
5503 for (i, source_table) in sa.iter().enumerate() {
5504 if let Some(target_table) = ta.get_mut(i) {
5505 copy_item_decor_table(target_table, source_table);
5506 merge_decor_table(target_table, source_table);
5507 }
5508 }
5509 }
5510 }
5511}
5512
5513fn copy_item_decor(target: &mut toml_edit::Item, source: &toml_edit::Item) {
5517 match (target, source) {
5518 (toml_edit::Item::Table(tt), toml_edit::Item::Table(st)) => {
5519 *tt.decor_mut() = st.decor().clone();
5520 }
5521 (toml_edit::Item::Value(tv), toml_edit::Item::Value(sv)) => {
5522 *tv.decor_mut() = sv.decor().clone();
5523 }
5524 _ => {}
5525 }
5526}
5527
5528fn copy_item_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
5529 *target.decor_mut() = source.decor().clone();
5530}
5531
5532pub fn default_secrets() -> &'static Secrets {
5537 static SECRETS: OnceLock<Secrets> = OnceLock::new();
5538 SECRETS.get_or_init(|| {
5539 #[cfg(test)]
5544 {
5545 Secrets::new(std::sync::Arc::new(
5546 codewhale_secrets::InMemoryKeyringStore::new(),
5547 ))
5548 }
5549 #[cfg(not(test))]
5550 {
5551 Secrets::auto_detect()
5552 }
5553 })
5554}
5555
5556pub use codewhale_paths::{CODEWHALE_APP_DIR, LEGACY_APP_DIR};
5564
5565pub fn codewhale_home() -> Result<PathBuf> {
5570 codewhale_paths::codewhale_home()
5571 .map_err(anyhow::Error::new)?
5572 .context("failed to resolve home directory")
5573}
5574
5575pub fn codewhale_home_is_explicit() -> bool {
5580 codewhale_paths::codewhale_home_is_explicit()
5581}
5582
5583pub fn legacy_deepseek_home() -> Result<PathBuf> {
5587 codewhale_paths::legacy_deepseek_home().context("failed to resolve home directory")
5588}
5589
5590fn ensure_safe_state_subdir(subdir: &str) -> Result<()> {
5598 if subdir.is_empty() {
5599 bail!("state subdir must not be empty");
5600 }
5601 let path = std::path::Path::new(subdir);
5602 if path.is_absolute() {
5603 bail!("state subdir must not be an absolute path: {subdir}");
5604 }
5605 if path.components().any(|c| {
5606 matches!(
5607 c,
5608 std::path::Component::RootDir | std::path::Component::Prefix(_)
5609 )
5610 }) {
5611 bail!("state subdir must not contain a root or prefix: {subdir}");
5612 }
5613 if path
5614 .components()
5615 .any(|c| matches!(c, std::path::Component::ParentDir))
5616 {
5617 bail!("state subdir must not contain parent-dir (..) components: {subdir}");
5618 }
5619 Ok(())
5620}
5621
5622pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
5629 ensure_safe_state_subdir(subdir)?;
5630 let explicit_codewhale_home = codewhale_home_is_explicit();
5631 let primary = codewhale_home()?.join(subdir);
5632 if explicit_codewhale_home || primary.exists() {
5633 return Ok(primary);
5634 }
5635 let legacy = legacy_deepseek_home()?.join(subdir);
5636 if legacy.exists() {
5637 return Ok(legacy);
5638 }
5639 Ok(primary)
5641}
5642
5643pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
5653 let (dir, migration) = ensure_state_dir_with_migration(subdir)?;
5654 if let Some(migration) = migration {
5655 eprintln!("{}", migration.user_notice());
5656 }
5657 Ok(dir)
5658}
5659
5660#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5661pub enum StateMigrationKind {
5662 Relocated,
5663 Copied,
5664}
5665
5666#[derive(Debug, Clone, PartialEq, Eq)]
5667pub struct StateMigration {
5668 pub subdir: String,
5669 pub legacy_path: PathBuf,
5670 pub primary_path: PathBuf,
5671 pub kind: StateMigrationKind,
5672}
5673
5674impl StateMigration {
5675 pub fn user_notice(&self) -> String {
5676 let action = match self.kind {
5677 StateMigrationKind::Relocated => "relocated",
5678 StateMigrationKind::Copied => "copied",
5679 };
5680 let legacy_detail = match self.kind {
5681 StateMigrationKind::Relocated => {
5682 "The legacy .deepseek copy for this state path was removed by the move."
5683 }
5684 StateMigrationKind::Copied => {
5685 "The legacy .deepseek copy was left in place because a direct move failed."
5686 }
5687 };
5688
5689 format!(
5690 "Codewhale migrated legacy state ({action}):\n {} -> {}\nYour data was preserved. Use .codewhale as the canonical state location from now on.\n{legacy_detail}\nIf no other apps use it, you can remove the legacy .deepseek tree after confirming everything looks right.",
5691 self.legacy_path.display(),
5692 self.primary_path.display(),
5693 )
5694 }
5695}
5696
5697pub fn ensure_state_dir_with_migration(subdir: &str) -> Result<(PathBuf, Option<StateMigration>)> {
5701 ensure_safe_state_subdir(subdir)?;
5702 let explicit_codewhale_home = codewhale_home_is_explicit();
5703 let dir = codewhale_home()?.join(subdir);
5704 let migration = if !explicit_codewhale_home {
5705 migrate_legacy_state_dir(&dir, subdir)?
5706 } else {
5707 None
5708 };
5709 std::fs::create_dir_all(&dir)
5710 .with_context(|| format!("failed to create {}/", dir.display()))?;
5711 Ok((dir, migration))
5712}
5713
5714fn migrate_legacy_state_dir(primary: &Path, subdir: &str) -> Result<Option<StateMigration>> {
5719 if primary.exists() || subdir == "." || subdir.is_empty() {
5720 return Ok(None);
5721 }
5722 let legacy = match legacy_deepseek_home() {
5723 Ok(home) => home.join(subdir),
5724 Err(_) => return Ok(None),
5725 };
5726 if !legacy.exists() {
5727 return Ok(None);
5728 }
5729 if let Some(parent) = primary.parent()
5731 && let Err(err) = std::fs::create_dir_all(parent)
5732 {
5733 tracing::warn!(
5734 target: "config::migration",
5735 "Could not create {} for state migration ({}); writing to primary anyway",
5736 parent.display(),
5737 err
5738 );
5739 }
5740 match std::fs::rename(&legacy, primary) {
5741 Ok(()) => {
5742 tracing::info!(
5743 target: "config::migration",
5744 "Migrated legacy state directory {} -> {} (relocated). The .deepseek copy was removed.",
5745 legacy.display(),
5746 primary.display()
5747 );
5748 return Ok(Some(StateMigration {
5749 subdir: subdir.to_string(),
5750 legacy_path: legacy,
5751 primary_path: primary.to_path_buf(),
5752 kind: StateMigrationKind::Relocated,
5753 }));
5754 }
5755 Err(err) => {
5756 match copy_dir_recursive(&legacy, primary) {
5761 Ok(()) => {
5762 tracing::info!(
5763 target: "config::migration",
5764 "Migrated legacy state directory {} -> {} (copied; rename failed: {err}). \
5765 The legacy .deepseek copy was left in place.",
5766 legacy.display(),
5767 primary.display()
5768 );
5769 return Ok(Some(StateMigration {
5770 subdir: subdir.to_string(),
5771 legacy_path: legacy,
5772 primary_path: primary.to_path_buf(),
5773 kind: StateMigrationKind::Copied,
5774 }));
5775 }
5776 Err(copy_err) => {
5777 tracing::warn!(
5778 target: "config::migration",
5779 "Could not migrate legacy state {} -> {} (rename: {err}; copy: {copy_err}). \
5780 New data is written to the primary path; the legacy tree remains untouched.",
5781 legacy.display(),
5782 primary.display()
5783 );
5784 }
5785 }
5786 }
5787 }
5788 Ok(None)
5789}
5790
5791fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
5794 std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
5795 for entry in
5796 std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
5797 {
5798 let entry = entry.with_context(|| format!("failed to read entry in {}", src.display()))?;
5799 let path = entry.path();
5800 let target = dst.join(entry.file_name());
5801 let file_type = entry
5802 .file_type()
5803 .with_context(|| format!("failed to read file type for {}", path.display()))?;
5804 if file_type.is_dir() {
5805 copy_dir_recursive(&path, &target)?;
5806 } else if file_type.is_file() {
5807 std::fs::copy(&path, &target).with_context(|| {
5808 format!("failed to copy {} -> {}", path.display(), target.display())
5809 })?;
5810 }
5811 }
5812 Ok(())
5813}
5814
5815pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> Result<(bool, PathBuf)> {
5822 ensure_safe_state_subdir(subdir)?;
5823 let workspace = normalize_project_workspace(workspace)?;
5824 let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
5825 if primary.exists() {
5826 return Ok((true, primary));
5827 }
5828 let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
5829 Ok((false, legacy))
5830}
5831
5832pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
5835 ensure_safe_state_subdir(subdir)?;
5836 let workspace = normalize_project_workspace(workspace)?;
5837 let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
5838 std::fs::create_dir_all(&dir)
5839 .with_context(|| format!("failed to create {}/", dir.display()))?;
5840 Ok(dir)
5841}
5842
5843pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
5844 if let Some(path) = explicit {
5845 return normalize_config_file_path(path);
5846 }
5847 if let Some(path) = codewhale_paths::config_path_override().map_err(anyhow::Error::new)? {
5848 return normalize_config_file_path(path);
5849 }
5850 default_config_path()
5851}
5852
5853#[must_use]
5873pub fn config_path_is_workspace_scoped(path: &Path) -> bool {
5874 config_path_is_workspace_scoped_with_context(
5875 path,
5876 codewhale_paths::codewhale_home_override()
5877 .ok()
5878 .flatten()
5879 .as_deref(),
5880 codewhale_paths::user_home().as_deref(),
5881 std::env::current_dir().ok().as_deref(),
5882 )
5883}
5884
5885fn config_path_is_workspace_scoped_with_context(
5888 path: &Path,
5889 explicit_codewhale_home: Option<&Path>,
5890 user_home: Option<&Path>,
5891 current_dir: Option<&Path>,
5892) -> bool {
5893 if let Some(home) = explicit_codewhale_home
5894 && same_lexical_or_canonical_path(path, &home.join(CONFIG_FILE_NAME))
5895 {
5896 return false;
5897 }
5898 let Some(parent) = path.parent() else {
5899 return false;
5900 };
5901 let parent_is_app_dir = parent
5902 .file_name()
5903 .and_then(OsStr::to_str)
5904 .is_some_and(|name| name == CODEWHALE_APP_DIR || name == LEGACY_APP_DIR);
5905 if !parent_is_app_dir {
5906 return false;
5907 }
5908 let Some(base) = parent.parent() else {
5909 return true;
5910 };
5911 if let Some(home) = user_home
5912 && same_lexical_or_canonical_path(base, home)
5913 {
5914 return false;
5915 }
5916 if path.is_relative() {
5917 return true;
5919 }
5920 if let Some(cwd) = current_dir
5922 && canonicalize_or_keep(cwd).starts_with(canonicalize_or_keep(base))
5923 {
5924 return true;
5925 }
5926 base.join(".git").exists()
5928}
5929
5930fn same_lexical_or_canonical_path(a: &Path, b: &Path) -> bool {
5933 a == b || canonicalize_or_keep(a) == canonicalize_or_keep(b)
5934}
5935
5936fn canonicalize_or_keep(path: &Path) -> PathBuf {
5937 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
5938}
5939
5940#[cfg(test)]
5941mod credential_scope_tests {
5942 use super::config_path_is_workspace_scoped_with_context;
5943 use std::path::Path;
5944
5945 #[test]
5946 fn config_inside_current_workspace_is_workspace_scoped() {
5947 let temp = tempfile::tempdir().expect("tempdir");
5948 let repo = temp.path().join("repo");
5949 let cwd = repo.join("nested/dir");
5950 for app_dir in [".codewhale", ".deepseek"] {
5951 let config = repo.join(app_dir).join("config.toml");
5952 assert!(
5953 config_path_is_workspace_scoped_with_context(
5954 &config,
5955 None,
5956 Some(Path::new("/home/user")),
5957 Some(&cwd),
5958 ),
5959 "{} should be workspace-scoped when cwd sits inside the repo",
5960 config.display()
5961 );
5962 }
5963 }
5964
5965 #[test]
5966 fn relative_app_dir_config_is_workspace_scoped() {
5967 assert!(config_path_is_workspace_scoped_with_context(
5968 Path::new(".codewhale/config.toml"),
5969 None,
5970 Some(Path::new("/home/user")),
5971 Some(Path::new("/somewhere/else")),
5972 ));
5973 }
5974
5975 #[test]
5976 fn checkout_config_outside_cwd_is_workspace_scoped_via_git_marker() {
5977 let temp = tempfile::tempdir().expect("tempdir");
5978 let repo = temp.path().join("repo");
5979 std::fs::create_dir_all(repo.join(".git")).expect("git marker");
5980 std::fs::create_dir_all(repo.join(".codewhale")).expect("app dir");
5981 assert!(config_path_is_workspace_scoped_with_context(
5982 &repo.join(".codewhale/config.toml"),
5983 None,
5984 Some(Path::new("/home/user")),
5985 Some(Path::new("/somewhere/else")),
5986 ));
5987 }
5988
5989 #[test]
5990 fn user_global_and_custom_locations_are_not_workspace_scoped() {
5991 let home = Path::new("/home/user");
5992 let elsewhere = Some(Path::new("/somewhere/else"));
5993 for global_config in [
5994 "/home/user/.codewhale/config.toml",
5995 "/home/user/.deepseek/config.toml",
5996 "/home/user/team-config.toml",
5997 "/etc/codewhale/config.toml",
5998 ] {
5999 assert!(
6000 !config_path_is_workspace_scoped_with_context(
6001 Path::new(global_config),
6002 None,
6003 Some(home),
6004 elsewhere,
6005 ),
6006 "{global_config} should stay user-global"
6007 );
6008 }
6009 let temp = tempfile::tempdir().expect("tempdir");
6013 assert!(!config_path_is_workspace_scoped_with_context(
6014 &temp.path().join(".codewhale/config.toml"),
6015 None,
6016 Some(home),
6017 elsewhere,
6018 ));
6019 }
6020
6021 #[test]
6022 fn explicit_codewhale_home_config_is_user_global_even_when_dir_is_app_named() {
6023 let temp = tempfile::tempdir().expect("tempdir");
6024 let repo = temp.path().join("repo");
6025 let explicit = repo.join(".codewhale");
6026 assert!(!config_path_is_workspace_scoped_with_context(
6029 &explicit.join("config.toml"),
6030 Some(&explicit),
6031 Some(Path::new("/home/user")),
6032 Some(&repo),
6033 ));
6034 assert!(config_path_is_workspace_scoped_with_context(
6036 &repo.join("other/.codewhale/config.toml"),
6037 Some(&explicit),
6038 Some(Path::new("/home/user")),
6039 Some(&repo.join("other")),
6040 ));
6041 }
6042}
6043
6044#[must_use]
6045pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
6046 config_sibling_path_unchecked(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
6047}
6048
6049fn checked_permissions_path_for_config_path(config_path: &Path) -> Result<PathBuf> {
6050 checked_config_sibling_path(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
6051}
6052
6053pub fn resolve_permissions_path(config_path: Option<PathBuf>) -> Result<PathBuf> {
6054 checked_permissions_path_for_config_path(&resolve_config_path(config_path)?)
6055}
6056
6057pub fn load_permissions_snapshot(config_path: Option<PathBuf>) -> Result<PermissionsSnapshot> {
6060 let path = resolve_permissions_path(config_path)?;
6061 let (file_exists, raw, permissions) = read_permissions_state(&path)?;
6062 let file_state = if !file_exists {
6063 PermissionsFileState::Missing
6064 } else if raw.is_empty() {
6065 PermissionsFileState::Empty
6066 } else {
6067 PermissionsFileState::Present
6068 };
6069 let removal_tokens = (0..permissions.rules.len())
6070 .map(|index| permission_removal_token(&path, &raw, index))
6071 .collect();
6072 Ok(PermissionsSnapshot {
6073 path,
6074 file_state,
6075 permissions,
6076 removal_tokens,
6077 })
6078}
6079
6080pub fn remove_permission_rule(
6087 config_path: Option<PathBuf>,
6088 index: usize,
6089 expected_token: &str,
6090) -> Result<ToolAskRule> {
6091 let path = resolve_permissions_path(config_path)?;
6092 config_document::with_config_write_lock(&path, |path| {
6093 let (file_exists, raw, permissions) = read_permissions_state(path)?;
6094 if !file_exists {
6095 bail!(
6096 "permissions changed after they were listed; reload {} and retry",
6097 quote_os_path(path)
6098 );
6099 }
6100 let rule = permissions.rules.get(index).cloned().with_context(|| {
6101 format!(
6102 "permission rule {} no longer exists in {}; list rules again",
6103 index + 1,
6104 quote_os_path(path)
6105 )
6106 })?;
6107 let current_token = permission_removal_token(path, &raw, index);
6108 if current_token != expected_token {
6109 bail!(
6110 "permissions changed after they were listed; reload {} and retry",
6111 quote_os_path(path)
6112 );
6113 }
6114
6115 let mut document = parse_permissions_document(path, &raw)?;
6116 let rules_item = document.get_mut("rules").with_context(|| {
6117 format!(
6118 "permissions at {} no longer contain a rules array",
6119 quote_os_path(path)
6120 )
6121 })?;
6122 let orphaned_header = remove_permission_rule_item(rules_item, index)?;
6123 if let Some(header) = orphaned_header {
6124 let trailing = format!(
6125 "{header}{}",
6126 document.trailing().as_str().unwrap_or_default()
6127 );
6128 document.set_trailing(trailing);
6129 }
6130 let body = document.to_string();
6131 let persisted = parse_generated_permissions(path, &body)?;
6132 if persisted.rules.len() + 1 != permissions.rules.len() {
6133 bail!(
6134 "refusing inconsistent permission removal at {}",
6135 quote_os_path(path)
6136 );
6137 }
6138 write_permissions_atomic(path, body.as_bytes())?;
6139 Ok(rule)
6140 })
6141}
6142
6143pub fn read_permissions_file(path: &Path) -> Result<String> {
6146 read_checked_permissions_file(path)
6147}
6148
6149fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
6150 let permissions_path = checked_permissions_path_for_config_path(config_path)?;
6151 let (_, _, permissions) = read_permissions_state(&permissions_path)?;
6152 Ok(permissions)
6153}
6154
6155fn read_permissions_state(path: &Path) -> Result<(bool, String, PermissionsToml)> {
6156 let file_exists = checked_path_exists(path)?;
6157 let raw = if file_exists {
6158 read_checked_permissions_file(path)?
6159 } else {
6160 String::new()
6161 };
6162 let permissions = if raw.trim().is_empty() {
6163 PermissionsToml::default()
6164 } else {
6165 toml::from_str(&raw).map_err(|_| {
6166 anyhow::anyhow!(
6167 "failed to parse permissions at {}; file contents were omitted",
6168 quote_os_path(path)
6169 )
6170 })?
6171 };
6172 Ok((file_exists, raw, permissions))
6173}
6174
6175fn parse_permissions_document(path: &Path, raw: &str) -> Result<toml_edit::DocumentMut> {
6176 if raw.trim().is_empty() {
6177 Ok(toml_edit::DocumentMut::new())
6178 } else {
6179 raw.parse::<toml_edit::DocumentMut>().map_err(|_| {
6180 anyhow::anyhow!(
6181 "failed to edit permissions at {}; file contents were omitted",
6182 quote_os_path(path)
6183 )
6184 })
6185 }
6186}
6187
6188fn parse_generated_permissions(path: &Path, body: &str) -> Result<PermissionsToml> {
6189 toml::from_str(body).map_err(|_| {
6190 anyhow::anyhow!(
6191 "generated invalid permissions document for {}; file contents were omitted",
6192 quote_os_path(path)
6193 )
6194 })
6195}
6196
6197fn permission_removal_token(path: &Path, raw: &str, index: usize) -> String {
6198 let mut hasher = Sha256::new();
6199 hasher.update(b"codewhale-permission-removal-v1\0");
6200 hasher.update(quote_os_path(path).as_bytes());
6201 hasher.update(b"\0");
6202 hasher.update(index.to_le_bytes());
6203 hasher.update(b"\0");
6204 hasher.update(raw.as_bytes());
6205 let digest = hasher.finalize();
6206 let mut token = String::with_capacity(24);
6207 for byte in &digest[..12] {
6208 use std::fmt::Write as _;
6209 let _ = write!(&mut token, "{byte:02x}");
6210 }
6211 token
6212}
6213
6214fn append_permission_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
6215 match item {
6216 toml_edit::Item::ArrayOfTables(rules) => {
6217 rules.push(permission_rule_table(rule));
6218 Ok(())
6219 }
6220 toml_edit::Item::Value(value) => {
6221 let Some(rules) = value.as_array_mut() else {
6222 bail!("`rules` in permissions.toml must be an array");
6223 };
6224 rules.push(toml_edit::Value::InlineTable(permission_rule_inline_table(
6225 rule,
6226 )));
6227 Ok(())
6228 }
6229 _ => bail!("`rules` in permissions.toml must be an array"),
6230 }
6231}
6232
6233fn remove_permission_rule_item(item: &mut toml_edit::Item, index: usize) -> Result<Option<String>> {
6234 match item {
6235 toml_edit::Item::ArrayOfTables(rules) => {
6236 if index >= rules.len() {
6237 bail!("permission rule index changed before removal");
6238 }
6239 let file_header = if index == 0 {
6240 rules
6241 .get(index)
6242 .and_then(|rule| rule.decor().prefix())
6243 .and_then(toml_edit::RawString::as_str)
6244 .map(str::to_owned)
6245 } else {
6246 None
6247 };
6248 rules.remove(index);
6249 if let Some(header) = file_header.as_deref()
6250 && let Some(next_rule) = rules.get_mut(0)
6251 {
6252 let next_prefix = next_rule
6253 .decor()
6254 .prefix()
6255 .and_then(toml_edit::RawString::as_str)
6256 .unwrap_or_default()
6257 .to_owned();
6258 next_rule
6259 .decor_mut()
6260 .set_prefix(format!("{header}{next_prefix}"));
6261 return Ok(None);
6262 }
6263 Ok(file_header)
6264 }
6265 toml_edit::Item::Value(value) => {
6266 let Some(rules) = value.as_array_mut() else {
6267 bail!("`rules` in permissions.toml must be an array");
6268 };
6269 if index >= rules.len() {
6270 bail!("permission rule index changed before removal");
6271 }
6272 rules.remove(index);
6273 Ok(None)
6274 }
6275 _ => bail!("`rules` in permissions.toml must be an array"),
6276 }
6277}
6278
6279fn permission_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
6280 let mut table = toml_edit::Table::new();
6281 table["tool"] = toml_edit::value(rule.tool.clone());
6282 if let Some(command) = rule.command.as_deref() {
6283 table["command"] = toml_edit::value(command);
6284 }
6285 if rule.command_exact {
6286 table["command_exact"] = toml_edit::value(true);
6287 }
6288 if let Some(path) = rule.path.as_deref() {
6289 table["path"] = toml_edit::value(path);
6290 }
6291 if let Some(workspace) = rule.workspace.as_deref() {
6292 table["workspace"] = toml_edit::value(workspace);
6293 }
6294 if rule.action != PermissionAction::Ask {
6295 table["action"] = toml_edit::value(match rule.action {
6296 PermissionAction::Allow => "allow",
6297 PermissionAction::Ask => "ask",
6298 PermissionAction::Deny => "deny",
6299 });
6300 }
6301 table
6302}
6303
6304fn permission_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
6305 let mut table = toml_edit::InlineTable::new();
6306 table.insert("tool", toml_edit::Value::from(rule.tool.clone()));
6307 if let Some(command) = rule.command.as_deref() {
6308 table.insert("command", toml_edit::Value::from(command));
6309 }
6310 if rule.command_exact {
6311 table.insert("command_exact", toml_edit::Value::from(true));
6312 }
6313 if let Some(path) = rule.path.as_deref() {
6314 table.insert("path", toml_edit::Value::from(path));
6315 }
6316 if let Some(workspace) = rule.workspace.as_deref() {
6317 table.insert("workspace", toml_edit::Value::from(workspace));
6318 }
6319 if rule.action != PermissionAction::Ask {
6320 table.insert(
6321 "action",
6322 toml_edit::Value::from(match rule.action {
6323 PermissionAction::Allow => "allow",
6324 PermissionAction::Ask => "ask",
6325 PermissionAction::Deny => "deny",
6326 }),
6327 );
6328 }
6329 table
6330}
6331
6332fn write_permissions_atomic(path: &Path, body: &[u8]) -> Result<()> {
6333 let parent = path.parent().with_context(|| {
6334 format!(
6335 "permissions path has no parent directory: {}",
6336 path.display()
6337 )
6338 })?;
6339 fs::create_dir_all(parent).with_context(|| {
6340 format!(
6341 "failed to create permissions directory {}",
6342 parent.display()
6343 )
6344 })?;
6345
6346 let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
6347 format!(
6348 "failed to create temporary permissions file in {}",
6349 parent.display()
6350 )
6351 })?;
6352 #[cfg(unix)]
6353 temporary
6354 .as_file()
6355 .set_permissions(fs::Permissions::from_mode(0o600))
6356 .with_context(|| {
6357 format!(
6358 "failed to secure temporary permissions file for {}",
6359 path.display()
6360 )
6361 })?;
6362 temporary
6363 .write_all(body)
6364 .with_context(|| format!("failed to write permissions at {}", path.display()))?;
6365 temporary
6366 .as_file()
6367 .sync_all()
6368 .with_context(|| format!("failed to sync permissions at {}", path.display()))?;
6369 temporary
6370 .persist(path)
6371 .map_err(|error| error.error)
6372 .with_context(|| format!("failed to replace permissions at {}", path.display()))?;
6373 Ok(())
6374}
6375
6376pub fn default_config_path() -> Result<PathBuf> {
6377 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
6380 if codewhale_home_is_explicit() || primary.exists() {
6381 return Ok(primary);
6382 }
6383 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
6384 if legacy.exists() {
6385 return Ok(legacy);
6386 }
6387 Ok(primary)
6389}
6390
6391#[derive(Debug, Clone, PartialEq, Eq)]
6392pub struct ConfigMigration {
6393 pub legacy_path: PathBuf,
6394 pub primary_path: PathBuf,
6395}
6396
6397impl ConfigMigration {
6398 pub fn user_notice(&self) -> String {
6399 format!(
6400 "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.",
6401 self.legacy_path.display(),
6402 self.primary_path.display()
6403 )
6404 }
6405}
6406
6407pub fn migrate_config_if_needed() -> Result<Option<ConfigMigration>> {
6412 if codewhale_home_is_explicit() {
6413 return Ok(None);
6414 }
6415 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
6416 if primary.exists() {
6417 return Ok(None);
6418 }
6419 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
6420 if !legacy.exists() {
6421 return Ok(None);
6422 }
6423 if let Some(parent) = primary.parent() {
6425 std::fs::create_dir_all(parent).context("failed to create codewhale config directory")?;
6426 }
6427 std::fs::copy(&legacy, &primary)
6428 .context("failed to migrate config from deepseek to codewhale home")?;
6429 tracing::info!(
6430 "Migrated config from {} to {}",
6431 legacy.display(),
6432 primary.display()
6433 );
6434 Ok(Some(ConfigMigration {
6435 legacy_path: legacy,
6436 primary_path: primary,
6437 }))
6438}
6439
6440fn parse_bool(raw: &str) -> Result<bool> {
6441 match raw.trim().to_ascii_lowercase().as_str() {
6442 "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
6443 "0" | "false" | "no" | "off" | "disabled" => Ok(false),
6444 _ => bail!("invalid boolean '{raw}'"),
6445 }
6446}
6447
6448fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
6449 let mut headers = BTreeMap::new();
6450 for pair in raw.trim().split(',') {
6451 let pair = pair.trim();
6452 if pair.is_empty() {
6453 continue;
6454 }
6455 let Some((name, value)) = pair.split_once('=') else {
6456 bail!("invalid header pair '{pair}', expected name=value");
6457 };
6458 let name = name.trim();
6459 let value = value.trim();
6460 if name.is_empty() {
6461 bail!("header name cannot be empty");
6462 }
6463 if value.is_empty() {
6464 continue;
6465 }
6466 headers.insert(name.to_string(), value.to_string());
6467 }
6468 Ok(headers)
6469}
6470
6471fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
6472 if headers.is_empty() {
6473 return None;
6474 }
6475 Some(
6476 headers
6477 .iter()
6478 .map(|(name, value)| format!("{name}={value}"))
6479 .collect::<Vec<_>>()
6480 .join(","),
6481 )
6482}
6483
6484fn serialize_http_headers_for_display(headers: &BTreeMap<String, String>) -> Option<String> {
6485 if headers.is_empty() {
6486 return None;
6487 }
6488 Some(
6489 headers
6490 .iter()
6491 .map(|(name, value)| {
6492 let display_value = if is_sensitive_config_key(name) {
6493 redact_secret(value)
6494 } else {
6495 value.clone()
6496 };
6497 format!("{name}={display_value}")
6498 })
6499 .collect::<Vec<_>>()
6500 .join(","),
6501 )
6502}
6503
6504fn redact_secret(secret: &str) -> String {
6505 let chars: Vec<char> = secret.chars().collect();
6506 if chars.len() <= 16 {
6507 return "********".to_string();
6508 }
6509 let prefix: String = chars.iter().take(4).collect();
6510 let suffix: String = chars
6511 .iter()
6512 .rev()
6513 .take(4)
6514 .collect::<Vec<_>>()
6515 .into_iter()
6516 .rev()
6517 .collect();
6518 format!("{prefix}***{suffix}")
6519}
6520
6521#[must_use]
6522pub fn is_sensitive_config_key(key: &str) -> bool {
6523 let Some(segment) = key.rsplit('.').next() else {
6524 return false;
6525 };
6526 let normalized = segment
6527 .trim()
6528 .trim_matches('"')
6529 .replace('-', "_")
6530 .to_ascii_lowercase();
6531
6532 matches!(
6533 normalized.as_str(),
6534 "api_key"
6535 | "apikey"
6536 | "api_keys"
6537 | "authorization"
6538 | "bearer"
6539 | "client_secret"
6540 | "credential"
6541 | "credentials"
6542 | "id_token"
6543 | "password"
6544 | "passwords"
6545 | "passwd"
6546 | "proxy_authorization"
6547 | "refresh_token"
6548 | "secret"
6549 | "secrets"
6550 | "token"
6551 | "tokens"
6552 ) || normalized.ends_with("_api_key")
6553 || normalized.ends_with("_authorization")
6554 || normalized.ends_with("_password")
6555 || normalized.ends_with("_secret")
6556 || normalized.ends_with("_token")
6557}
6558
6559fn redact_toml_value_for_display(key: &str, value: &toml::Value) -> String {
6560 redact_toml_value_for_display_inner(key, false, value).to_string()
6561}
6562
6563fn toml_value_as_u64(value: &toml::Value) -> Option<u64> {
6564 match value {
6565 toml::Value::Integer(value) => u64::try_from(*value).ok(),
6566 toml::Value::String(value) => value.trim().parse().ok(),
6567 _ => None,
6568 }
6569}
6570
6571fn redact_toml_value_for_display_inner(
6572 key: &str,
6573 sensitive_ancestor: bool,
6574 value: &toml::Value,
6575) -> toml::Value {
6576 let sensitive = sensitive_ancestor || is_sensitive_config_key(key);
6577 match value {
6578 toml::Value::String(value) if sensitive => toml::Value::String(redact_secret(value)),
6579 toml::Value::Array(values) => toml::Value::Array(
6580 values
6581 .iter()
6582 .map(|value| redact_toml_value_for_display_inner(key, sensitive, value))
6583 .collect(),
6584 ),
6585 toml::Value::Table(table) => {
6586 let mut redacted = toml::map::Map::new();
6587 for (child_key, child_value) in table {
6588 let path = if key.is_empty() {
6589 child_key.clone()
6590 } else {
6591 format!("{key}.{child_key}")
6592 };
6593 redacted.insert(
6594 child_key.clone(),
6595 redact_toml_value_for_display_inner(&path, sensitive, child_value),
6596 );
6597 }
6598 toml::Value::Table(redacted)
6599 }
6600 _ if sensitive => toml::Value::String("********".to_string()),
6601 _ => value.clone(),
6602 }
6603}
6604
6605fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
6606 if path.as_os_str().is_empty() {
6607 bail!("config path cannot be empty");
6608 }
6609 if path
6610 .components()
6611 .any(|component| matches!(component, Component::ParentDir))
6612 {
6613 bail!("config path cannot contain '..' components");
6614 }
6615 if path.file_name().is_none() {
6616 bail!("config path must include a file name");
6617 }
6618 let absolute = if path.is_absolute() {
6619 path
6620 } else {
6621 std::env::current_dir()
6622 .context("failed to resolve current directory for config path")?
6623 .join(path)
6624 };
6625 let file_name = absolute
6626 .file_name()
6627 .map(OsString::from)
6628 .context("config path must include a file name")?;
6629 let parent = absolute
6630 .parent()
6631 .context("config path must include a parent directory")?;
6632 let parent = match parent.canonicalize() {
6633 Ok(parent) => parent,
6634 Err(err) if err.kind() == std::io::ErrorKind::NotFound => parent.to_path_buf(),
6635 Err(err) => {
6636 return Err(err).with_context(|| {
6637 format!("failed to resolve config directory {}", parent.display())
6638 });
6639 }
6640 };
6641 let normalized = parent.join(file_name);
6642 reject_path_symlink(&normalized)?;
6643 Ok(normalized)
6644}
6645
6646fn normalize_project_workspace(workspace: &Path) -> Result<PathBuf> {
6647 if workspace.as_os_str().is_empty() {
6648 bail!("project workspace path cannot be empty");
6649 }
6650 if workspace
6651 .components()
6652 .any(|component| matches!(component, Component::ParentDir))
6653 {
6654 bail!("project workspace path cannot contain '..' components");
6655 }
6656 let absolute = if workspace.is_absolute() {
6657 workspace.to_path_buf()
6658 } else {
6659 std::env::current_dir()
6660 .context("failed to resolve current directory for project workspace")?
6661 .join(workspace)
6662 };
6663 match absolute.canonicalize() {
6664 Ok(path) => Ok(path),
6665 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
6666 Ok(normalize_path_components(&absolute))
6667 }
6668 Err(err) => Err(err).with_context(|| {
6669 format!(
6670 "failed to resolve project workspace {}",
6671 workspace.display()
6672 )
6673 }),
6674 }
6675}
6676
6677fn normalize_path_components(path: &Path) -> PathBuf {
6678 let mut normalized = PathBuf::new();
6679 for component in path.components() {
6680 match component {
6681 Component::Prefix(_) | Component::RootDir => normalized.push(component.as_os_str()),
6682 Component::CurDir => {}
6683 Component::ParentDir => {
6684 normalized.pop();
6685 }
6686 Component::Normal(part) => normalized.push(part),
6687 }
6688 }
6689 if normalized.as_os_str().is_empty() {
6690 PathBuf::from(".")
6691 } else {
6692 normalized
6693 }
6694}
6695
6696fn checked_path_exists(path: &Path) -> Result<bool> {
6697 let path = normalize_config_file_path(path.to_path_buf())?;
6698 path.try_exists()
6699 .with_context(|| format!("failed to inspect config path {}", path.display()))
6700}
6701
6702fn read_checked_config_file(path: &Path) -> Result<String> {
6703 read_checked_toml_file(path, "config")
6704}
6705
6706fn read_checked_permissions_file(path: &Path) -> Result<String> {
6707 read_checked_toml_file(path, "permissions")
6708}
6709
6710fn read_checked_toml_file(path: &Path, label: &str) -> Result<String> {
6711 let path = normalize_config_file_path(path.to_path_buf())?;
6712 read_string_no_follow(&path)
6713 .with_context(|| format!("failed to read {label} at {}", path.display()))
6714}
6715
6716#[cfg(unix)]
6717fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
6718 let mut file = fs::OpenOptions::new()
6719 .read(true)
6720 .custom_flags(libc::O_NOFOLLOW)
6721 .open(path)?;
6722 let mut raw = String::new();
6723 file.read_to_string(&mut raw)?;
6724 Ok(raw)
6725}
6726
6727#[cfg(not(unix))]
6728fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
6729 fs::read_to_string(path)
6730}
6731
6732fn reject_path_symlink(path: &Path) -> Result<()> {
6733 let Ok(metadata) = fs::symlink_metadata(path) else {
6734 return Ok(());
6735 };
6736 if metadata.file_type().is_symlink() {
6737 bail!("config path must not be a symlink: {}", path.display());
6738 }
6739 Ok(())
6740}
6741
6742#[derive(Debug, Clone, Default)]
6743struct EnvRuntimeOverrides {
6744 provider: Option<ProviderKind>,
6745 provider_source: Option<&'static str>,
6746 model: Option<String>,
6747 volcengine_model: Option<String>,
6748 wanjie_ark_model: Option<String>,
6749 openrouter_model: Option<String>,
6750 orcarouter_model: Option<String>,
6751 moonshot_model: Option<String>,
6752 xiaomi_mimo_model: Option<String>,
6753 xiaomi_mimo_mode: Option<String>,
6754 novita_model: Option<String>,
6755 fireworks_model: Option<String>,
6756 arcee_model: Option<String>,
6757 output_mode: Option<String>,
6758 auth_mode: Option<String>,
6759 log_level: Option<String>,
6760 telemetry: Option<bool>,
6761 telemetry_env_invalid: bool,
6766 telemetry_floor: bool,
6771 telemetry_endpoint: Option<String>,
6775 approval_policy: Option<String>,
6776 sandbox_mode: Option<String>,
6777 yolo: Option<bool>,
6778 verbosity: Option<String>,
6779 http_headers: Option<BTreeMap<String, String>>,
6780 deepseek_base_url: Option<String>,
6781 deepseek_anthropic_base_url: Option<String>,
6782 nvidia_base_url: Option<String>,
6783 openai_base_url: Option<String>,
6784 atlascloud_base_url: Option<String>,
6785 volcengine_base_url: Option<String>,
6786 wanjie_ark_base_url: Option<String>,
6787 openrouter_base_url: Option<String>,
6788 orcarouter_base_url: Option<String>,
6789 xiaomi_mimo_base_url: Option<String>,
6790 novita_base_url: Option<String>,
6791 fireworks_base_url: Option<String>,
6792 siliconflow_base_url: Option<String>,
6793 siliconflow_model: Option<String>,
6794 arcee_base_url: Option<String>,
6795 moonshot_base_url: Option<String>,
6796 sglang_base_url: Option<String>,
6797 vllm_base_url: Option<String>,
6798 ollama_base_url: Option<String>,
6799 ollama_cloud_base_url: Option<String>,
6800 ollama_cloud_model: Option<String>,
6801 huggingface_base_url: Option<String>,
6802 huggingface_model: Option<String>,
6803 together_base_url: Option<String>,
6804 together_model: Option<String>,
6805 qianfan_base_url: Option<String>,
6806 qianfan_model: Option<String>,
6807 openai_codex_base_url: Option<String>,
6808 openai_codex_model: Option<String>,
6809 anthropic_base_url: Option<String>,
6810 anthropic_model: Option<String>,
6811 openmodel_base_url: Option<String>,
6812 openmodel_model: Option<String>,
6813 zai_base_url: Option<String>,
6814 zai_model: Option<String>,
6815 stepfun_base_url: Option<String>,
6816 stepfun_model: Option<String>,
6817 minimax_base_url: Option<String>,
6818 minimax_anthropic_base_url: Option<String>,
6819 minimax_model: Option<String>,
6820 deepinfra_base_url: Option<String>,
6821 deepinfra_model: Option<String>,
6822 sakana_base_url: Option<String>,
6823 sakana_model: Option<String>,
6824 longcat_base_url: Option<String>,
6825 longcat_model: Option<String>,
6826 opencode_go_base_url: Option<String>,
6827 opencode_go_model: Option<String>,
6828 opencode_zen_base_url: Option<String>,
6829 opencode_zen_model: Option<String>,
6830 meta_base_url: Option<String>,
6831 meta_model: Option<String>,
6832 xai_base_url: Option<String>,
6833 xai_model: Option<String>,
6834 mistral_base_url: Option<String>,
6835 mistral_model: Option<String>,
6836 google_base_url: Option<String>,
6837 google_model: Option<String>,
6838 antigravity_base_url: Option<String>,
6839 antigravity_model: Option<String>,
6840 telecomjs_base_url: Option<String>,
6841 telecomjs_model: Option<String>,
6842 edenai_base_url: Option<String>,
6843 edenai_model: Option<String>,
6844 modelstudio_token_plan_base_url: Option<String>,
6845 modelstudio_token_plan_model: Option<String>,
6846 modelstudio_coding_plan_base_url: Option<String>,
6847 modelstudio_coding_plan_model: Option<String>,
6848}
6849
6850impl EnvRuntimeOverrides {
6851 fn load() -> Self {
6852 let (provider, provider_source) = Self::load_provider();
6853 let (telemetry, telemetry_env_invalid) = Self::load_telemetry();
6854 let telemetry_floor = telemetry_floor_in_force();
6855 Self {
6856 provider,
6857 provider_source,
6858 model: std::env::var("CODEWHALE_MODEL")
6859 .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
6860 .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
6861 .ok()
6862 .filter(|v| !v.trim().is_empty()),
6863 volcengine_model: std::env::var("VOLCENGINE_MODEL")
6864 .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
6865 .ok()
6866 .filter(|v| !v.trim().is_empty()),
6867 wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
6868 .or_else(|_| std::env::var("WANJIE_MODEL"))
6869 .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
6870 .ok()
6871 .filter(|v| !v.trim().is_empty()),
6872 openrouter_model: std::env::var("OPENROUTER_MODEL")
6873 .ok()
6874 .filter(|v| !v.trim().is_empty()),
6875 orcarouter_model: std::env::var("ORCAROUTER_MODEL")
6876 .ok()
6877 .filter(|v| !v.trim().is_empty()),
6878 moonshot_model: std::env::var("MOONSHOT_MODEL")
6879 .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
6880 .or_else(|_| std::env::var("KIMI_MODEL"))
6881 .ok()
6882 .filter(|v| !v.trim().is_empty()),
6883 xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
6884 .or_else(|_| std::env::var("MIMO_MODEL"))
6885 .ok()
6886 .filter(|v| !v.trim().is_empty()),
6887 xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
6888 .or_else(|_| std::env::var("MIMO_MODE"))
6889 .ok()
6890 .filter(|v| !v.trim().is_empty()),
6891 novita_model: std::env::var("NOVITA_MODEL")
6892 .ok()
6893 .filter(|v| !v.trim().is_empty()),
6894 fireworks_model: std::env::var("FIREWORKS_MODEL")
6895 .ok()
6896 .filter(|v| !v.trim().is_empty()),
6897 arcee_model: std::env::var("ARCEE_MODEL")
6898 .ok()
6899 .filter(|v| !v.trim().is_empty()),
6900 verbosity: std::env::var("CODEWHALE_VERBOSITY")
6901 .or_else(|_| std::env::var("DEEPSEEK_VERBOSITY"))
6902 .ok(),
6903 output_mode: std::env::var("CODEWHALE_OUTPUT_MODE")
6904 .or_else(|_| std::env::var("DEEPSEEK_OUTPUT_MODE"))
6905 .ok(),
6906 auth_mode: std::env::var("CODEWHALE_AUTH_MODE")
6907 .or_else(|_| std::env::var("DEEPSEEK_AUTH_MODE"))
6908 .ok(),
6909 log_level: std::env::var("CODEWHALE_LOG_LEVEL")
6910 .or_else(|_| std::env::var("DEEPSEEK_LOG_LEVEL"))
6911 .ok(),
6912 telemetry,
6913 telemetry_env_invalid,
6914 telemetry_floor,
6915 telemetry_endpoint: std::env::var("CODEWHALE_TELEMETRY_ENDPOINT")
6922 .or_else(|_| std::env::var("DEEPSEEK_TELEMETRY_ENDPOINT"))
6923 .ok(),
6924 approval_policy: std::env::var("CODEWHALE_APPROVAL_POLICY")
6925 .or_else(|_| std::env::var("DEEPSEEK_APPROVAL_POLICY"))
6926 .ok(),
6927 sandbox_mode: std::env::var("CODEWHALE_SANDBOX_MODE")
6928 .or_else(|_| std::env::var("DEEPSEEK_SANDBOX_MODE"))
6929 .ok(),
6930 yolo: std::env::var("CODEWHALE_YOLO")
6931 .or_else(|_| std::env::var("DEEPSEEK_YOLO"))
6932 .ok()
6933 .and_then(|v| match parse_bool(&v) {
6934 Ok(b) => Some(b),
6935 Err(_) => {
6936 tracing::warn!("Invalid CODEWHALE_YOLO/DEEPSEEK_YOLO value '{v}', expected true/false");
6937 None
6938 }
6939 }),
6940 http_headers: std::env::var("CODEWHALE_HTTP_HEADERS")
6941 .or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
6942 .ok()
6943 .and_then(|value| match parse_http_headers(&value) {
6944 Ok(h) => Some(h),
6945 Err(_) => {
6946 tracing::warn!("Invalid CODEWHALE_HTTP_HEADERS/DEEPSEEK_HTTP_HEADERS value, expected format: header1=val1,header2=val2");
6947 None
6948 }
6949 })
6950 .filter(|headers| !headers.is_empty()),
6951 deepseek_base_url: std::env::var("CODEWHALE_BASE_URL")
6952 .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
6953 .ok()
6954 .filter(|v| !v.trim().is_empty()),
6955 deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
6956 .or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
6957 .ok()
6958 .filter(|v| !v.trim().is_empty()),
6959 nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
6960 .or_else(|_| std::env::var("NIM_BASE_URL"))
6961 .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
6962 .ok()
6963 .filter(|v| !v.trim().is_empty()),
6964 openai_base_url: std::env::var("OPENAI_BASE_URL")
6965 .ok()
6966 .filter(|v| !v.trim().is_empty()),
6967 atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
6968 .ok()
6969 .filter(|v| !v.trim().is_empty()),
6970 volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
6971 .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
6972 .or_else(|_| std::env::var("ARK_BASE_URL"))
6973 .ok()
6974 .filter(|v| !v.trim().is_empty()),
6975 wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
6976 .or_else(|_| std::env::var("WANJIE_BASE_URL"))
6977 .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
6978 .ok()
6979 .filter(|v| !v.trim().is_empty()),
6980 openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
6981 .ok()
6982 .filter(|v| !v.trim().is_empty()),
6983 orcarouter_base_url: std::env::var("ORCAROUTER_BASE_URL")
6984 .ok()
6985 .filter(|v| !v.trim().is_empty()),
6986 xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
6987 .or_else(|_| std::env::var("MIMO_BASE_URL"))
6988 .ok()
6989 .filter(|v| !v.trim().is_empty()),
6990 novita_base_url: std::env::var("NOVITA_BASE_URL")
6991 .ok()
6992 .filter(|v| !v.trim().is_empty()),
6993 fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
6994 .ok()
6995 .filter(|v| !v.trim().is_empty()),
6996 siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
6997 .ok()
6998 .filter(|v| !v.trim().is_empty()),
6999 siliconflow_model: std::env::var("SILICONFLOW_MODEL")
7000 .ok()
7001 .filter(|v| !v.trim().is_empty()),
7002 arcee_base_url: std::env::var("ARCEE_BASE_URL")
7003 .ok()
7004 .filter(|v| !v.trim().is_empty()),
7005 moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
7006 .or_else(|_| std::env::var("KIMI_BASE_URL"))
7007 .ok()
7008 .filter(|v| !v.trim().is_empty()),
7009 sglang_base_url: std::env::var("SGLANG_BASE_URL")
7010 .ok()
7011 .filter(|v| !v.trim().is_empty()),
7012 vllm_base_url: std::env::var("VLLM_BASE_URL")
7013 .ok()
7014 .filter(|v| !v.trim().is_empty()),
7015 ollama_base_url: std::env::var("OLLAMA_BASE_URL")
7016 .ok()
7017 .filter(|v| !v.trim().is_empty()),
7018 ollama_cloud_base_url: std::env::var("OLLAMA_CLOUD_BASE_URL")
7019 .ok()
7020 .filter(|v| !v.trim().is_empty()),
7021 ollama_cloud_model: std::env::var("OLLAMA_CLOUD_MODEL")
7022 .ok()
7023 .filter(|v| !v.trim().is_empty()),
7024 huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
7025 .or_else(|_| std::env::var("HF_BASE_URL"))
7026 .ok()
7027 .filter(|v| !v.trim().is_empty()),
7028 huggingface_model: std::env::var("HUGGINGFACE_MODEL")
7029 .or_else(|_| std::env::var("HF_MODEL"))
7030 .ok()
7031 .filter(|v| !v.trim().is_empty()),
7032 together_base_url: std::env::var("TOGETHER_BASE_URL")
7033 .ok()
7034 .filter(|v| !v.trim().is_empty()),
7035 together_model: std::env::var("TOGETHER_MODEL")
7036 .ok()
7037 .filter(|v| !v.trim().is_empty()),
7038 qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
7039 .ok()
7040 .filter(|v| !v.trim().is_empty())
7041 .or_else(|| {
7042 std::env::var("BAIDU_QIANFAN_BASE_URL")
7043 .ok()
7044 .filter(|v| !v.trim().is_empty())
7045 }),
7046 qianfan_model: std::env::var("QIANFAN_MODEL")
7047 .ok()
7048 .filter(|v| !v.trim().is_empty())
7049 .or_else(|| {
7050 std::env::var("BAIDU_QIANFAN_MODEL")
7051 .ok()
7052 .filter(|v| !v.trim().is_empty())
7053 }),
7054 openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
7055 .or_else(|_| std::env::var("CODEX_BASE_URL"))
7056 .ok()
7057 .filter(|v| !v.trim().is_empty()),
7058 openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
7059 .or_else(|_| std::env::var("CODEX_MODEL"))
7060 .ok()
7061 .filter(|v| !v.trim().is_empty()),
7062 anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
7063 .ok()
7064 .filter(|v| !v.trim().is_empty()),
7065 anthropic_model: std::env::var("ANTHROPIC_MODEL")
7066 .ok()
7067 .filter(|v| !v.trim().is_empty()),
7068 openmodel_base_url: std::env::var("OPENMODEL_BASE_URL")
7069 .ok()
7070 .filter(|v| !v.trim().is_empty()),
7071 openmodel_model: std::env::var("OPENMODEL_MODEL")
7072 .ok()
7073 .filter(|v| !v.trim().is_empty()),
7074 zai_base_url: std::env::var("ZAI_BASE_URL")
7075 .or_else(|_| std::env::var("Z_AI_BASE_URL"))
7076 .or_else(|_| std::env::var("ZHIPU_BASE_URL"))
7077 .or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
7078 .or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
7079 .ok()
7080 .filter(|v| !v.trim().is_empty()),
7081 zai_model: std::env::var("ZAI_MODEL")
7082 .or_else(|_| std::env::var("Z_AI_MODEL"))
7083 .or_else(|_| std::env::var("ZHIPU_MODEL"))
7084 .or_else(|_| std::env::var("ZHIPUAI_MODEL"))
7085 .or_else(|_| std::env::var("BIGMODEL_MODEL"))
7086 .or_else(|_| std::env::var("GLM_MODEL"))
7087 .ok()
7088 .filter(|v| !v.trim().is_empty()),
7089 stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
7090 .or_else(|_| std::env::var("STEP_BASE_URL"))
7091 .ok()
7092 .filter(|v| !v.trim().is_empty()),
7093 stepfun_model: std::env::var("STEPFUN_MODEL")
7094 .or_else(|_| std::env::var("STEP_MODEL"))
7095 .ok()
7096 .filter(|v| !v.trim().is_empty()),
7097 minimax_base_url: std::env::var("MINIMAX_BASE_URL")
7098 .ok()
7099 .filter(|v| !v.trim().is_empty()),
7100 minimax_anthropic_base_url: std::env::var("MINIMAX_ANTHROPIC_BASE_URL")
7101 .ok()
7102 .filter(|v| !v.trim().is_empty()),
7103 minimax_model: std::env::var("MINIMAX_MODEL")
7104 .ok()
7105 .filter(|v| !v.trim().is_empty()),
7106 deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
7107 .ok()
7108 .filter(|v| !v.trim().is_empty()),
7109 deepinfra_model: std::env::var("DEEPINFRA_MODEL")
7110 .ok()
7111 .filter(|v| !v.trim().is_empty()),
7112 sakana_base_url: std::env::var("SAKANA_BASE_URL")
7113 .ok()
7114 .filter(|v| !v.trim().is_empty()),
7115 sakana_model: std::env::var("SAKANA_MODEL")
7116 .ok()
7117 .filter(|v| !v.trim().is_empty()),
7118 longcat_base_url: std::env::var("LONGCAT_BASE_URL")
7119 .ok()
7120 .filter(|v| !v.trim().is_empty()),
7121 longcat_model: std::env::var("LONGCAT_MODEL")
7122 .ok()
7123 .filter(|v| !v.trim().is_empty()),
7124 opencode_go_base_url: std::env::var("OPENCODE_GO_BASE_URL")
7125 .ok()
7126 .filter(|v| !v.trim().is_empty()),
7127 opencode_go_model: std::env::var("OPENCODE_GO_MODEL")
7128 .ok()
7129 .filter(|v| !v.trim().is_empty()),
7130 opencode_zen_base_url: std::env::var("OPENCODE_ZEN_BASE_URL")
7131 .ok()
7132 .filter(|v| !v.trim().is_empty()),
7133 opencode_zen_model: std::env::var("OPENCODE_ZEN_MODEL")
7134 .ok()
7135 .filter(|v| !v.trim().is_empty()),
7136 meta_base_url: std::env::var("META_MODEL_API_BASE_URL")
7137 .ok()
7138 .filter(|v| !v.trim().is_empty())
7139 .or_else(|| {
7140 std::env::var("MODEL_API_BASE_URL")
7141 .ok()
7142 .filter(|v| !v.trim().is_empty())
7143 }),
7144 meta_model: std::env::var("META_MODEL_API_MODEL")
7145 .ok()
7146 .filter(|v| !v.trim().is_empty())
7147 .or_else(|| {
7148 std::env::var("MODEL_API_MODEL")
7149 .ok()
7150 .filter(|v| !v.trim().is_empty())
7151 }),
7152 xai_base_url: std::env::var("XAI_BASE_URL")
7153 .ok()
7154 .filter(|v| !v.trim().is_empty()),
7155 xai_model: std::env::var("XAI_MODEL")
7156 .ok()
7157 .filter(|v| !v.trim().is_empty()),
7158 antigravity_base_url: std::env::var("ANTIGRAVITY_BASE_URL")
7159 .ok()
7160 .filter(|v| !v.trim().is_empty()),
7161 antigravity_model: std::env::var("ANTIGRAVITY_MODEL")
7162 .ok()
7163 .filter(|v| !v.trim().is_empty()),
7164 google_base_url: std::env::var("GOOGLE_BASE_URL")
7165 .ok()
7166 .filter(|v| !v.trim().is_empty())
7167 .or_else(|| {
7168 std::env::var("GEMINI_BASE_URL")
7169 .ok()
7170 .filter(|v| !v.trim().is_empty())
7171 }),
7172 google_model: std::env::var("GOOGLE_MODEL")
7173 .ok()
7174 .filter(|v| !v.trim().is_empty())
7175 .or_else(|| {
7176 std::env::var("GEMINI_MODEL")
7177 .ok()
7178 .filter(|v| !v.trim().is_empty())
7179 }),
7180 mistral_base_url: std::env::var("MISTRAL_BASE_URL")
7181 .ok()
7182 .filter(|v| !v.trim().is_empty()),
7183 mistral_model: std::env::var("MISTRAL_MODEL")
7184 .ok()
7185 .filter(|v| !v.trim().is_empty()),
7186 telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL")
7187 .ok()
7188 .filter(|v| !v.trim().is_empty()),
7189 telecomjs_model: std::env::var("TELECOMJS_MODEL")
7190 .ok()
7191 .filter(|v| !v.trim().is_empty()),
7192 edenai_base_url: std::env::var("EDENAI_BASE_URL")
7193 .ok()
7194 .filter(|v| !v.trim().is_empty()),
7195 edenai_model: std::env::var("EDENAI_MODEL")
7196 .ok()
7197 .filter(|v| !v.trim().is_empty()),
7198 modelstudio_token_plan_base_url: std::env::var("MODELSTUDIO_TOKEN_PLAN_BASE_URL")
7199 .ok()
7200 .filter(|v| !v.trim().is_empty()),
7201 modelstudio_token_plan_model: std::env::var("MODELSTUDIO_TOKEN_PLAN_MODEL")
7202 .ok()
7203 .filter(|v| !v.trim().is_empty()),
7204 modelstudio_coding_plan_base_url: std::env::var("MODELSTUDIO_CODING_PLAN_BASE_URL")
7205 .ok()
7206 .filter(|v| !v.trim().is_empty()),
7207 modelstudio_coding_plan_model: std::env::var("MODELSTUDIO_CODING_PLAN_MODEL")
7208 .ok()
7209 .filter(|v| !v.trim().is_empty()),
7210 }
7211 }
7212
7213 fn load_provider() -> (Option<ProviderKind>, Option<&'static str>) {
7214 if let Ok(value) = std::env::var("CODEWHALE_PROVIDER") {
7215 let parsed = ProviderKind::parse_config_identity(&value);
7216 return (parsed, parsed.map(|_| "CODEWHALE_PROVIDER"));
7217 }
7218
7219 if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
7220 let parsed = ProviderKind::parse_config_identity(&value);
7221 return (parsed, parsed.map(|_| "DEEPSEEK_PROVIDER"));
7222 }
7223
7224 (None, None)
7225 }
7226
7227 fn load_telemetry() -> (Option<bool>, bool) {
7230 read_telemetry_env()
7231 }
7232
7233 fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
7234 match provider {
7237 ProviderKind::Deepseek => self.deepseek_base_url.clone(),
7238 ProviderKind::DeepseekAnthropic => self.deepseek_anthropic_base_url.clone(),
7239 ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
7240 ProviderKind::Openai => self.openai_base_url.clone(),
7241 ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
7242 ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
7243 ProviderKind::Volcengine => self.volcengine_base_url.clone(),
7244 ProviderKind::Openrouter => self.openrouter_base_url.clone(),
7245 ProviderKind::Orcarouter => self.orcarouter_base_url.clone(),
7246 ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
7247 ProviderKind::Novita => self.novita_base_url.clone(),
7248 ProviderKind::Fireworks => self.fireworks_base_url.clone(),
7249 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
7250 self.siliconflow_base_url.clone()
7251 }
7252 ProviderKind::Arcee => self.arcee_base_url.clone(),
7253 ProviderKind::Moonshot => self.moonshot_base_url.clone(),
7254 ProviderKind::Sglang => self.sglang_base_url.clone(),
7255 ProviderKind::Vllm => self.vllm_base_url.clone(),
7256 ProviderKind::Ollama => self.ollama_base_url.clone(),
7257 ProviderKind::OllamaCloud => self.ollama_cloud_base_url.clone(),
7258 ProviderKind::Huggingface => self.huggingface_base_url.clone(),
7259 ProviderKind::Together => self.together_base_url.clone(),
7260 ProviderKind::Qianfan => self.qianfan_base_url.clone(),
7261 ProviderKind::OpenaiCodex => self.openai_codex_base_url.clone(),
7262 ProviderKind::Anthropic => self.anthropic_base_url.clone(),
7263 ProviderKind::Openmodel => self.openmodel_base_url.clone(),
7264 ProviderKind::Zai => self.zai_base_url.clone(),
7265 ProviderKind::Stepfun => self.stepfun_base_url.clone(),
7266 ProviderKind::Minimax => self.minimax_base_url.clone(),
7267 ProviderKind::MinimaxAnthropic => self.minimax_anthropic_base_url.clone(),
7268 ProviderKind::Deepinfra => self.deepinfra_base_url.clone(),
7269 ProviderKind::Sakana => self.sakana_base_url.clone(),
7270 ProviderKind::LongCat => self.longcat_base_url.clone(),
7271 ProviderKind::OpencodeGo => self.opencode_go_base_url.clone(),
7272 ProviderKind::OpencodeZen => self.opencode_zen_base_url.clone(),
7273 ProviderKind::Meta => self.meta_base_url.clone(),
7274 ProviderKind::Xai => self.xai_base_url.clone(),
7275 ProviderKind::Mistral => self.mistral_base_url.clone(),
7276 ProviderKind::Google => self.google_base_url.clone(),
7277 ProviderKind::Antigravity => self.antigravity_base_url.clone(),
7278 ProviderKind::Telecomjs => self.telecomjs_base_url.clone(),
7279 ProviderKind::Edenai => self.edenai_base_url.clone(),
7280 ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => {
7281 self.modelstudio_token_plan_base_url.clone()
7282 }
7283 ProviderKind::ModelstudioCodingPlan | ProviderKind::ModelstudioCodingPlanAnthropic => {
7284 self.modelstudio_coding_plan_base_url.clone()
7285 }
7286 ProviderKind::Custom => None,
7289 }
7290 }
7291
7292 fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
7293 let model = match provider {
7294 ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
7295 ProviderKind::Volcengine => self.volcengine_model.clone(),
7296 ProviderKind::Openrouter => self.openrouter_model.clone(),
7297 ProviderKind::Orcarouter => self.orcarouter_model.clone(),
7298 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
7299 self.siliconflow_model.clone()
7300 }
7301 ProviderKind::Arcee => self.arcee_model.clone(),
7302 ProviderKind::Moonshot => self.moonshot_model.clone(),
7303 ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
7304 ProviderKind::Novita => self.novita_model.clone(),
7305 ProviderKind::Fireworks => self.fireworks_model.clone(),
7306 ProviderKind::Huggingface => self.huggingface_model.clone(),
7307 ProviderKind::Together => self.together_model.clone(),
7308 ProviderKind::Qianfan => self.qianfan_model.clone(),
7309 ProviderKind::OpenaiCodex => self.openai_codex_model.clone(),
7310 ProviderKind::Anthropic => self.anthropic_model.clone(),
7311 ProviderKind::Openmodel => self.openmodel_model.clone(),
7312 ProviderKind::Zai => self.zai_model.clone(),
7313 ProviderKind::Stepfun => self.stepfun_model.clone(),
7314 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => self.minimax_model.clone(),
7315 ProviderKind::Deepinfra => self.deepinfra_model.clone(),
7316 ProviderKind::Sakana => self.sakana_model.clone(),
7317 ProviderKind::LongCat => self.longcat_model.clone(),
7318 ProviderKind::OpencodeGo => self.opencode_go_model.clone(),
7319 ProviderKind::OpencodeZen => self.opencode_zen_model.clone(),
7320 ProviderKind::Meta => self.meta_model.clone(),
7321 ProviderKind::Xai => self.xai_model.clone(),
7322 ProviderKind::Mistral => self.mistral_model.clone(),
7323 ProviderKind::Google => self.google_model.clone(),
7324 ProviderKind::Antigravity => self.antigravity_model.clone(),
7325 ProviderKind::Telecomjs => self.telecomjs_model.clone(),
7326 ProviderKind::Edenai => self.edenai_model.clone(),
7327 ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => {
7328 self.modelstudio_token_plan_model.clone()
7329 }
7330 ProviderKind::ModelstudioCodingPlan | ProviderKind::ModelstudioCodingPlanAnthropic => {
7331 self.modelstudio_coding_plan_model.clone()
7332 }
7333 ProviderKind::OllamaCloud => self.ollama_cloud_model.clone(),
7334 _ => None,
7335 }?;
7336
7337 if provider_preserves_custom_base_url_model(provider, base_url) {
7338 Some(model.trim().to_string())
7339 } else {
7340 Some(normalize_model_for_provider(provider, &model))
7341 }
7342 }
7343}
7344
7345#[cfg(test)]
7346mod tests;