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 == "http_headers" {
2871 return serialize_http_headers_for_display(&self.http_headers);
2872 }
2873
2874 if let Some(value) = self.extras.get(key) {
2875 return Some(redact_toml_value_for_display(key, value));
2876 }
2877
2878 self.get_value(key).map(|value| {
2879 if is_sensitive_config_key(key) {
2880 redact_secret(&value)
2881 } else {
2882 value
2883 }
2884 })
2885 }
2886
2887 #[must_use]
2888 pub fn stream_chunk_timeout_secs(&self) -> u64 {
2889 let raw = self
2890 .extras
2891 .get("tui")
2892 .and_then(toml::Value::as_table)
2893 .and_then(|table| table.get("stream_chunk_timeout_secs"))
2894 .and_then(toml_value_as_u64)
2895 .or_else(|| {
2896 self.extras
2897 .get("tui.stream_chunk_timeout_secs")
2898 .and_then(toml_value_as_u64)
2899 })
2900 .or_else(|| {
2901 self.extras
2902 .get("stream_chunk_timeout_secs")
2903 .and_then(toml_value_as_u64)
2904 })
2905 .unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS);
2906 if raw == 0 {
2907 DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
2908 } else {
2909 raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS)
2910 }
2911 }
2912
2913 pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
2914 if let Some((provider, field)) = parse_provider_config_key(key) {
2915 return set_provider_config_value(self, provider, field, value);
2916 }
2917 if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
2918 return self.set_custom_provider_value(provider_id, field_key, value);
2919 }
2920
2921 match key {
2922 "provider" => {
2923 if let Some(provider) = ProviderKind::parse_config_identity(value) {
2924 self.provider = provider;
2925 self.selected_provider_id = None;
2926 } else {
2927 let provider_id = value.trim();
2928 self.named_custom_provider_table(provider_id)
2929 .with_context(|| {
2930 format!(
2931 "unknown provider '{value}': expected {} or a configured custom provider",
2932 ProviderKind::names_hint()
2933 )
2934 })?;
2935 self.provider = ProviderKind::Custom;
2936 self.selected_provider_id = Some(provider_id.to_string());
2937 }
2938 }
2939 "api_key" => self.api_key = Some(value.to_string()),
2940 "base_url" => self.base_url = Some(value.to_string()),
2941 "http_headers" => self.http_headers = parse_http_headers(value)?,
2942 "default_text_model" => self.default_text_model = Some(value.to_string()),
2943 "model" => self.model = Some(value.to_string()),
2944 "auth.mode" => self.auth_mode = Some(value.to_string()),
2945 "output_mode" => self.output_mode = Some(value.to_string()),
2946 "verbosity" => self.verbosity = Some(value.to_string()),
2947 "log_level" => self.log_level = Some(value.to_string()),
2948 "telemetry" => {
2949 self.telemetry = Some(parse_bool(value)?);
2950 }
2951 "telemetry_endpoint" => self.telemetry_endpoint = Some(value.to_string()),
2955 "approval_policy" => self.approval_policy = Some(value.to_string()),
2956 "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
2957 "hook_sinks.unix_socket_path" => {
2958 self.hook_sinks
2959 .get_or_insert_with(HookSinksToml::default)
2960 .unix_socket_path = Some(PathBuf::from(value));
2961 }
2962 _ => {
2963 self.extras
2964 .insert(key.to_string(), toml::Value::String(value.to_string()));
2965 }
2966 }
2967 Ok(())
2968 }
2969
2970 pub fn unset_value(&mut self, key: &str) -> Result<()> {
2971 if let Some((provider, field)) = parse_provider_config_key(key) {
2972 unset_provider_config_value(self, provider, field);
2973 return Ok(());
2974 }
2975 if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
2976 self.unset_custom_provider_value(provider_id, field_key);
2977 return Ok(());
2978 }
2979
2980 match key {
2981 "provider" => {
2982 self.provider = ProviderKind::Deepseek;
2983 self.selected_provider_id = None;
2984 }
2985 "api_key" => self.api_key = None,
2986 "base_url" => self.base_url = None,
2987 "http_headers" => self.http_headers.clear(),
2988 "default_text_model" => self.default_text_model = None,
2989 "model" => self.model = None,
2990 "auth.mode" => self.auth_mode = None,
2991 "output_mode" => self.output_mode = None,
2992 "verbosity" => self.verbosity = None,
2993 "log_level" => self.log_level = None,
2994 "telemetry" => self.telemetry = None,
2995 "telemetry_endpoint" => self.telemetry_endpoint = None,
2996 "approval_policy" => self.approval_policy = None,
2997 "sandbox_mode" => self.sandbox_mode = None,
2998 "hook_sinks.unix_socket_path" => {
2999 if let Some(sinks) = self.hook_sinks.as_mut() {
3000 sinks.unix_socket_path = None;
3001 }
3002 }
3003 _ => {
3004 self.extras.remove(key);
3005 }
3006 }
3007 Ok(())
3008 }
3009
3010 #[must_use]
3011 pub fn list_values(&self) -> BTreeMap<String, String> {
3012 let mut out = BTreeMap::new();
3013 out.insert("provider".to_string(), self.provider_id().to_string());
3014
3015 if let Some(v) = self.api_key.as_ref() {
3016 out.insert("api_key".to_string(), redact_secret(v));
3017 }
3018 if let Some(v) = self.base_url.as_ref() {
3019 out.insert("base_url".to_string(), v.clone());
3020 }
3021 if let Some(v) = serialize_http_headers_for_display(&self.http_headers) {
3022 out.insert("http_headers".to_string(), v);
3023 }
3024 if let Some(v) = self.default_text_model.as_ref() {
3025 out.insert("default_text_model".to_string(), v.clone());
3026 }
3027 if let Some(v) = self.model.as_ref() {
3028 out.insert("model".to_string(), v.clone());
3029 }
3030 if let Some(v) = self.auth_mode.as_ref() {
3031 out.insert("auth.mode".to_string(), v.clone());
3032 }
3033 if let Some(v) = self.output_mode.as_ref() {
3034 out.insert("output_mode".to_string(), v.clone());
3035 }
3036 if let Some(v) = self.verbosity.as_ref() {
3037 out.insert("verbosity".to_string(), v.clone());
3038 }
3039 if let Some(v) = self.log_level.as_ref() {
3040 out.insert("log_level".to_string(), v.clone());
3041 }
3042 if let Some(v) = self.telemetry {
3043 out.insert("telemetry".to_string(), v.to_string());
3044 }
3045 if let Some(v) = self.telemetry_endpoint.as_ref() {
3046 out.insert("telemetry_endpoint".to_string(), v.clone());
3047 }
3048 if let Some(v) = self.approval_policy.as_ref() {
3049 out.insert("approval_policy".to_string(), v.clone());
3050 }
3051 if let Some(v) = self.sandbox_mode.as_ref() {
3052 out.insert("sandbox_mode".to_string(), v.clone());
3053 }
3054 if let Some(v) = self
3055 .hook_sinks
3056 .as_ref()
3057 .and_then(|sinks| sinks.unix_socket_path.as_ref())
3058 {
3059 out.insert(
3060 "hook_sinks.unix_socket_path".to_string(),
3061 v.display().to_string(),
3062 );
3063 }
3064
3065 for provider in provider::all_providers().iter().map(|p| p.kind()) {
3066 insert_provider_config_values(
3067 &mut out,
3068 provider,
3069 self.providers.for_provider(provider),
3070 );
3071 }
3072
3073 for (k, v) in &self.extras {
3074 out.insert(k.clone(), redact_toml_value_for_display(k, v));
3075 }
3076 out
3077 }
3078
3079 #[must_use]
3086 pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
3087 let no_keyring = Secrets::new(std::sync::Arc::new(
3088 codewhale_secrets::InMemoryKeyringStore::new(),
3089 ));
3090 self.resolve_runtime_options_with_secrets(cli, &no_keyring)
3091 }
3092
3093 #[must_use]
3097 pub fn resolve_runtime_options_with_secrets(
3098 &self,
3099 cli: &CliRuntimeOverrides,
3100 secrets: &Secrets,
3101 ) -> ResolvedRuntimeOptions {
3102 let env = EnvRuntimeOverrides::load();
3103 let (provider, provider_source) = if let Some(provider) = cli.provider {
3104 (provider, ProviderSource::Cli)
3105 } else if let Some(provider) = env.provider {
3106 (
3107 provider,
3108 ProviderSource::Env(env.provider_source.unwrap_or("CODEWHALE_PROVIDER")),
3109 )
3110 } else {
3111 (self.provider, ProviderSource::Config)
3112 };
3113
3114 let mut provider_cfg = if provider == ProviderKind::Custom
3115 && matches!(provider_source, ProviderSource::Config)
3116 {
3117 self.named_custom_provider_config()
3118 .unwrap_or_else(|| self.providers.for_provider(provider).clone())
3119 } else {
3120 self.providers.for_provider(provider).clone()
3121 };
3122 if provider == ProviderKind::SiliconflowCN {
3123 let fb = &self.providers.siliconflow;
3124 if provider_cfg.api_key.is_none() {
3125 provider_cfg.api_key = fb.api_key.clone();
3126 }
3127 if provider_cfg.base_url.is_none() {
3128 provider_cfg.base_url = fb.base_url.clone();
3129 }
3130 if provider_cfg.model.is_none() {
3131 provider_cfg.model = fb.model.clone();
3132 }
3133 }
3134 let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
3135 .then(|| self.api_key.clone())
3136 .flatten();
3137 let root_base_url = matches!(
3143 provider,
3144 ProviderKind::Deepseek | ProviderKind::XiaomiMimo | ProviderKind::OpenaiCodex
3145 )
3146 .then(|| self.base_url.clone())
3147 .flatten();
3148 let auth_mode = cli
3149 .auth_mode
3150 .clone()
3151 .or_else(|| env.auth_mode.clone())
3152 .or_else(|| provider_cfg.auth_mode.clone())
3153 .or_else(|| self.auth_mode.clone());
3154 let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
3155 let cli_base_url = cli.base_url.clone();
3156 let env_base_url = env.base_url_for(provider);
3157 let file_base_url = provider_cfg.base_url.clone().or(root_base_url);
3158 let base_url_from_file =
3159 cli_base_url.is_none() && env_base_url.is_none() && file_base_url.is_some();
3160 let configured_base_url = cli_base_url.or(env_base_url).or(file_base_url);
3161 let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo {
3162 env.xiaomi_mimo_mode
3163 .clone()
3164 .or_else(|| provider_cfg.mode.clone())
3165 } else {
3166 None
3167 };
3168 let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo {
3169 xiaomi_mimo_env_api_key_for_runtime(
3170 xiaomi_mimo_mode.as_deref(),
3171 configured_base_url.as_deref(),
3172 )
3173 } else {
3174 None
3175 };
3176 let explicit_api_key_for_endpoint = cli
3177 .api_key
3178 .as_deref()
3179 .or(from_file.as_deref().filter(|value| {
3180 classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal
3181 }))
3182 .or(xiaomi_mimo_env_api_key.as_deref());
3183 let provider_wire = provider_cfg.wire.as_deref();
3184 let base_url = if provider == ProviderKind::XiaomiMimo {
3185 resolve_xiaomi_mimo_base_url(
3186 configured_base_url,
3187 explicit_api_key_for_endpoint,
3188 xiaomi_mimo_mode.as_deref(),
3189 )
3190 } else if is_modelstudio_family(provider) {
3191 resolve_modelstudio_base_url(
3192 configured_base_url,
3193 provider,
3194 provider_cfg.mode.as_deref(),
3195 provider_wire,
3196 )
3197 } else if matches!(
3198 provider,
3199 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic
3200 ) {
3201 resolve_minimax_base_url(configured_base_url, provider, provider_wire)
3202 } else if matches!(
3203 provider,
3204 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
3205 ) {
3206 resolve_deepseek_base_url(configured_base_url, provider, provider_wire)
3207 } else {
3208 configured_base_url.unwrap_or_else(|| match provider {
3209 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
3210 ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string(),
3211 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
3212 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
3213 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
3214 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
3215 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
3216 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
3217 ProviderKind::Orcarouter => DEFAULT_ORCAROUTER_BASE_URL.to_string(),
3218 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
3219 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
3220 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
3221 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
3222 ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL.to_string(),
3223 ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL.to_string(),
3224 ProviderKind::Moonshot => {
3225 if auth_mode
3226 .as_deref()
3227 .is_some_and(auth_mode_uses_kimi_imported_token)
3228 {
3229 DEFAULT_KIMI_CODE_BASE_URL.to_string()
3230 } else {
3231 DEFAULT_MOONSHOT_BASE_URL.to_string()
3232 }
3233 }
3234 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
3235 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
3236 ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
3237 ProviderKind::OllamaCloud => DEFAULT_OLLAMA_CLOUD_BASE_URL.to_string(),
3238 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(),
3239 ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL.to_string(),
3240 ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL.to_string(),
3241 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
3242 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(),
3243 ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL.to_string(),
3244 ProviderKind::Zai => DEFAULT_ZAI_BASE_URL.to_string(),
3245 ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL.to_string(),
3246 ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL.to_string(),
3247 ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string(),
3248 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL.to_string(),
3249 ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL.to_string(),
3250 ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL.to_string(),
3251 ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL.to_string(),
3252 ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL.to_string(),
3253 ProviderKind::Meta => DEFAULT_META_BASE_URL.to_string(),
3254 ProviderKind::Xai => DEFAULT_XAI_BASE_URL.to_string(),
3255 ProviderKind::Mistral => DEFAULT_MISTRAL_BASE_URL.to_string(),
3256 ProviderKind::Google => DEFAULT_GOOGLE_BASE_URL.to_string(),
3257 ProviderKind::Antigravity => DEFAULT_ANTIGRAVITY_BASE_URL.to_string(),
3258 ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL.to_string(),
3259 ProviderKind::Edenai => DEFAULT_EDENAI_BASE_URL.to_string(),
3260 ProviderKind::ModelstudioTokenPlan
3261 | ProviderKind::ModelstudioTokenPlanAnthropic
3262 | ProviderKind::ModelstudioCodingPlan
3263 | ProviderKind::ModelstudioCodingPlanAnthropic => {
3264 DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL.to_string()
3265 }
3266 ProviderKind::Custom => provider.provider().default_base_url().to_string(),
3270 })
3271 };
3272 let legacy_ollama_cloud = provider::migrates_legacy_ollama_cloud_route(provider, &base_url);
3277 let provider = if legacy_ollama_cloud {
3278 ProviderKind::OllamaCloud
3279 } else {
3280 provider
3281 };
3282 let uses_kimi_imported_token = provider == ProviderKind::Moonshot
3290 && auth_mode
3291 .as_deref()
3292 .is_some_and(auth_mode_uses_kimi_imported_token);
3293 let auth_disabled = auth_mode_disables_api_key(auth_mode.as_deref());
3294 let custom_endpoint = provider_preserves_custom_base_url_model(provider, &base_url);
3295 let (api_key, api_key_source) = if auth_disabled {
3296 (None, None)
3297 } else if let Some(value) = cli.api_key.clone() {
3298 (Some(value), Some(RuntimeApiKeySource::Cli))
3299 } else if uses_kimi_imported_token && !custom_endpoint {
3300 (None, None)
3301 } else if (!custom_endpoint || base_url_from_file)
3302 && let Some(value) = from_file.clone().filter(|value| {
3303 classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal
3304 })
3305 {
3306 (Some(value), Some(RuntimeApiKeySource::ConfigFile))
3307 } else if !custom_endpoint
3308 && let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty())
3309 {
3310 (Some(value), Some(RuntimeApiKeySource::Env))
3311 } else if custom_endpoint {
3312 (None, None)
3313 } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
3314 match env_api_key_for_provider(provider) {
3315 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
3316 None => (None, None),
3317 }
3318 } else {
3319 match stored_api_key_for_provider(secrets, provider, legacy_ollama_cloud) {
3320 Some((value, source)) => {
3321 let source = match source {
3322 SecretSource::Keyring => RuntimeApiKeySource::Keyring,
3323 SecretSource::Env => RuntimeApiKeySource::Env,
3324 };
3325 (Some(value), Some(source))
3326 }
3327 None => match env_api_key_for_provider(provider) {
3328 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
3329 None => (None, None),
3330 },
3331 }
3332 };
3333
3334 let env_provider_model = env.model_for(provider, &base_url);
3335 let root_default_model = self
3348 .default_text_model
3349 .clone()
3350 .filter(|model| !root_default_model_is_foreign_to_provider(provider, model, &base_url));
3351 let model_source = if cli.model.is_some() {
3354 ModelSource::Cli
3355 } else if env.model.is_some() || env_provider_model.is_some() {
3356 ModelSource::Env
3357 } else if provider_cfg.model.is_some() {
3358 ModelSource::ProviderConfig
3359 } else if root_default_model.is_some() {
3360 ModelSource::RootDefaultTextModel
3361 } else if self.model.is_some() {
3362 ModelSource::RootModel
3363 } else {
3364 ModelSource::ProviderDefault
3365 };
3366 let explicit_model = model_source.is_explicit();
3367 let model = cli
3368 .model
3369 .clone()
3370 .or_else(|| env.model.clone())
3371 .or(env_provider_model)
3372 .or_else(|| provider_cfg.model.clone())
3373 .or(root_default_model)
3374 .or_else(|| self.model.clone())
3375 .unwrap_or_else(|| {
3376 if provider == ProviderKind::Moonshot
3377 && (auth_mode
3378 .as_deref()
3379 .is_some_and(auth_mode_uses_kimi_imported_token)
3380 || moonshot_base_url_uses_kimi_code(&base_url))
3381 {
3382 DEFAULT_KIMI_CODE_MODEL.to_string()
3383 } else {
3384 default_model_for_provider(provider).to_string()
3385 }
3386 });
3387 let model = if provider == ProviderKind::OpencodeGo {
3388 normalize_model_for_provider(provider, &model)
3393 } else if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
3394 model.trim().to_string()
3395 } else {
3396 normalize_model_for_provider(provider, &model)
3397 };
3398
3399 let mut http_headers = self.http_headers.clone();
3400 http_headers.extend(provider_cfg.http_headers.clone());
3401 if let Some(env_headers) = env.http_headers {
3402 http_headers.extend(env_headers);
3403 }
3404 http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
3405 if auth_disabled {
3406 http_headers.retain(|name, _| !is_upstream_auth_header(name));
3407 }
3408
3409 let output_mode = cli
3410 .output_mode
3411 .clone()
3412 .or_else(|| env.output_mode.clone())
3413 .or_else(|| self.output_mode.clone());
3414 let log_level = cli
3415 .log_level
3416 .clone()
3417 .or_else(|| env.log_level.clone())
3418 .or_else(|| self.log_level.clone());
3419 let telemetry_allowed = cli
3420 .telemetry
3421 .or(env.telemetry)
3422 .or(self.telemetry)
3423 .unwrap_or(true);
3424 let telemetry_persisted_off = self.telemetry == Some(false);
3435 let telemetry = telemetry_allowed
3441 && env.telemetry != Some(false)
3442 && !env.telemetry_env_invalid
3443 && !env.telemetry_floor
3444 && !telemetry_persisted_off;
3445 let telemetry_explicit_off = telemetry_persisted_off;
3453 let telemetry_endpoint = match env
3463 .telemetry_endpoint
3464 .clone()
3465 .or_else(|| self.telemetry_endpoint.clone())
3466 {
3467 Some(configured) if configured.trim().is_empty() => None,
3468 Some(configured) => Some(configured),
3469 None => Some(DEFAULT_TELEMETRY_ENDPOINT.to_string()),
3470 };
3471 let approval_policy = cli
3472 .approval_policy
3473 .clone()
3474 .or_else(|| env.approval_policy.clone())
3475 .or_else(|| self.approval_policy.clone());
3476 let sandbox_mode = cli
3477 .sandbox_mode
3478 .clone()
3479 .or_else(|| env.sandbox_mode.clone())
3480 .or_else(|| self.sandbox_mode.clone());
3481 let yolo = cli.yolo.or(env.yolo);
3482 let verbosity = cli
3483 .verbosity
3484 .clone()
3485 .or_else(|| env.verbosity.clone())
3486 .or_else(|| self.verbosity.clone());
3487
3488 ResolvedRuntimeOptions {
3489 provider,
3490 provider_source,
3491 model,
3492 model_source,
3493 api_key,
3494 api_key_source,
3495 base_url,
3496 auth_mode,
3497 insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false),
3498 output_mode,
3499 log_level,
3500 telemetry,
3501 telemetry_explicit_off,
3502 telemetry_endpoint,
3503 approval_policy,
3504 sandbox_mode,
3505 yolo,
3506 verbosity,
3507 http_headers,
3508 }
3509 }
3510}
3511
3512fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
3513 if source.model.is_some() {
3514 target.model = source.model.clone();
3515 }
3516}
3517
3518pub const DEFAULT_TELEMETRY_ENDPOINT: &str = "https://telemetry.codewhale.net/v1/telemetry";
3534
3535pub const TELEMETRY_FLOOR_ENV: &str = "CODEWHALE_TELEMETRY_FLOOR";
3540
3541#[must_use]
3554pub fn telemetry_floor_in_force() -> bool {
3555 if let Ok(raw) = std::env::var(TELEMETRY_FLOOR_ENV)
3556 && let Ok(declared) = parse_bool(&raw)
3557 {
3558 return declared;
3559 }
3560 let Ok(raw) =
3561 std::env::var("CODEWHALE_TELEMETRY").or_else(|_| std::env::var("DEEPSEEK_TELEMETRY"))
3562 else {
3563 return false;
3564 };
3565 !matches!(parse_bool(&raw), Ok(true))
3566}
3567
3568#[must_use]
3569pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
3570 let Some(project_rank) = approval_policy_rank(project) else {
3571 return false;
3572 };
3573 match current.and_then(approval_policy_rank) {
3574 Some(current_rank) => project_rank >= current_rank,
3575 None => project_rank >= 2,
3576 }
3577}
3578
3579#[must_use]
3580pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
3581 let normalized_project = project.trim().to_ascii_lowercase();
3582 if normalized_project == "external-sandbox" {
3583 return current
3584 .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
3585 .unwrap_or(false);
3586 }
3587
3588 let Some(project_rank) = sandbox_mode_rank(project) else {
3589 return false;
3590 };
3591 match current.and_then(sandbox_mode_rank) {
3592 Some(current_rank) => project_rank >= current_rank,
3593 None => project_rank >= 2,
3594 }
3595}
3596
3597fn approval_policy_rank(value: &str) -> Option<u8> {
3598 match value.trim().to_ascii_lowercase().as_str() {
3599 "auto" => Some(0),
3600 "suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
3601 "never" | "deny" | "denied" => Some(2),
3602 _ => None,
3603 }
3604}
3605
3606fn sandbox_mode_rank(value: &str) -> Option<u8> {
3607 match value.trim().to_ascii_lowercase().as_str() {
3608 "danger-full-access" => Some(0),
3609 "external-sandbox" => Some(0),
3610 "workspace-write" => Some(1),
3611 "read-only" => Some(2),
3612 _ => None,
3613 }
3614}
3615
3616#[derive(Debug, Clone)]
3625pub enum ProjectConfigOutcome {
3626 Missing,
3628 Loaded(Box<ConfigToml>),
3630 Invalid {
3633 path: PathBuf,
3635 reason: String,
3637 },
3638}
3639
3640impl ProjectConfigOutcome {
3641 #[must_use]
3643 pub fn into_config(self) -> Option<ConfigToml> {
3644 match self {
3645 Self::Loaded(config) => Some(*config),
3646 Self::Missing | Self::Invalid { .. } => None,
3647 }
3648 }
3649
3650 #[must_use]
3652 pub fn invalid(&self) -> Option<(&Path, &str)> {
3653 match self {
3654 Self::Invalid { path, reason } => Some((path.as_path(), reason.as_str())),
3655 Self::Missing | Self::Loaded(_) => None,
3656 }
3657 }
3658}
3659
3660pub fn load_project_config_outcome(workspace: &Path) -> ProjectConfigOutcome {
3666 for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
3667 let path = workspace.join(dir).join(CONFIG_FILE_NAME);
3668 if !project_config_candidate_exists(&path) {
3669 continue;
3670 }
3671 let raw = match read_checked_config_file(&path) {
3672 Ok(raw) => raw,
3673 Err(e) => {
3674 tracing::warn!("Failed to read project config {}: {e:#}", path.display());
3675 return ProjectConfigOutcome::Invalid {
3676 path,
3677 reason: format!("could not be read: {e}"),
3678 };
3679 }
3680 };
3681 match toml::from_str::<ConfigToml>(&raw) {
3682 Ok(config) => {
3683 let raw_provider = toml::from_str::<toml::Value>(&raw)
3684 .ok()
3685 .and_then(|document| document.get("provider").cloned())
3686 .and_then(|provider| provider.as_str().map(str::to_string));
3687 if config.provider == ProviderKind::Custom
3688 && raw_provider.as_deref() != Some(ProviderKind::Custom.as_str())
3689 {
3690 tracing::warn!(
3694 "Failed to parse project config {}; file contents were omitted",
3695 quote_os_path(&path)
3696 );
3697 return ProjectConfigOutcome::Invalid {
3698 path,
3699 reason: match raw_provider {
3700 Some(name) => format!("unknown provider '{name}'"),
3701 None => "unknown provider".to_string(),
3702 },
3703 };
3704 }
3705 return ProjectConfigOutcome::Loaded(Box::new(config));
3706 }
3707 Err(err) => {
3708 tracing::warn!(
3709 "Failed to parse project config {}; file contents were omitted",
3710 quote_os_path(&path)
3711 );
3712 return ProjectConfigOutcome::Invalid {
3713 path,
3714 reason: err.message().to_string(),
3717 };
3718 }
3719 }
3720 }
3721 ProjectConfigOutcome::Missing
3722}
3723
3724pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
3732 load_project_config_outcome(workspace).into_config()
3733}
3734
3735fn project_config_candidate_exists(path: &Path) -> bool {
3736 fs::symlink_metadata(path).is_ok_and(|metadata| {
3737 let file_type = metadata.file_type();
3738 file_type.is_file() || file_type.is_symlink()
3739 })
3740}
3741
3742fn deepseek_family_model_id(model: &str) -> Option<String> {
3749 let trimmed = model.trim();
3750 if trimmed.is_empty() {
3751 return None;
3752 }
3753 match trimmed.to_ascii_lowercase().as_str() {
3754 "pro" | "deepseek-v4pro" => return Some("deepseek-v4-pro".to_string()),
3755 "flash" | "deepseek-v4flash" => return Some("deepseek-v4-flash".to_string()),
3756 _ => {}
3757 }
3758
3759 let normalized = trimmed.to_ascii_lowercase();
3760 if !normalized.starts_with("deepseek") && !normalized.contains("/deepseek") {
3761 return None;
3762 }
3763 if trimmed
3764 .chars()
3765 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))
3766 {
3767 return Some(trimmed.to_string());
3768 }
3769 None
3770}
3771
3772fn provider_passes_model_through(provider: ProviderKind) -> bool {
3777 matches!(
3778 provider,
3779 ProviderKind::Openai
3780 | ProviderKind::Atlascloud
3781 | ProviderKind::WanjieArk
3782 | ProviderKind::Volcengine
3783 | ProviderKind::XiaomiMimo
3784 | ProviderKind::Moonshot
3785 | ProviderKind::Qianfan
3786 | ProviderKind::Openmodel
3787 | ProviderKind::Ollama
3788 | ProviderKind::OllamaCloud
3789 | ProviderKind::Huggingface
3790 | ProviderKind::Meta
3791 | ProviderKind::Xai
3792 | ProviderKind::Telecomjs
3793 | ProviderKind::Edenai
3794 | ProviderKind::ModelstudioTokenPlan
3795 | ProviderKind::ModelstudioTokenPlanAnthropic
3796 | ProviderKind::ModelstudioCodingPlan
3797 | ProviderKind::ModelstudioCodingPlanAnthropic
3798 | ProviderKind::Custom
3799 )
3800}
3801
3802fn root_default_model_is_foreign_to_provider(
3814 provider: ProviderKind,
3815 model: &str,
3816 base_url: &str,
3817) -> bool {
3818 if deepseek_family_model_id(model).is_none() {
3822 return false;
3823 }
3824 if matches!(
3826 provider,
3827 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
3828 ) {
3829 return false;
3830 }
3831 if provider_preserves_custom_base_url_model(provider, base_url) {
3834 return false;
3835 }
3836 if matches!(
3840 provider,
3841 ProviderKind::Xai | ProviderKind::Openai | ProviderKind::Moonshot
3842 ) {
3843 return true;
3844 }
3845 if provider_passes_model_through(provider) {
3848 return false;
3849 }
3850 if matches!(
3853 provider,
3854 ProviderKind::NvidiaNim
3855 | ProviderKind::Openrouter
3856 | ProviderKind::Orcarouter
3857 | ProviderKind::Novita
3858 | ProviderKind::Fireworks
3859 | ProviderKind::Siliconflow
3860 | ProviderKind::SiliconflowCN
3861 | ProviderKind::Deepinfra
3862 | ProviderKind::Together
3863 | ProviderKind::Sglang
3864 | ProviderKind::Vllm
3865 | ProviderKind::Volcengine
3866 | ProviderKind::Atlascloud
3867 | ProviderKind::OpencodeGo
3868 | ProviderKind::WanjieArk
3869 ) {
3870 return false;
3871 }
3872 true
3875}
3876
3877#[must_use]
3885pub fn known_foreign_model_owner(
3886 provider: ProviderKind,
3887 model: &str,
3888 base_url: &str,
3889) -> Option<ProviderKind> {
3890 root_default_model_is_foreign_to_provider(provider, model, base_url)
3891 .then_some(ProviderKind::Deepseek)
3892}
3893
3894fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
3895 if matches!(provider, ProviderKind::OpencodeGo) {
3896 return opencode_go_chat_model_id(model)
3901 .map(str::to_string)
3902 .unwrap_or_else(|| model.trim().to_string());
3903 }
3904 if matches!(provider, ProviderKind::XiaomiMimo)
3905 && let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
3906 {
3907 return canonical.to_string();
3908 }
3909 if matches!(
3910 provider,
3911 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic
3912 ) && let Some(canonical) = canonical_minimax_model_id(model)
3913 {
3914 return canonical.to_string();
3915 }
3916 if matches!(provider, ProviderKind::Zai)
3917 && let Some(canonical) = canonical_zai_model_id(model)
3918 {
3919 return canonical.to_string();
3920 }
3921
3922 if matches!(
3923 provider,
3924 ProviderKind::Atlascloud
3925 | ProviderKind::WanjieArk
3926 | ProviderKind::Volcengine
3927 | ProviderKind::XiaomiMimo
3928 | ProviderKind::Zai
3929 | ProviderKind::Stepfun
3930 | ProviderKind::Minimax
3931 | ProviderKind::MinimaxAnthropic
3932 | ProviderKind::Qianfan
3933 | ProviderKind::Ollama
3934 | ProviderKind::OllamaCloud
3935 | ProviderKind::Meta
3936 | ProviderKind::Xai
3937 ) {
3938 return model.to_string();
3939 }
3940
3941 let normalized = model.trim().to_ascii_lowercase();
3942 if provider == ProviderKind::Openrouter
3943 && let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
3944 {
3945 return canonical.to_string();
3946 }
3947 if provider == ProviderKind::Orcarouter
3948 && let Some(canonical) = canonical_orcarouter_recent_model_id(&normalized)
3949 {
3950 return canonical.to_string();
3951 }
3952 match (provider, normalized.as_str()) {
3953 (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
3954 DEFAULT_NVIDIA_NIM_MODEL.to_string()
3955 }
3956 (
3957 ProviderKind::NvidiaNim,
3958 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3959 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3960 ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
3961 (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
3962 DEFAULT_OPENROUTER_MODEL.to_string()
3963 }
3964 (
3965 ProviderKind::Openrouter,
3966 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3967 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3968 ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
3969 (ProviderKind::Orcarouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
3970 DEFAULT_ORCAROUTER_MODEL.to_string()
3971 }
3972 (
3973 ProviderKind::Orcarouter,
3974 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3975 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3976 ) => DEFAULT_ORCAROUTER_FLASH_MODEL.to_string(),
3977 (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
3978 DEFAULT_NOVITA_MODEL.to_string()
3979 }
3980 (
3981 ProviderKind::Novita,
3982 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3983 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3984 ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
3985 (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
3986 DEFAULT_FIREWORKS_MODEL.to_string()
3987 }
3988 (
3989 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
3990 "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
3991 ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
3992 (
3993 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
3994 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
3995 ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
3996 (
3997 ProviderKind::Arcee,
3998 "trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking",
3999 ) => DEFAULT_ARCEE_MODEL.to_string(),
4000 (ProviderKind::Arcee, "trinity-mini" | "arcee-trinity-mini") => {
4001 ARCEE_TRINITY_MINI_MODEL.to_string()
4002 }
4003 (ProviderKind::Arcee, "arcee-trinity-large-preview") => {
4004 ARCEE_TRINITY_LARGE_PREVIEW_MODEL.to_string()
4005 }
4006 (
4007 ProviderKind::Moonshot,
4008 "kimi"
4009 | "kimi-k2"
4010 | "kimi-k2.7"
4011 | "kimi-k2-7"
4012 | "kimi-k2.7-code"
4013 | "kimi-k2-7-code"
4014 | "kimi-code"
4015 | "moonshot-kimi-k2.7-code",
4016 ) => DEFAULT_MOONSHOT_MODEL.to_string(),
4017 (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
4018 MOONSHOT_KIMI_K2_6_MODEL.to_string()
4019 }
4020 (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
4021 DEFAULT_SGLANG_MODEL.to_string()
4022 }
4023 (
4024 ProviderKind::Sglang,
4025 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4026 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4027 ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
4028 (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
4029 DEFAULT_VLLM_MODEL.to_string()
4030 }
4031 (
4032 ProviderKind::Vllm,
4033 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4034 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4035 ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
4036 (ProviderKind::Huggingface, "deepseek-v4-pro" | "deepseek-v4pro") => {
4037 DEFAULT_HUGGINGFACE_MODEL.to_string()
4038 }
4039 (
4040 ProviderKind::Huggingface,
4041 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4042 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4043 ) => DEFAULT_HUGGINGFACE_FLASH_MODEL.to_string(),
4044 (ProviderKind::Together, "deepseek-v4-pro" | "deepseek-v4pro") => {
4045 DEFAULT_TOGETHER_MODEL.to_string()
4046 }
4047 (
4048 ProviderKind::Together,
4049 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4050 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4051 ) => DEFAULT_TOGETHER_FLASH_MODEL.to_string(),
4052 (ProviderKind::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
4053 DEFAULT_DEEPINFRA_MODEL.to_string()
4054 }
4055 (
4056 ProviderKind::Deepinfra,
4057 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
4058 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
4059 ) => DEFAULT_DEEPINFRA_FLASH_MODEL.to_string(),
4060 _ => model.to_string(),
4061 }
4062}
4063
4064pub const OPENCODE_GO_CHAT_MODELS: &[&str] = &[
4077 DEFAULT_OPENCODE_GO_MODEL,
4078 OPENCODE_GO_GROK_4_5_MODEL,
4079 OPENCODE_GO_GLM_5_2_MODEL,
4080 OPENCODE_GO_GLM_5_1_MODEL,
4081 OPENCODE_GO_KIMI_K3_MODEL,
4082 OPENCODE_GO_KIMI_K2_7_CODE_MODEL,
4083 OPENCODE_GO_KIMI_K2_6_MODEL,
4084 OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL,
4085 OPENCODE_GO_MIMO_V2_5_MODEL,
4086 OPENCODE_GO_MIMO_V2_5_PRO_MODEL,
4087];
4088
4089#[must_use]
4094pub fn opencode_go_chat_model_id(model: &str) -> Option<&'static str> {
4095 let normalized = model.trim().to_ascii_lowercase().replace(['_', ' '], "-");
4096 let normalized = normalized
4097 .strip_prefix("opencode-go/")
4098 .unwrap_or(&normalized);
4099 let familiar_alias = match normalized {
4100 "grok-4-5" => Some(OPENCODE_GO_GROK_4_5_MODEL),
4101 "glm-5-2" => Some(OPENCODE_GO_GLM_5_2_MODEL),
4102 "glm-5-1" => Some(OPENCODE_GO_GLM_5_1_MODEL),
4103 "kimi-k2-7-code" => Some(OPENCODE_GO_KIMI_K2_7_CODE_MODEL),
4104 "kimi-k2-6" => Some(OPENCODE_GO_KIMI_K2_6_MODEL),
4105 "deepseek-v4pro" => Some(DEFAULT_OPENCODE_GO_MODEL),
4106 "deepseek-v4flash" => Some(OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL),
4107 "mimo-v2-5" => Some(OPENCODE_GO_MIMO_V2_5_MODEL),
4108 "mimo-v2-5-pro" => Some(OPENCODE_GO_MIMO_V2_5_PRO_MODEL),
4109 _ => None,
4110 };
4111 familiar_alias.or_else(|| {
4112 OPENCODE_GO_CHAT_MODELS
4113 .iter()
4114 .copied()
4115 .find(|candidate| *candidate == normalized)
4116 })
4117}
4118
4119fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> {
4120 let normalized = model.trim().to_ascii_lowercase();
4121 let normalized = normalized.replace(['_', ' '], "-");
4122 match normalized.as_str() {
4123 "mimo"
4124 | DEFAULT_XIAOMI_MIMO_MODEL
4125 | "mimo-v2-5-pro"
4126 | "xiaomi-mimo-v2.5-pro"
4127 | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL),
4128 XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL
4129 | "mimo-v2-5-pro-ultraspeed"
4130 | "xiaomi-mimo-v2.5-pro-ultraspeed"
4131 | "xiaomi-mimo-v2-5-pro-ultraspeed"
4132 | "ultraspeed"
4133 | "pro-ultraspeed" => Some(XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL),
4134 "omni"
4135 | "mimo-omni"
4136 | "v2.5-omni"
4137 | "v25-omni"
4138 | "mimo-v2.5"
4139 | "mimo-v25"
4140 | "mimo-v2-5"
4141 | "mimo-v2.5-omni"
4142 | "mimo-v25-omni"
4143 | "mimo-v2-5-omni"
4144 | "xiaomi-mimo-v2.5"
4145 | "xiaomi-mimo-v2-5"
4146 | "xiaomi-mimo-v2.5-omni"
4147 | "xiaomi-mimo-v2-5-omni" => Some(XIAOMI_MIMO_V2_5_OMNI_MODEL),
4148 "asr" | "mimo-asr" | "mimo-v2.5-asr" | "speech-to-text" | "transcribe" => {
4149 Some(XIAOMI_MIMO_ASR_MODEL)
4150 }
4151 "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => {
4152 Some(XIAOMI_MIMO_TTS_MODEL)
4153 }
4154 "mimo-tts-voicedesign"
4155 | "mimo-voice-design"
4156 | "mimo-v25-tts-voicedesign"
4157 | "mimo-v2.5-tts-voicedesign"
4158 | "voicedesign"
4159 | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL),
4160 "mimo-tts-voiceclone"
4161 | "mimo-voice-clone"
4162 | "mimo-v25-tts-voiceclone"
4163 | "mimo-v2.5-tts-voiceclone"
4164 | "voiceclone"
4165 | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL),
4166 "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL),
4167 _ => None,
4168 }
4169}
4170
4171fn canonical_minimax_model_id(model: &str) -> Option<&'static str> {
4172 let normalized = model.trim().to_ascii_lowercase();
4173 let normalized = normalized.replace(['_', ' '], "-");
4174 match normalized.as_str() {
4175 "minimax" | "minimax-m3" | "minimax-m-3" | "minimax-m-3-thinking" => {
4176 Some(DEFAULT_MINIMAX_MODEL)
4177 }
4178 "minimax-m2.7" | "minimax-m2-7" | "minimax-m-2.7" | "minimax-m-2-7" => {
4179 Some(MINIMAX_M2_7_MODEL)
4180 }
4181 "minimax-m2.7-highspeed"
4182 | "minimax-m2-7-highspeed"
4183 | "minimax-m-2.7-highspeed"
4184 | "minimax-m-2-7-highspeed" => Some(MINIMAX_M2_7_HIGHSPEED_MODEL),
4185 "minimax-m2.5" | "minimax-m2-5" | "minimax-m-2.5" | "minimax-m-2-5" => {
4186 Some(MINIMAX_M2_5_MODEL)
4187 }
4188 "minimax-m2.5-highspeed"
4189 | "minimax-m2-5-highspeed"
4190 | "minimax-m-2.5-highspeed"
4191 | "minimax-m-2-5-highspeed" => Some(MINIMAX_M2_5_HIGHSPEED_MODEL),
4192 "minimax-m2.1" | "minimax-m2-1" | "minimax-m-2.1" | "minimax-m-2-1" => {
4193 Some(MINIMAX_M2_1_MODEL)
4194 }
4195 "minimax-m2.1-highspeed"
4196 | "minimax-m2-1-highspeed"
4197 | "minimax-m-2.1-highspeed"
4198 | "minimax-m-2-1-highspeed" => Some(MINIMAX_M2_1_HIGHSPEED_MODEL),
4199 "minimax-m2" | "minimax-m-2" => Some(MINIMAX_M2_MODEL),
4200 _ => None,
4201 }
4202}
4203
4204fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
4205 let normalized = model.trim().to_ascii_lowercase();
4206 let normalized = normalized.replace(['_', ' '], "-");
4207 match normalized.as_str() {
4208 "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
4209 "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(ZAI_GLM_5_2_MODEL),
4213 "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => Some(ZAI_GLM_5_3_MODEL),
4214 "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
4215 _ => None,
4216 }
4217}
4218
4219fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
4220 let normalized = model.trim().to_ascii_lowercase();
4221 let normalized = normalized.replace(['_', ' '], "-");
4222 match normalized.as_str() {
4223 OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
4224 | "trinity"
4225 | "trinity-large-thinking"
4226 | "arcee-trinity"
4227 | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
4228 OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
4229 Some(OPENROUTER_GEMMA_4_31B_MODEL)
4230 }
4231 OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
4232 Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
4233 }
4234 OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
4235 Some(OPENROUTER_GLM_5_1_MODEL)
4236 }
4237 OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
4238 Some(OPENROUTER_GLM_5_2_MODEL)
4239 }
4240 OPENROUTER_GLM_5_3_MODEL | "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => {
4241 Some(OPENROUTER_GLM_5_3_MODEL)
4242 }
4243 OPENROUTER_KIMI_K2_7_CODE_MODEL
4244 | "kimi"
4245 | "kimi-k2"
4246 | "kimi-k2.7"
4247 | "kimi-k2-7"
4248 | "kimi-k2.7-code"
4249 | "kimi-k2-7-code"
4250 | "kimi-code"
4251 | "moonshot-kimi-k2.7-code"
4252 | "openrouter-kimi-k2.7-code" => Some(OPENROUTER_KIMI_K2_7_CODE_MODEL),
4253 OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
4254 Some(OPENROUTER_KIMI_K2_6_MODEL)
4255 }
4256 OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => {
4257 Some(OPENROUTER_MINIMAX_M3_MODEL)
4258 }
4259 OPENROUTER_MINIMAX_M2_7_MODEL
4260 | "minimax-2.7"
4261 | "minimax-2-7"
4262 | "minimax-m2.7"
4263 | "minimax-m2-7"
4264 | "minimax-m-2.7"
4265 | "minimax-m-2-7" => Some(OPENROUTER_MINIMAX_M2_7_MODEL),
4266 OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
4267 | "nemotron-3-nano-omni"
4268 | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
4269 OPENROUTER_QWEN_3_6_35B_A3B_MODEL
4270 | "qwen3.6-35b-a3b"
4271 | "qwen-3.6-35b-a3b"
4272 | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
4273 OPENROUTER_QWEN_3_6_FLASH_MODEL | "qwen3.6-flash" | "qwen-3.6-flash" => {
4274 Some(OPENROUTER_QWEN_3_6_FLASH_MODEL)
4275 }
4276 OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL
4277 | "qwen3.6-max-preview"
4278 | "qwen-3.6-max-preview"
4279 | "qwen-max-preview" => Some(OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
4280 OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
4281 Some(OPENROUTER_QWEN_3_6_27B_MODEL)
4282 }
4283 OPENROUTER_QWEN_3_6_PLUS_MODEL | "qwen3.6-plus" | "qwen-3.6-plus" => {
4284 Some(OPENROUTER_QWEN_3_6_PLUS_MODEL)
4285 }
4286 OPENROUTER_QWEN_3_7_PLUS_MODEL | "qwen3.7-plus" | "qwen-3.7-plus" => {
4287 Some(OPENROUTER_QWEN_3_7_PLUS_MODEL)
4288 }
4289 OPENROUTER_QWEN_3_7_MAX_MODEL | "qwen3.7-max" | "qwen-3.7-max" => {
4290 Some(OPENROUTER_QWEN_3_7_MAX_MODEL)
4291 }
4292 OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
4293 Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
4294 }
4295 OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
4296 | "mimo-v2.5-pro"
4297 | "mimo-v2-5-pro"
4298 | "xiaomi-mimo-v2.5-pro"
4299 | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
4300 OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
4301 | "mimo-v2.5"
4302 | "mimo-v2-5"
4303 | "xiaomi-mimo-v2.5"
4304 | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
4305 _ => None,
4306 }
4307}
4308
4309fn canonical_orcarouter_recent_model_id(model: &str) -> Option<&'static str> {
4317 let normalized = model.trim().to_ascii_lowercase();
4318 let normalized = normalized.replace(['_', ' '], "-");
4319 match normalized.as_str() {
4320 ORCAROUTER_AUTO_MODEL | "auto" | "orcarouter-auto" | "orca-auto" => {
4321 Some(ORCAROUTER_AUTO_MODEL)
4322 }
4323 _ => None,
4324 }
4325}
4326
4327fn default_model_for_provider(provider: ProviderKind) -> &'static str {
4328 match provider {
4329 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
4330 ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_MODEL,
4331 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
4332 ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
4333 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
4334 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
4335 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
4336 ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
4337 ProviderKind::Orcarouter => DEFAULT_ORCAROUTER_MODEL,
4338 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
4339 ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
4340 ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
4341 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_MODEL,
4342 ProviderKind::Arcee => DEFAULT_ARCEE_MODEL,
4343 ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
4344 ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
4345 ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
4346 ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
4347 ProviderKind::OllamaCloud => DEFAULT_OLLAMA_CLOUD_MODEL,
4348 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
4349 ProviderKind::Together => DEFAULT_TOGETHER_MODEL,
4350 ProviderKind::Qianfan => DEFAULT_QIANFAN_MODEL,
4351 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL,
4352 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_MODEL,
4353 ProviderKind::Openmodel => DEFAULT_OPENMODEL_MODEL,
4354 ProviderKind::Zai => DEFAULT_ZAI_MODEL,
4355 ProviderKind::Stepfun => DEFAULT_STEPFUN_MODEL,
4356 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_MODEL,
4357 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
4358 ProviderKind::Sakana => DEFAULT_SAKANA_MODEL,
4359 ProviderKind::LongCat => DEFAULT_LONGCAT_MODEL,
4360 ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_MODEL,
4361 ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_MODEL,
4362 ProviderKind::Meta => DEFAULT_META_MODEL,
4363 ProviderKind::Xai => DEFAULT_XAI_MODEL,
4364 ProviderKind::Mistral => DEFAULT_MISTRAL_MODEL,
4365 ProviderKind::Google => DEFAULT_GOOGLE_MODEL,
4366 ProviderKind::Antigravity => DEFAULT_ANTIGRAVITY_MODEL,
4367 ProviderKind::Telecomjs => DEFAULT_TELECOMJS_MODEL,
4368 ProviderKind::Edenai => DEFAULT_EDENAI_MODEL,
4369 ProviderKind::ModelstudioTokenPlan
4370 | ProviderKind::ModelstudioTokenPlanAnthropic
4371 | ProviderKind::ModelstudioCodingPlan
4372 | ProviderKind::ModelstudioCodingPlanAnthropic => DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL,
4373 ProviderKind::Custom => provider.provider().default_model(),
4375 }
4376}
4377
4378fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
4379 match provider {
4380 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
4381 ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
4382 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
4383 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
4384 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
4385 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
4386 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
4387 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
4388 ProviderKind::Orcarouter => DEFAULT_ORCAROUTER_BASE_URL,
4389 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
4390 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
4391 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
4392 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
4393 ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL,
4394 ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL,
4395 ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
4396 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
4397 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
4398 ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
4399 ProviderKind::OllamaCloud => DEFAULT_OLLAMA_CLOUD_BASE_URL,
4400 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
4401 ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL,
4402 ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL,
4403 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
4404 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
4405 ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL,
4406 ProviderKind::Zai => DEFAULT_ZAI_BASE_URL,
4407 ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL,
4408 ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL,
4409 ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
4410 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
4411 ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL,
4412 ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL,
4413 ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL,
4414 ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL,
4415 ProviderKind::Meta => DEFAULT_META_BASE_URL,
4416 ProviderKind::Xai => DEFAULT_XAI_BASE_URL,
4417 ProviderKind::Mistral => DEFAULT_MISTRAL_BASE_URL,
4418 ProviderKind::Google => DEFAULT_GOOGLE_BASE_URL,
4419 ProviderKind::Antigravity => DEFAULT_ANTIGRAVITY_BASE_URL,
4420 ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL,
4421 ProviderKind::Edenai => DEFAULT_EDENAI_BASE_URL,
4422 ProviderKind::ModelstudioTokenPlan => DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
4423 ProviderKind::ModelstudioTokenPlanAnthropic => MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL,
4424 ProviderKind::ModelstudioCodingPlan => DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL,
4425 ProviderKind::ModelstudioCodingPlanAnthropic => MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL,
4426 ProviderKind::Custom => provider.provider().default_base_url(),
4428 }
4429}
4430
4431fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
4432 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
4433 normalized == DEFAULT_KIMI_CODE_BASE_URL
4434 || normalized == "https://api.kimi.com/coding"
4435 || normalized.starts_with("https://api.kimi.com/coding/")
4436}
4437
4438fn wire_prefers_anthropic(kind: ProviderKind, wire: Option<&str>) -> bool {
4440 if matches!(
4441 kind,
4442 ProviderKind::DeepseekAnthropic
4443 | ProviderKind::MinimaxAnthropic
4444 | ProviderKind::ModelstudioTokenPlanAnthropic
4445 | ProviderKind::ModelstudioCodingPlanAnthropic
4446 ) {
4447 return true;
4448 }
4449 let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else {
4450 return false;
4451 };
4452 let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-");
4453 matches!(
4454 normalized.as_str(),
4455 "anthropic"
4456 | "anthropic-messages"
4457 | "messages"
4458 | "claude"
4459 | "anthropic-compatible"
4460 | "anthropic-compat"
4461 )
4462}
4463
4464fn modelstudio_mode_is_coding_plan(kind: ProviderKind, mode: Option<&str>) -> bool {
4465 if matches!(
4466 kind,
4467 ProviderKind::ModelstudioCodingPlan | ProviderKind::ModelstudioCodingPlanAnthropic
4468 ) {
4469 return true;
4470 }
4471 let Some(raw) = mode.map(str::trim).filter(|value| !value.is_empty()) else {
4472 return false;
4473 };
4474 let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-");
4475 matches!(
4476 normalized.as_str(),
4477 "coding-plan" | "coding" | "codingplan" | "dashscope-coding" | "code"
4478 )
4479}
4480
4481fn is_modelstudio_family(kind: ProviderKind) -> bool {
4482 matches!(
4483 kind,
4484 ProviderKind::ModelstudioTokenPlan
4485 | ProviderKind::ModelstudioTokenPlanAnthropic
4486 | ProviderKind::ModelstudioCodingPlan
4487 | ProviderKind::ModelstudioCodingPlanAnthropic
4488 )
4489}
4490
4491fn resolve_modelstudio_base_url(
4492 configured: Option<String>,
4493 kind: ProviderKind,
4494 mode: Option<&str>,
4495 wire: Option<&str>,
4496) -> String {
4497 if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
4498 return url;
4499 }
4500 let coding = modelstudio_mode_is_coding_plan(kind, mode);
4501 let anthropic = wire_prefers_anthropic(kind, wire);
4502 match (coding, anthropic) {
4503 (true, true) => MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL.to_string(),
4504 (true, false) => DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL.to_string(),
4505 (false, true) => MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL.to_string(),
4506 (false, false) => DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL.to_string(),
4507 }
4508}
4509
4510fn resolve_minimax_base_url(
4511 configured: Option<String>,
4512 kind: ProviderKind,
4513 wire: Option<&str>,
4514) -> String {
4515 if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
4516 return url;
4517 }
4518 if wire_prefers_anthropic(kind, wire) {
4519 DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string()
4520 } else {
4521 DEFAULT_MINIMAX_BASE_URL.to_string()
4522 }
4523}
4524
4525fn resolve_deepseek_base_url(
4526 configured: Option<String>,
4527 kind: ProviderKind,
4528 wire: Option<&str>,
4529) -> String {
4530 if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
4531 return url;
4532 }
4533 if wire_prefers_anthropic(kind, wire) {
4534 DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string()
4535 } else {
4536 DEFAULT_DEEPSEEK_BASE_URL.to_string()
4537 }
4538}
4539
4540fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
4541 let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
4542 if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
4543 return None;
4544 }
4545 Some(match normalized.as_str() {
4546 "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => {
4547 DEFAULT_XIAOMI_MIMO_BASE_URL
4548 }
4549 "token-plan-cn"
4550 | "token-plan-china"
4551 | "token-plan-mainland"
4552 | "token-plan-mainland-china"
4553 | "cn"
4554 | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
4555 "token-plan-sgp"
4556 | "token-plan-sg"
4557 | "token-plan-singapore"
4558 | "sgp"
4559 | "sg"
4560 | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
4561 "token-plan-ams"
4562 | "token-plan-eu"
4563 | "token-plan-europe"
4564 | "token-plan-amsterdam"
4565 | "ams"
4566 | "eu"
4567 | "europe"
4568 | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
4569 _ => DEFAULT_XIAOMI_MIMO_BASE_URL,
4570 })
4571}
4572
4573fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
4574 matches!(
4575 normalized_mode,
4576 "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
4577 )
4578}
4579
4580fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
4581 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
4582 normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
4583 || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL
4584 || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
4585}
4586
4587fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option<String> {
4588 candidates.iter().find_map(|name| {
4589 std::env::var(name)
4590 .ok()
4591 .filter(|value| !value.trim().is_empty())
4592 })
4593}
4594
4595fn xiaomi_mimo_env_api_key_for_runtime(
4596 mode: Option<&str>,
4597 base_url: Option<&str>,
4598) -> Option<String> {
4599 const TOKEN_PLAN_ENV_VARS: &[&str] =
4600 &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"];
4601 const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"];
4602
4603 let normalized_mode =
4604 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
4605 let standard_selected = normalized_mode
4606 .as_deref()
4607 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint)
4608 || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go);
4609 if standard_selected {
4610 return xiaomi_mimo_env_var(STANDARD_ENV_VARS);
4611 }
4612
4613 let token_plan_selected = normalized_mode
4614 .as_deref()
4615 .and_then(xiaomi_mimo_base_url_for_mode)
4616 .is_some()
4617 || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan);
4618 if token_plan_selected {
4619 return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS);
4620 }
4621
4622 xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS))
4623}
4624
4625fn resolve_xiaomi_mimo_base_url(
4626 configured: Option<String>,
4627 api_key: Option<&str>,
4628 mode: Option<&str>,
4629) -> String {
4630 let normalized_mode =
4631 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
4632 let uses_standard_mode = normalized_mode
4633 .as_deref()
4634 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint);
4635 let mode_base_url = normalized_mode
4636 .as_deref()
4637 .and_then(xiaomi_mimo_base_url_for_mode);
4638 let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key);
4639 match configured {
4640 Some(base_url) if uses_standard_mode => base_url,
4641 Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => {
4642 mode_base_url
4643 .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL)
4644 .to_string()
4645 }
4646 Some(base_url) => base_url,
4647 None => {
4648 if let Some(base_url) = mode_base_url {
4649 base_url.to_string()
4650 } else if uses_standard_mode {
4651 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
4652 } else if uses_token_plan || api_key.is_none() {
4653 DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()
4654 } else {
4655 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
4656 }
4657 }
4658 }
4659}
4660
4661fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
4662 api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
4663}
4664
4665fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool {
4666 matches!(
4667 base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
4668 "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
4669 )
4670}
4671
4672#[must_use]
4678pub fn provider_base_url_is_official(provider: ProviderKind, base_url: &str) -> bool {
4679 let normalized = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
4680 match provider {
4681 ProviderKind::Deepseek => matches!(
4682 normalized.as_str(),
4683 "https://api.deepseek.com"
4684 | "https://api.deepseek.com/v1"
4685 | "https://api.deepseek.com/beta"
4686 ),
4687 ProviderKind::DeepseekAnthropic => matches!(
4688 normalized.as_str(),
4689 "https://api.deepseek.com/anthropic" | "https://api.deepseek.com/anthropic/v1"
4690 ),
4691 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => matches!(
4692 normalized.as_str(),
4693 "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
4694 ),
4695 ProviderKind::Moonshot => {
4696 normalized == DEFAULT_MOONSHOT_BASE_URL || moonshot_base_url_uses_kimi_code(base_url)
4697 }
4698 ProviderKind::XiaomiMimo => {
4699 xiaomi_mimo_base_url_uses_token_plan(base_url)
4700 || xiaomi_mimo_base_url_is_pay_as_you_go(base_url)
4701 }
4702 ProviderKind::Ollama => {
4703 normalized == DEFAULT_OLLAMA_BASE_URL
4704 || provider::is_exact_ollama_cloud_route(provider, base_url)
4705 }
4706 ProviderKind::OllamaCloud => provider::is_exact_ollama_cloud_route(provider, base_url),
4707 ProviderKind::Edenai => matches!(
4708 normalized.as_str(),
4709 "https://api.edenai.run/v3" | "https://api.eu.edenai.run/v3"
4710 ),
4711 ProviderKind::Custom => false,
4714 _ => {
4715 normalized
4716 == default_base_url_for_provider(provider)
4717 .trim()
4718 .trim_end_matches('/')
4719 .to_ascii_lowercase()
4720 }
4721 }
4722}
4723
4724fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
4725 !provider_base_url_is_official(provider, base_url)
4726}
4727
4728#[must_use]
4735pub fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
4736 base_url_is_custom_for_provider(provider, base_url)
4737}
4738
4739fn should_skip_secret_store_for_provider(
4740 provider: ProviderKind,
4741 base_url: &str,
4742 auth_mode: Option<&str>,
4743) -> bool {
4744 if auth_mode_disables_api_key(auth_mode) {
4745 return true;
4746 }
4747 if base_url_is_custom_for_provider(provider, base_url) {
4748 return true;
4749 }
4750 if auth_mode_requires_api_key(auth_mode) {
4751 return false;
4752 }
4753
4754 matches!(provider, ProviderKind::Sglang | ProviderKind::Vllm)
4755 || (provider == ProviderKind::Ollama
4756 && !provider::is_exact_ollama_cloud_route(provider, base_url))
4757 || base_url_uses_local_host(base_url)
4758}
4759
4760fn stored_api_key_for_provider(
4765 secrets: &Secrets,
4766 provider: ProviderKind,
4767 legacy_ollama_cloud: bool,
4768) -> Option<(String, SecretSource)> {
4769 let mut slots = vec![provider.secret_store_slot()];
4770 if provider == ProviderKind::OllamaCloud && legacy_ollama_cloud {
4771 slots.push(ProviderKind::Ollama.secret_store_slot());
4772 }
4773 slots.into_iter().find_map(|slot| {
4774 secrets
4775 .get(slot)
4776 .ok()
4777 .flatten()
4778 .filter(|value| !value.trim().is_empty())
4779 .map(|value| (value, SecretSource::Keyring))
4780 })
4781}
4782
4783fn env_api_key_for_provider(provider: ProviderKind) -> Option<String> {
4784 if provider == ProviderKind::Huggingface {
4785 return std::env::var("HUGGINGFACE_API_KEY")
4786 .ok()
4787 .filter(|value| !value.trim().is_empty())
4788 .or_else(|| {
4789 std::env::var("HF_TOKEN")
4790 .ok()
4791 .filter(|value| !value.trim().is_empty())
4792 });
4793 }
4794
4795 codewhale_secrets::env_for(provider.as_str())
4796}
4797
4798#[must_use]
4800pub fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
4801 matches!(
4802 auth_mode
4803 .map(str::trim)
4804 .filter(|value| !value.is_empty())
4805 .map(|value| value.to_ascii_lowercase()),
4806 Some(value)
4807 if matches!(
4808 value.as_str(),
4809 "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
4810 )
4811 )
4812}
4813
4814#[must_use]
4816pub fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
4817 matches!(
4818 auth_mode
4819 .map(str::trim)
4820 .filter(|value| !value.is_empty())
4821 .map(|value| value.to_ascii_lowercase()),
4822 Some(value)
4823 if matches!(
4824 value.as_str(),
4825 "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
4826 )
4827 )
4828}
4829
4830#[must_use]
4832pub fn auth_mode_uses_kimi_imported_token(auth_mode: &str) -> bool {
4833 matches!(
4834 auth_mode
4835 .trim()
4836 .to_ascii_lowercase()
4837 .replace('-', "_")
4838 .as_str(),
4839 "kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
4840 )
4841}
4842
4843fn base_url_uses_local_host(base_url: &str) -> bool {
4844 let Some(host) = base_url_host(base_url) else {
4845 return false;
4846 };
4847 let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
4848 if matches!(host.as_str(), "localhost" | "0.0.0.0") {
4849 return true;
4850 }
4851 host.parse::<std::net::IpAddr>()
4852 .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
4853}
4854
4855fn base_url_host(base_url: &str) -> Option<&str> {
4856 let without_scheme = base_url
4857 .split_once("://")
4858 .map_or(base_url, |(_, rest)| rest);
4859 let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
4860 if let Some(rest) = authority.strip_prefix('[') {
4861 return rest.split_once(']').map(|(host, _)| host);
4862 }
4863 authority.split(':').next().filter(|host| !host.is_empty())
4864}
4865
4866#[derive(Debug, Clone, Default)]
4867pub struct CliRuntimeOverrides {
4868 pub provider: Option<ProviderKind>,
4869 pub model: Option<String>,
4870 pub api_key: Option<String>,
4871 pub base_url: Option<String>,
4872 pub auth_mode: Option<String>,
4873 pub output_mode: Option<String>,
4874 pub log_level: Option<String>,
4875 pub telemetry: Option<bool>,
4876 pub approval_policy: Option<String>,
4877 pub sandbox_mode: Option<String>,
4878 pub yolo: Option<bool>,
4879 pub verbosity: Option<String>,
4880}
4881
4882#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4883pub enum RuntimeApiKeySource {
4884 Cli,
4885 ConfigFile,
4886 Keyring,
4887 Env,
4888}
4889
4890impl RuntimeApiKeySource {
4891 #[must_use]
4892 pub fn as_env_value(self) -> &'static str {
4893 match self {
4894 Self::Cli => "cli",
4895 Self::ConfigFile => "config",
4896 Self::Keyring => "keyring",
4897 Self::Env => "env",
4898 }
4899 }
4900}
4901
4902#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4903pub enum ProviderSource {
4904 Cli,
4905 Env(&'static str),
4906 Config,
4907}
4908
4909#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4917pub enum ModelSource {
4918 Cli,
4920 Env,
4922 ProviderConfig,
4924 RootDefaultTextModel,
4926 RootModel,
4928 ProviderDefault,
4930}
4931
4932impl ModelSource {
4933 #[must_use]
4935 pub fn is_explicit(self) -> bool {
4936 !matches!(self, Self::ProviderDefault)
4937 }
4938
4939 #[must_use]
4940 pub fn as_str(self) -> &'static str {
4941 match self {
4942 Self::Cli => "--model",
4943 Self::Env => "environment",
4944 Self::ProviderConfig => "config [providers.*].model",
4945 Self::RootDefaultTextModel => "config default_text_model",
4946 Self::RootModel => "config model",
4947 Self::ProviderDefault => "provider default",
4948 }
4949 }
4950}
4951
4952#[derive(Debug, Clone)]
4953pub struct ResolvedRuntimeOptions {
4954 pub provider: ProviderKind,
4955 pub provider_source: ProviderSource,
4956 pub model: String,
4957 pub model_source: ModelSource,
4958 pub api_key: Option<String>,
4959 pub api_key_source: Option<RuntimeApiKeySource>,
4960 pub base_url: String,
4961 pub auth_mode: Option<String>,
4962 pub insecure_skip_tls_verify: bool,
4963 pub output_mode: Option<String>,
4964 pub log_level: Option<String>,
4965 pub telemetry: bool,
4966 pub telemetry_explicit_off: bool,
4976 pub telemetry_endpoint: Option<String>,
4984 pub approval_policy: Option<String>,
4985 pub sandbox_mode: Option<String>,
4986 pub yolo: Option<bool>,
4987 pub verbosity: Option<String>,
4988 pub http_headers: BTreeMap<String, String>,
4989}
4990
4991#[derive(Debug, Clone)]
4992pub struct ConfigStore {
4993 path: PathBuf,
4994 pub config: ConfigToml,
4995 permissions: PermissionsToml,
4996 original_raw: Option<String>,
4999}
5000
5001impl ConfigStore {
5002 pub fn load(path: Option<PathBuf>) -> Result<Self> {
5003 let path = resolve_config_path(path)?;
5004 let (config, original_raw) = if checked_path_exists(&path)? {
5005 let raw = read_checked_config_file(&path)?;
5006 let mut parsed: ConfigToml = toml::from_str(&raw).map_err(|_| {
5007 anyhow::anyhow!(
5008 "failed to parse config at {}; file contents were omitted",
5009 quote_os_path(&path)
5010 )
5011 })?;
5012 let raw_document: toml::Value = toml::from_str(&raw).map_err(|_| {
5013 anyhow::anyhow!(
5014 "failed to parse config at {}; file contents were omitted",
5015 quote_os_path(&path)
5016 )
5017 })?;
5018 if let Some(provider_id) = raw_document.get("provider").and_then(toml::Value::as_str) {
5019 parsed
5020 .bind_persisted_provider_id(provider_id)
5021 .with_context(|| {
5022 format!("failed to parse config at {}", quote_os_path(&path))
5023 })?;
5024 }
5025 (parsed, Some(raw))
5026 } else {
5027 (ConfigToml::default(), None)
5028 };
5029 let permissions = load_sibling_permissions(&path)?;
5030
5031 Ok(Self {
5032 path,
5033 config,
5034 permissions,
5035 original_raw,
5036 })
5037 }
5038
5039 pub fn rendered_body(&self) -> Result<String> {
5045 let mut serialized =
5046 toml::to_string_pretty(&self.config).context("failed to serialize config")?;
5047 if let Some(provider_id) = self.config.named_custom_provider_id() {
5048 let mut document = serialized
5049 .parse::<toml_edit::DocumentMut>()
5050 .context("failed to edit serialized config")?;
5051 document["provider"] = toml_edit::value(provider_id);
5052 serialized = document.to_string();
5053 }
5054 if let Some(ref original_raw) = self.original_raw {
5055 merge_and_preserve_comments(&serialized, original_raw).with_context(|| {
5056 format!(
5057 "cannot safely preserve config at {}; reload it and retry instead of replacing an unmergeable snapshot",
5058 quote_os_path(&self.path)
5059 )
5060 })
5061 } else {
5062 Ok(serialized)
5063 }
5064 }
5065
5066 pub fn save(&mut self) -> Result<()> {
5067 let path = normalize_config_file_path(self.path.clone())?;
5068 let body = self.rendered_body()?;
5069 replace_config_document_if_unchanged(&path, self.original_raw.as_deref(), &body)?;
5070 self.original_raw = Some(body);
5071 Ok(())
5072 }
5073
5074 pub fn reload(&mut self) -> Result<()> {
5078 *self = Self::load(Some(self.path.clone()))?;
5079 Ok(())
5080 }
5081
5082 #[must_use]
5083 pub fn path(&self) -> &Path {
5084 &self.path
5085 }
5086
5087 #[must_use]
5088 pub fn permissions(&self) -> &PermissionsToml {
5089 &self.permissions
5090 }
5091
5092 #[must_use]
5093 pub fn permissions_path(&self) -> PathBuf {
5094 checked_permissions_path_for_config_path(&self.path)
5095 .expect("ConfigStore path is validated before construction")
5096 }
5097
5098 #[must_use]
5099 pub fn exec_policy_engine(&self) -> ExecPolicyEngine {
5100 if self.permissions.is_empty() {
5101 ExecPolicyEngine::new(Vec::new(), Vec::new())
5102 } else {
5103 ExecPolicyEngine::with_rulesets(vec![self.permissions.ruleset()])
5104 }
5105 }
5106
5107 pub fn append_ask_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
5114 self.append_permission_rules(rules, PermissionAction::Ask)
5115 }
5116
5117 pub fn append_allow_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
5124 for rule in rules {
5125 if rule.action != PermissionAction::Allow {
5126 bail!("append_allow_rules only accepts action = \"allow\"");
5127 }
5128 let Some(workspace) = rule
5129 .workspace
5130 .as_deref()
5131 .and_then(codewhale_execpolicy::normalize_workspace_scope)
5132 else {
5133 bail!("persistent allow rules must be scoped to a workspace");
5134 };
5135 if rule.command.is_some() && !rule.command_exact {
5136 bail!("persistent command allow rules must use exact matching");
5137 }
5138 if rule.command.is_none() && rule.path.is_none() {
5139 bail!("persistent allow rules must match an exact command or path");
5140 }
5141 if let Some(command) = rule.command.as_deref()
5142 && command.trim().is_empty()
5143 {
5144 bail!("persistent command allow rules must not be empty");
5145 }
5146 if let Some(path) = rule.path.as_deref()
5147 && codewhale_execpolicy::normalize_workspace_relative_path(path, &workspace)
5148 .is_none_or(|path| path.is_empty())
5149 {
5150 bail!("persistent path allow rules must stay within the workspace");
5151 }
5152 }
5153 self.append_permission_rules(rules, PermissionAction::Allow)
5154 }
5155
5156 fn append_permission_rules(
5157 &mut self,
5158 rules: &[ToolAskRule],
5159 expected_action: PermissionAction,
5160 ) -> Result<usize> {
5161 if rules.is_empty() {
5162 return Ok(0);
5163 }
5164 if rules.iter().any(|rule| rule.action != expected_action) {
5165 bail!(
5166 "permission rule action does not match requested {:?} persistence",
5167 expected_action
5168 );
5169 }
5170
5171 let path = checked_permissions_path_for_config_path(&self.path)?;
5172 let (added, persisted) = config_document::with_config_write_lock(&path, |path| {
5173 let (_, raw, mut permissions) = read_permissions_state(path)?;
5174 let mut document = parse_permissions_document(path, &raw)?;
5175
5176 if !document.contains_key("rules") {
5177 document["rules"] = toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
5178 }
5179 let rules_item = document
5180 .get_mut("rules")
5181 .expect("rules entry was inserted above");
5182
5183 let mut added = 0;
5184 for rule in rules {
5185 if permissions.rules.contains(rule) {
5186 continue;
5187 }
5188 append_permission_rule(rules_item, rule)?;
5189 permissions.rules.push(rule.clone());
5190 added += 1;
5191 }
5192 if added == 0 {
5193 return Ok((0, permissions));
5194 }
5195
5196 let body = document.to_string();
5197 let persisted = parse_generated_permissions(path, &body)?;
5198 write_permissions_atomic(path, body.as_bytes())?;
5199 Ok((added, persisted))
5200 })?;
5201 self.permissions = persisted;
5202 Ok(added)
5203 }
5204}
5205
5206fn config_backup_file_name(path: &Path) -> OsString {
5207 let mut file_name = path
5208 .file_name()
5209 .map(OsString::from)
5210 .unwrap_or_else(|| OsString::from(CONFIG_FILE_NAME));
5211 file_name.push(".bak");
5212 file_name
5213}
5214
5215fn config_sibling_path_unchecked(config_path: &Path, file_name: &OsStr) -> PathBuf {
5216 config_path
5217 .parent()
5218 .unwrap_or_else(|| Path::new("."))
5219 .join(file_name)
5220}
5221
5222fn checked_config_sibling_path(config_path: &Path, file_name: &OsStr) -> Result<PathBuf> {
5223 let config_path = normalize_config_file_path(config_path.to_path_buf())?;
5224 let parent = config_path
5225 .parent()
5226 .context("config path must include a parent directory")?;
5227 let path = parent.join(file_name);
5228 reject_path_symlink(&path)?;
5229 Ok(path)
5230}
5231
5232#[cfg(test)]
5233fn config_backup_path(path: &Path) -> PathBuf {
5234 config_sibling_path_unchecked(path, &config_backup_file_name(path))
5235}
5236
5237fn checked_config_backup_path(path: &Path) -> Result<PathBuf> {
5238 checked_config_sibling_path(path, &config_backup_file_name(path))
5239}
5240
5241pub fn scrub_plaintext_api_keys_from_config_backup(path: &Path) -> Result<()> {
5248 let backup = checked_config_backup_path(path)?;
5249 if !backup.exists() {
5250 return Ok(());
5251 }
5252
5253 let raw = read_checked_toml_file(&backup, "config backup")?;
5254 let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
5255 format!(
5256 "failed to scrub plaintext API keys from config backup {}",
5257 backup.display()
5258 )
5259 })?;
5260 if scrubbed != raw {
5261 persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
5262 format!(
5263 "failed to write credential-free config backup {}",
5264 backup.display()
5265 )
5266 })?;
5267 }
5268 Ok(())
5269}
5270
5271fn write_one_time_config_backup(path: &Path) -> Result<()> {
5272 let backup = checked_config_backup_path(path)?;
5273 if backup.exists() {
5274 return scrub_plaintext_api_keys_from_config_backup(path);
5275 }
5276
5277 let raw = read_checked_config_file(path)?;
5278 let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
5279 format!(
5280 "failed to scrub plaintext API keys while creating config backup {}",
5281 backup.display()
5282 )
5283 })?;
5284 persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
5285 format!(
5286 "failed to create credential-free config backup {} from {}",
5287 backup.display(),
5288 path.display()
5289 )
5290 })?;
5291 Ok(())
5292}
5293
5294fn config_toml_without_plaintext_api_keys(raw: &str) -> Result<String> {
5295 let mut document = raw
5296 .parse::<toml_edit::DocumentMut>()
5297 .map_err(|_| {
5298 anyhow::anyhow!(
5299 "failed to parse config TOML while removing plaintext API keys; file contents were omitted"
5300 )
5301 })?;
5302 remove_plaintext_api_keys_recursive(document.as_table_mut());
5303 Ok(document.to_string())
5304}
5305
5306fn remove_plaintext_api_keys_recursive(table: &mut dyn toml_edit::TableLike) {
5307 table.remove("api_key");
5308 for (_, item) in table.iter_mut() {
5309 if let toml_edit::Item::ArrayOfTables(tables) = item {
5310 for nested in tables.iter_mut() {
5311 remove_plaintext_api_keys_recursive(nested);
5312 }
5313 } else if let Some(nested) = item.as_table_like_mut() {
5314 remove_plaintext_api_keys_recursive(nested);
5315 }
5316 }
5317}
5318
5319pub fn merge_and_preserve_comments(serialized: &str, original_raw: &str) -> Result<String> {
5327 let original = original_raw
5328 .parse::<toml_edit::DocumentMut>()
5329 .map_err(|_| {
5330 anyhow::anyhow!(
5331 "failed to parse original config for comment merge; file contents were omitted"
5332 )
5333 })?;
5334
5335 let mut new_doc = serialized.parse::<toml_edit::DocumentMut>().map_err(|_| {
5336 anyhow::anyhow!(
5337 "failed to parse serialized config for comment merge; file contents were omitted"
5338 )
5339 })?;
5340
5341 new_doc.set_trailing(original.trailing().clone());
5344
5345 *new_doc.as_table_mut().decor_mut() = original.as_table().decor().clone();
5348
5349 merge_decor_table(new_doc.as_table_mut(), original.as_table());
5350
5351 Ok(new_doc.to_string())
5352}
5353
5354fn merge_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
5357 let keys: Vec<String> = source.iter().map(|(k, _)| k.to_owned()).collect();
5360 for key in &keys {
5361 let Some((source_key, source_item)) = source.get_key_value(key) else {
5362 continue;
5363 };
5364 let Some((mut target_key_mut, target_item)) = target.get_key_value_mut(key) else {
5365 continue;
5366 };
5367
5368 *target_key_mut.leaf_decor_mut() = source_key.leaf_decor().clone();
5370
5371 copy_item_decor(target_item, source_item);
5372
5373 if let (Some(tt), Some(st)) = (target_item.as_table_mut(), source_item.as_table()) {
5374 merge_decor_table(tt, st);
5375 }
5376
5377 if let (Some(ta), Some(sa)) = (
5378 target_item.as_array_of_tables_mut(),
5379 source_item.as_array_of_tables(),
5380 ) {
5381 for (i, source_table) in sa.iter().enumerate() {
5382 if let Some(target_table) = ta.get_mut(i) {
5383 copy_item_decor_table(target_table, source_table);
5384 merge_decor_table(target_table, source_table);
5385 }
5386 }
5387 }
5388 }
5389}
5390
5391fn copy_item_decor(target: &mut toml_edit::Item, source: &toml_edit::Item) {
5395 match (target, source) {
5396 (toml_edit::Item::Table(tt), toml_edit::Item::Table(st)) => {
5397 *tt.decor_mut() = st.decor().clone();
5398 }
5399 (toml_edit::Item::Value(tv), toml_edit::Item::Value(sv)) => {
5400 *tv.decor_mut() = sv.decor().clone();
5401 }
5402 _ => {}
5403 }
5404}
5405
5406fn copy_item_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
5407 *target.decor_mut() = source.decor().clone();
5408}
5409
5410pub fn default_secrets() -> &'static Secrets {
5415 static SECRETS: OnceLock<Secrets> = OnceLock::new();
5416 SECRETS.get_or_init(|| {
5417 #[cfg(test)]
5422 {
5423 Secrets::new(std::sync::Arc::new(
5424 codewhale_secrets::InMemoryKeyringStore::new(),
5425 ))
5426 }
5427 #[cfg(not(test))]
5428 {
5429 Secrets::auto_detect()
5430 }
5431 })
5432}
5433
5434pub use codewhale_paths::{CODEWHALE_APP_DIR, LEGACY_APP_DIR};
5442
5443pub fn codewhale_home() -> Result<PathBuf> {
5448 codewhale_paths::codewhale_home()
5449 .map_err(anyhow::Error::new)?
5450 .context("failed to resolve home directory")
5451}
5452
5453pub fn codewhale_home_is_explicit() -> bool {
5458 codewhale_paths::codewhale_home_is_explicit()
5459}
5460
5461pub fn legacy_deepseek_home() -> Result<PathBuf> {
5465 codewhale_paths::legacy_deepseek_home().context("failed to resolve home directory")
5466}
5467
5468fn ensure_safe_state_subdir(subdir: &str) -> Result<()> {
5476 if subdir.is_empty() {
5477 bail!("state subdir must not be empty");
5478 }
5479 let path = std::path::Path::new(subdir);
5480 if path.is_absolute() {
5481 bail!("state subdir must not be an absolute path: {subdir}");
5482 }
5483 if path.components().any(|c| {
5484 matches!(
5485 c,
5486 std::path::Component::RootDir | std::path::Component::Prefix(_)
5487 )
5488 }) {
5489 bail!("state subdir must not contain a root or prefix: {subdir}");
5490 }
5491 if path
5492 .components()
5493 .any(|c| matches!(c, std::path::Component::ParentDir))
5494 {
5495 bail!("state subdir must not contain parent-dir (..) components: {subdir}");
5496 }
5497 Ok(())
5498}
5499
5500pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
5507 ensure_safe_state_subdir(subdir)?;
5508 let explicit_codewhale_home = codewhale_home_is_explicit();
5509 let primary = codewhale_home()?.join(subdir);
5510 if explicit_codewhale_home || primary.exists() {
5511 return Ok(primary);
5512 }
5513 let legacy = legacy_deepseek_home()?.join(subdir);
5514 if legacy.exists() {
5515 return Ok(legacy);
5516 }
5517 Ok(primary)
5519}
5520
5521pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
5531 let (dir, migration) = ensure_state_dir_with_migration(subdir)?;
5532 if let Some(migration) = migration {
5533 eprintln!("{}", migration.user_notice());
5534 }
5535 Ok(dir)
5536}
5537
5538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5539pub enum StateMigrationKind {
5540 Relocated,
5541 Copied,
5542}
5543
5544#[derive(Debug, Clone, PartialEq, Eq)]
5545pub struct StateMigration {
5546 pub subdir: String,
5547 pub legacy_path: PathBuf,
5548 pub primary_path: PathBuf,
5549 pub kind: StateMigrationKind,
5550}
5551
5552impl StateMigration {
5553 pub fn user_notice(&self) -> String {
5554 let action = match self.kind {
5555 StateMigrationKind::Relocated => "relocated",
5556 StateMigrationKind::Copied => "copied",
5557 };
5558 let legacy_detail = match self.kind {
5559 StateMigrationKind::Relocated => {
5560 "The legacy .deepseek copy for this state path was removed by the move."
5561 }
5562 StateMigrationKind::Copied => {
5563 "The legacy .deepseek copy was left in place because a direct move failed."
5564 }
5565 };
5566
5567 format!(
5568 "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.",
5569 self.legacy_path.display(),
5570 self.primary_path.display(),
5571 )
5572 }
5573}
5574
5575pub fn ensure_state_dir_with_migration(subdir: &str) -> Result<(PathBuf, Option<StateMigration>)> {
5579 ensure_safe_state_subdir(subdir)?;
5580 let explicit_codewhale_home = codewhale_home_is_explicit();
5581 let dir = codewhale_home()?.join(subdir);
5582 let migration = if !explicit_codewhale_home {
5583 migrate_legacy_state_dir(&dir, subdir)?
5584 } else {
5585 None
5586 };
5587 std::fs::create_dir_all(&dir)
5588 .with_context(|| format!("failed to create {}/", dir.display()))?;
5589 Ok((dir, migration))
5590}
5591
5592fn migrate_legacy_state_dir(primary: &Path, subdir: &str) -> Result<Option<StateMigration>> {
5597 if primary.exists() || subdir == "." || subdir.is_empty() {
5598 return Ok(None);
5599 }
5600 let legacy = match legacy_deepseek_home() {
5601 Ok(home) => home.join(subdir),
5602 Err(_) => return Ok(None),
5603 };
5604 if !legacy.exists() {
5605 return Ok(None);
5606 }
5607 if let Some(parent) = primary.parent()
5609 && let Err(err) = std::fs::create_dir_all(parent)
5610 {
5611 tracing::warn!(
5612 target: "config::migration",
5613 "Could not create {} for state migration ({}); writing to primary anyway",
5614 parent.display(),
5615 err
5616 );
5617 }
5618 match std::fs::rename(&legacy, primary) {
5619 Ok(()) => {
5620 tracing::info!(
5621 target: "config::migration",
5622 "Migrated legacy state directory {} -> {} (relocated). The .deepseek copy was removed.",
5623 legacy.display(),
5624 primary.display()
5625 );
5626 return Ok(Some(StateMigration {
5627 subdir: subdir.to_string(),
5628 legacy_path: legacy,
5629 primary_path: primary.to_path_buf(),
5630 kind: StateMigrationKind::Relocated,
5631 }));
5632 }
5633 Err(err) => {
5634 match copy_dir_recursive(&legacy, primary) {
5639 Ok(()) => {
5640 tracing::info!(
5641 target: "config::migration",
5642 "Migrated legacy state directory {} -> {} (copied; rename failed: {err}). \
5643 The legacy .deepseek copy was left in place.",
5644 legacy.display(),
5645 primary.display()
5646 );
5647 return Ok(Some(StateMigration {
5648 subdir: subdir.to_string(),
5649 legacy_path: legacy,
5650 primary_path: primary.to_path_buf(),
5651 kind: StateMigrationKind::Copied,
5652 }));
5653 }
5654 Err(copy_err) => {
5655 tracing::warn!(
5656 target: "config::migration",
5657 "Could not migrate legacy state {} -> {} (rename: {err}; copy: {copy_err}). \
5658 New data is written to the primary path; the legacy tree remains untouched.",
5659 legacy.display(),
5660 primary.display()
5661 );
5662 }
5663 }
5664 }
5665 }
5666 Ok(None)
5667}
5668
5669fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
5672 std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
5673 for entry in
5674 std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
5675 {
5676 let entry = entry.with_context(|| format!("failed to read entry in {}", src.display()))?;
5677 let path = entry.path();
5678 let target = dst.join(entry.file_name());
5679 let file_type = entry
5680 .file_type()
5681 .with_context(|| format!("failed to read file type for {}", path.display()))?;
5682 if file_type.is_dir() {
5683 copy_dir_recursive(&path, &target)?;
5684 } else if file_type.is_file() {
5685 std::fs::copy(&path, &target).with_context(|| {
5686 format!("failed to copy {} -> {}", path.display(), target.display())
5687 })?;
5688 }
5689 }
5690 Ok(())
5691}
5692
5693pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> Result<(bool, PathBuf)> {
5700 ensure_safe_state_subdir(subdir)?;
5701 let workspace = normalize_project_workspace(workspace)?;
5702 let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
5703 if primary.exists() {
5704 return Ok((true, primary));
5705 }
5706 let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
5707 Ok((false, legacy))
5708}
5709
5710pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
5713 ensure_safe_state_subdir(subdir)?;
5714 let workspace = normalize_project_workspace(workspace)?;
5715 let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
5716 std::fs::create_dir_all(&dir)
5717 .with_context(|| format!("failed to create {}/", dir.display()))?;
5718 Ok(dir)
5719}
5720
5721pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
5722 if let Some(path) = explicit {
5723 return normalize_config_file_path(path);
5724 }
5725 if let Some(path) = codewhale_paths::config_path_override().map_err(anyhow::Error::new)? {
5726 return normalize_config_file_path(path);
5727 }
5728 default_config_path()
5729}
5730
5731#[must_use]
5751pub fn config_path_is_workspace_scoped(path: &Path) -> bool {
5752 config_path_is_workspace_scoped_with_context(
5753 path,
5754 codewhale_paths::codewhale_home_override()
5755 .ok()
5756 .flatten()
5757 .as_deref(),
5758 codewhale_paths::user_home().as_deref(),
5759 std::env::current_dir().ok().as_deref(),
5760 )
5761}
5762
5763fn config_path_is_workspace_scoped_with_context(
5766 path: &Path,
5767 explicit_codewhale_home: Option<&Path>,
5768 user_home: Option<&Path>,
5769 current_dir: Option<&Path>,
5770) -> bool {
5771 if let Some(home) = explicit_codewhale_home
5772 && same_lexical_or_canonical_path(path, &home.join(CONFIG_FILE_NAME))
5773 {
5774 return false;
5775 }
5776 let Some(parent) = path.parent() else {
5777 return false;
5778 };
5779 let parent_is_app_dir = parent
5780 .file_name()
5781 .and_then(OsStr::to_str)
5782 .is_some_and(|name| name == CODEWHALE_APP_DIR || name == LEGACY_APP_DIR);
5783 if !parent_is_app_dir {
5784 return false;
5785 }
5786 let Some(base) = parent.parent() else {
5787 return true;
5788 };
5789 if let Some(home) = user_home
5790 && same_lexical_or_canonical_path(base, home)
5791 {
5792 return false;
5793 }
5794 if path.is_relative() {
5795 return true;
5797 }
5798 if let Some(cwd) = current_dir
5800 && canonicalize_or_keep(cwd).starts_with(canonicalize_or_keep(base))
5801 {
5802 return true;
5803 }
5804 base.join(".git").exists()
5806}
5807
5808fn same_lexical_or_canonical_path(a: &Path, b: &Path) -> bool {
5811 a == b || canonicalize_or_keep(a) == canonicalize_or_keep(b)
5812}
5813
5814fn canonicalize_or_keep(path: &Path) -> PathBuf {
5815 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
5816}
5817
5818#[cfg(test)]
5819mod credential_scope_tests {
5820 use super::config_path_is_workspace_scoped_with_context;
5821 use std::path::Path;
5822
5823 #[test]
5824 fn config_inside_current_workspace_is_workspace_scoped() {
5825 let temp = tempfile::tempdir().expect("tempdir");
5826 let repo = temp.path().join("repo");
5827 let cwd = repo.join("nested/dir");
5828 for app_dir in [".codewhale", ".deepseek"] {
5829 let config = repo.join(app_dir).join("config.toml");
5830 assert!(
5831 config_path_is_workspace_scoped_with_context(
5832 &config,
5833 None,
5834 Some(Path::new("/home/user")),
5835 Some(&cwd),
5836 ),
5837 "{} should be workspace-scoped when cwd sits inside the repo",
5838 config.display()
5839 );
5840 }
5841 }
5842
5843 #[test]
5844 fn relative_app_dir_config_is_workspace_scoped() {
5845 assert!(config_path_is_workspace_scoped_with_context(
5846 Path::new(".codewhale/config.toml"),
5847 None,
5848 Some(Path::new("/home/user")),
5849 Some(Path::new("/somewhere/else")),
5850 ));
5851 }
5852
5853 #[test]
5854 fn checkout_config_outside_cwd_is_workspace_scoped_via_git_marker() {
5855 let temp = tempfile::tempdir().expect("tempdir");
5856 let repo = temp.path().join("repo");
5857 std::fs::create_dir_all(repo.join(".git")).expect("git marker");
5858 std::fs::create_dir_all(repo.join(".codewhale")).expect("app dir");
5859 assert!(config_path_is_workspace_scoped_with_context(
5860 &repo.join(".codewhale/config.toml"),
5861 None,
5862 Some(Path::new("/home/user")),
5863 Some(Path::new("/somewhere/else")),
5864 ));
5865 }
5866
5867 #[test]
5868 fn user_global_and_custom_locations_are_not_workspace_scoped() {
5869 let home = Path::new("/home/user");
5870 let elsewhere = Some(Path::new("/somewhere/else"));
5871 for global_config in [
5872 "/home/user/.codewhale/config.toml",
5873 "/home/user/.deepseek/config.toml",
5874 "/home/user/team-config.toml",
5875 "/etc/codewhale/config.toml",
5876 ] {
5877 assert!(
5878 !config_path_is_workspace_scoped_with_context(
5879 Path::new(global_config),
5880 None,
5881 Some(home),
5882 elsewhere,
5883 ),
5884 "{global_config} should stay user-global"
5885 );
5886 }
5887 let temp = tempfile::tempdir().expect("tempdir");
5891 assert!(!config_path_is_workspace_scoped_with_context(
5892 &temp.path().join(".codewhale/config.toml"),
5893 None,
5894 Some(home),
5895 elsewhere,
5896 ));
5897 }
5898
5899 #[test]
5900 fn explicit_codewhale_home_config_is_user_global_even_when_dir_is_app_named() {
5901 let temp = tempfile::tempdir().expect("tempdir");
5902 let repo = temp.path().join("repo");
5903 let explicit = repo.join(".codewhale");
5904 assert!(!config_path_is_workspace_scoped_with_context(
5907 &explicit.join("config.toml"),
5908 Some(&explicit),
5909 Some(Path::new("/home/user")),
5910 Some(&repo),
5911 ));
5912 assert!(config_path_is_workspace_scoped_with_context(
5914 &repo.join("other/.codewhale/config.toml"),
5915 Some(&explicit),
5916 Some(Path::new("/home/user")),
5917 Some(&repo.join("other")),
5918 ));
5919 }
5920}
5921
5922#[must_use]
5923pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
5924 config_sibling_path_unchecked(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
5925}
5926
5927fn checked_permissions_path_for_config_path(config_path: &Path) -> Result<PathBuf> {
5928 checked_config_sibling_path(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
5929}
5930
5931pub fn resolve_permissions_path(config_path: Option<PathBuf>) -> Result<PathBuf> {
5932 checked_permissions_path_for_config_path(&resolve_config_path(config_path)?)
5933}
5934
5935pub fn load_permissions_snapshot(config_path: Option<PathBuf>) -> Result<PermissionsSnapshot> {
5938 let path = resolve_permissions_path(config_path)?;
5939 let (file_exists, raw, permissions) = read_permissions_state(&path)?;
5940 let file_state = if !file_exists {
5941 PermissionsFileState::Missing
5942 } else if raw.is_empty() {
5943 PermissionsFileState::Empty
5944 } else {
5945 PermissionsFileState::Present
5946 };
5947 let removal_tokens = (0..permissions.rules.len())
5948 .map(|index| permission_removal_token(&path, &raw, index))
5949 .collect();
5950 Ok(PermissionsSnapshot {
5951 path,
5952 file_state,
5953 permissions,
5954 removal_tokens,
5955 })
5956}
5957
5958pub fn remove_permission_rule(
5965 config_path: Option<PathBuf>,
5966 index: usize,
5967 expected_token: &str,
5968) -> Result<ToolAskRule> {
5969 let path = resolve_permissions_path(config_path)?;
5970 config_document::with_config_write_lock(&path, |path| {
5971 let (file_exists, raw, permissions) = read_permissions_state(path)?;
5972 if !file_exists {
5973 bail!(
5974 "permissions changed after they were listed; reload {} and retry",
5975 quote_os_path(path)
5976 );
5977 }
5978 let rule = permissions.rules.get(index).cloned().with_context(|| {
5979 format!(
5980 "permission rule {} no longer exists in {}; list rules again",
5981 index + 1,
5982 quote_os_path(path)
5983 )
5984 })?;
5985 let current_token = permission_removal_token(path, &raw, index);
5986 if current_token != expected_token {
5987 bail!(
5988 "permissions changed after they were listed; reload {} and retry",
5989 quote_os_path(path)
5990 );
5991 }
5992
5993 let mut document = parse_permissions_document(path, &raw)?;
5994 let rules_item = document.get_mut("rules").with_context(|| {
5995 format!(
5996 "permissions at {} no longer contain a rules array",
5997 quote_os_path(path)
5998 )
5999 })?;
6000 let orphaned_header = remove_permission_rule_item(rules_item, index)?;
6001 if let Some(header) = orphaned_header {
6002 let trailing = format!(
6003 "{header}{}",
6004 document.trailing().as_str().unwrap_or_default()
6005 );
6006 document.set_trailing(trailing);
6007 }
6008 let body = document.to_string();
6009 let persisted = parse_generated_permissions(path, &body)?;
6010 if persisted.rules.len() + 1 != permissions.rules.len() {
6011 bail!(
6012 "refusing inconsistent permission removal at {}",
6013 quote_os_path(path)
6014 );
6015 }
6016 write_permissions_atomic(path, body.as_bytes())?;
6017 Ok(rule)
6018 })
6019}
6020
6021pub fn read_permissions_file(path: &Path) -> Result<String> {
6024 read_checked_permissions_file(path)
6025}
6026
6027fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
6028 let permissions_path = checked_permissions_path_for_config_path(config_path)?;
6029 let (_, _, permissions) = read_permissions_state(&permissions_path)?;
6030 Ok(permissions)
6031}
6032
6033fn read_permissions_state(path: &Path) -> Result<(bool, String, PermissionsToml)> {
6034 let file_exists = checked_path_exists(path)?;
6035 let raw = if file_exists {
6036 read_checked_permissions_file(path)?
6037 } else {
6038 String::new()
6039 };
6040 let permissions = if raw.trim().is_empty() {
6041 PermissionsToml::default()
6042 } else {
6043 toml::from_str(&raw).map_err(|_| {
6044 anyhow::anyhow!(
6045 "failed to parse permissions at {}; file contents were omitted",
6046 quote_os_path(path)
6047 )
6048 })?
6049 };
6050 Ok((file_exists, raw, permissions))
6051}
6052
6053fn parse_permissions_document(path: &Path, raw: &str) -> Result<toml_edit::DocumentMut> {
6054 if raw.trim().is_empty() {
6055 Ok(toml_edit::DocumentMut::new())
6056 } else {
6057 raw.parse::<toml_edit::DocumentMut>().map_err(|_| {
6058 anyhow::anyhow!(
6059 "failed to edit permissions at {}; file contents were omitted",
6060 quote_os_path(path)
6061 )
6062 })
6063 }
6064}
6065
6066fn parse_generated_permissions(path: &Path, body: &str) -> Result<PermissionsToml> {
6067 toml::from_str(body).map_err(|_| {
6068 anyhow::anyhow!(
6069 "generated invalid permissions document for {}; file contents were omitted",
6070 quote_os_path(path)
6071 )
6072 })
6073}
6074
6075fn permission_removal_token(path: &Path, raw: &str, index: usize) -> String {
6076 let mut hasher = Sha256::new();
6077 hasher.update(b"codewhale-permission-removal-v1\0");
6078 hasher.update(quote_os_path(path).as_bytes());
6079 hasher.update(b"\0");
6080 hasher.update(index.to_le_bytes());
6081 hasher.update(b"\0");
6082 hasher.update(raw.as_bytes());
6083 let digest = hasher.finalize();
6084 let mut token = String::with_capacity(24);
6085 for byte in &digest[..12] {
6086 use std::fmt::Write as _;
6087 let _ = write!(&mut token, "{byte:02x}");
6088 }
6089 token
6090}
6091
6092fn append_permission_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
6093 match item {
6094 toml_edit::Item::ArrayOfTables(rules) => {
6095 rules.push(permission_rule_table(rule));
6096 Ok(())
6097 }
6098 toml_edit::Item::Value(value) => {
6099 let Some(rules) = value.as_array_mut() else {
6100 bail!("`rules` in permissions.toml must be an array");
6101 };
6102 rules.push(toml_edit::Value::InlineTable(permission_rule_inline_table(
6103 rule,
6104 )));
6105 Ok(())
6106 }
6107 _ => bail!("`rules` in permissions.toml must be an array"),
6108 }
6109}
6110
6111fn remove_permission_rule_item(item: &mut toml_edit::Item, index: usize) -> Result<Option<String>> {
6112 match item {
6113 toml_edit::Item::ArrayOfTables(rules) => {
6114 if index >= rules.len() {
6115 bail!("permission rule index changed before removal");
6116 }
6117 let file_header = if index == 0 {
6118 rules
6119 .get(index)
6120 .and_then(|rule| rule.decor().prefix())
6121 .and_then(toml_edit::RawString::as_str)
6122 .map(str::to_owned)
6123 } else {
6124 None
6125 };
6126 rules.remove(index);
6127 if let Some(header) = file_header.as_deref()
6128 && let Some(next_rule) = rules.get_mut(0)
6129 {
6130 let next_prefix = next_rule
6131 .decor()
6132 .prefix()
6133 .and_then(toml_edit::RawString::as_str)
6134 .unwrap_or_default()
6135 .to_owned();
6136 next_rule
6137 .decor_mut()
6138 .set_prefix(format!("{header}{next_prefix}"));
6139 return Ok(None);
6140 }
6141 Ok(file_header)
6142 }
6143 toml_edit::Item::Value(value) => {
6144 let Some(rules) = value.as_array_mut() else {
6145 bail!("`rules` in permissions.toml must be an array");
6146 };
6147 if index >= rules.len() {
6148 bail!("permission rule index changed before removal");
6149 }
6150 rules.remove(index);
6151 Ok(None)
6152 }
6153 _ => bail!("`rules` in permissions.toml must be an array"),
6154 }
6155}
6156
6157fn permission_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
6158 let mut table = toml_edit::Table::new();
6159 table["tool"] = toml_edit::value(rule.tool.clone());
6160 if let Some(command) = rule.command.as_deref() {
6161 table["command"] = toml_edit::value(command);
6162 }
6163 if rule.command_exact {
6164 table["command_exact"] = toml_edit::value(true);
6165 }
6166 if let Some(path) = rule.path.as_deref() {
6167 table["path"] = toml_edit::value(path);
6168 }
6169 if let Some(workspace) = rule.workspace.as_deref() {
6170 table["workspace"] = toml_edit::value(workspace);
6171 }
6172 if rule.action != PermissionAction::Ask {
6173 table["action"] = toml_edit::value(match rule.action {
6174 PermissionAction::Allow => "allow",
6175 PermissionAction::Ask => "ask",
6176 PermissionAction::Deny => "deny",
6177 });
6178 }
6179 table
6180}
6181
6182fn permission_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
6183 let mut table = toml_edit::InlineTable::new();
6184 table.insert("tool", toml_edit::Value::from(rule.tool.clone()));
6185 if let Some(command) = rule.command.as_deref() {
6186 table.insert("command", toml_edit::Value::from(command));
6187 }
6188 if rule.command_exact {
6189 table.insert("command_exact", toml_edit::Value::from(true));
6190 }
6191 if let Some(path) = rule.path.as_deref() {
6192 table.insert("path", toml_edit::Value::from(path));
6193 }
6194 if let Some(workspace) = rule.workspace.as_deref() {
6195 table.insert("workspace", toml_edit::Value::from(workspace));
6196 }
6197 if rule.action != PermissionAction::Ask {
6198 table.insert(
6199 "action",
6200 toml_edit::Value::from(match rule.action {
6201 PermissionAction::Allow => "allow",
6202 PermissionAction::Ask => "ask",
6203 PermissionAction::Deny => "deny",
6204 }),
6205 );
6206 }
6207 table
6208}
6209
6210fn write_permissions_atomic(path: &Path, body: &[u8]) -> Result<()> {
6211 let parent = path.parent().with_context(|| {
6212 format!(
6213 "permissions path has no parent directory: {}",
6214 path.display()
6215 )
6216 })?;
6217 fs::create_dir_all(parent).with_context(|| {
6218 format!(
6219 "failed to create permissions directory {}",
6220 parent.display()
6221 )
6222 })?;
6223
6224 let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
6225 format!(
6226 "failed to create temporary permissions file in {}",
6227 parent.display()
6228 )
6229 })?;
6230 #[cfg(unix)]
6231 temporary
6232 .as_file()
6233 .set_permissions(fs::Permissions::from_mode(0o600))
6234 .with_context(|| {
6235 format!(
6236 "failed to secure temporary permissions file for {}",
6237 path.display()
6238 )
6239 })?;
6240 temporary
6241 .write_all(body)
6242 .with_context(|| format!("failed to write permissions at {}", path.display()))?;
6243 temporary
6244 .as_file()
6245 .sync_all()
6246 .with_context(|| format!("failed to sync permissions at {}", path.display()))?;
6247 temporary
6248 .persist(path)
6249 .map_err(|error| error.error)
6250 .with_context(|| format!("failed to replace permissions at {}", path.display()))?;
6251 Ok(())
6252}
6253
6254pub fn default_config_path() -> Result<PathBuf> {
6255 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
6258 if codewhale_home_is_explicit() || primary.exists() {
6259 return Ok(primary);
6260 }
6261 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
6262 if legacy.exists() {
6263 return Ok(legacy);
6264 }
6265 Ok(primary)
6267}
6268
6269#[derive(Debug, Clone, PartialEq, Eq)]
6270pub struct ConfigMigration {
6271 pub legacy_path: PathBuf,
6272 pub primary_path: PathBuf,
6273}
6274
6275impl ConfigMigration {
6276 pub fn user_notice(&self) -> String {
6277 format!(
6278 "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.",
6279 self.legacy_path.display(),
6280 self.primary_path.display()
6281 )
6282 }
6283}
6284
6285pub fn migrate_config_if_needed() -> Result<Option<ConfigMigration>> {
6290 if codewhale_home_is_explicit() {
6291 return Ok(None);
6292 }
6293 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
6294 if primary.exists() {
6295 return Ok(None);
6296 }
6297 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
6298 if !legacy.exists() {
6299 return Ok(None);
6300 }
6301 if let Some(parent) = primary.parent() {
6303 std::fs::create_dir_all(parent).context("failed to create codewhale config directory")?;
6304 }
6305 std::fs::copy(&legacy, &primary)
6306 .context("failed to migrate config from deepseek to codewhale home")?;
6307 tracing::info!(
6308 "Migrated config from {} to {}",
6309 legacy.display(),
6310 primary.display()
6311 );
6312 Ok(Some(ConfigMigration {
6313 legacy_path: legacy,
6314 primary_path: primary,
6315 }))
6316}
6317
6318fn parse_bool(raw: &str) -> Result<bool> {
6319 match raw.trim().to_ascii_lowercase().as_str() {
6320 "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
6321 "0" | "false" | "no" | "off" | "disabled" => Ok(false),
6322 _ => bail!("invalid boolean '{raw}'"),
6323 }
6324}
6325
6326fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
6327 let mut headers = BTreeMap::new();
6328 for pair in raw.trim().split(',') {
6329 let pair = pair.trim();
6330 if pair.is_empty() {
6331 continue;
6332 }
6333 let Some((name, value)) = pair.split_once('=') else {
6334 bail!("invalid header pair '{pair}', expected name=value");
6335 };
6336 let name = name.trim();
6337 let value = value.trim();
6338 if name.is_empty() {
6339 bail!("header name cannot be empty");
6340 }
6341 if value.is_empty() {
6342 continue;
6343 }
6344 headers.insert(name.to_string(), value.to_string());
6345 }
6346 Ok(headers)
6347}
6348
6349fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
6350 if headers.is_empty() {
6351 return None;
6352 }
6353 Some(
6354 headers
6355 .iter()
6356 .map(|(name, value)| format!("{name}={value}"))
6357 .collect::<Vec<_>>()
6358 .join(","),
6359 )
6360}
6361
6362fn serialize_http_headers_for_display(headers: &BTreeMap<String, String>) -> Option<String> {
6363 if headers.is_empty() {
6364 return None;
6365 }
6366 Some(
6367 headers
6368 .iter()
6369 .map(|(name, value)| {
6370 let display_value = if is_sensitive_config_key(name) {
6371 redact_secret(value)
6372 } else {
6373 value.clone()
6374 };
6375 format!("{name}={display_value}")
6376 })
6377 .collect::<Vec<_>>()
6378 .join(","),
6379 )
6380}
6381
6382fn redact_secret(secret: &str) -> String {
6383 let chars: Vec<char> = secret.chars().collect();
6384 if chars.len() <= 16 {
6385 return "********".to_string();
6386 }
6387 let prefix: String = chars.iter().take(4).collect();
6388 let suffix: String = chars
6389 .iter()
6390 .rev()
6391 .take(4)
6392 .collect::<Vec<_>>()
6393 .into_iter()
6394 .rev()
6395 .collect();
6396 format!("{prefix}***{suffix}")
6397}
6398
6399#[must_use]
6400pub fn is_sensitive_config_key(key: &str) -> bool {
6401 let Some(segment) = key.rsplit('.').next() else {
6402 return false;
6403 };
6404 let normalized = segment
6405 .trim()
6406 .trim_matches('"')
6407 .replace('-', "_")
6408 .to_ascii_lowercase();
6409
6410 matches!(
6411 normalized.as_str(),
6412 "api_key"
6413 | "apikey"
6414 | "api_keys"
6415 | "authorization"
6416 | "bearer"
6417 | "client_secret"
6418 | "credential"
6419 | "credentials"
6420 | "id_token"
6421 | "password"
6422 | "passwords"
6423 | "passwd"
6424 | "proxy_authorization"
6425 | "refresh_token"
6426 | "secret"
6427 | "secrets"
6428 | "token"
6429 | "tokens"
6430 ) || normalized.ends_with("_api_key")
6431 || normalized.ends_with("_authorization")
6432 || normalized.ends_with("_password")
6433 || normalized.ends_with("_secret")
6434 || normalized.ends_with("_token")
6435}
6436
6437fn redact_toml_value_for_display(key: &str, value: &toml::Value) -> String {
6438 redact_toml_value_for_display_inner(key, false, value).to_string()
6439}
6440
6441fn toml_value_as_u64(value: &toml::Value) -> Option<u64> {
6442 match value {
6443 toml::Value::Integer(value) => u64::try_from(*value).ok(),
6444 toml::Value::String(value) => value.trim().parse().ok(),
6445 _ => None,
6446 }
6447}
6448
6449fn redact_toml_value_for_display_inner(
6450 key: &str,
6451 sensitive_ancestor: bool,
6452 value: &toml::Value,
6453) -> toml::Value {
6454 let sensitive = sensitive_ancestor || is_sensitive_config_key(key);
6455 match value {
6456 toml::Value::String(value) if sensitive => toml::Value::String(redact_secret(value)),
6457 toml::Value::Array(values) => toml::Value::Array(
6458 values
6459 .iter()
6460 .map(|value| redact_toml_value_for_display_inner(key, sensitive, value))
6461 .collect(),
6462 ),
6463 toml::Value::Table(table) => {
6464 let mut redacted = toml::map::Map::new();
6465 for (child_key, child_value) in table {
6466 let path = if key.is_empty() {
6467 child_key.clone()
6468 } else {
6469 format!("{key}.{child_key}")
6470 };
6471 redacted.insert(
6472 child_key.clone(),
6473 redact_toml_value_for_display_inner(&path, sensitive, child_value),
6474 );
6475 }
6476 toml::Value::Table(redacted)
6477 }
6478 _ if sensitive => toml::Value::String("********".to_string()),
6479 _ => value.clone(),
6480 }
6481}
6482
6483fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
6484 if path.as_os_str().is_empty() {
6485 bail!("config path cannot be empty");
6486 }
6487 if path
6488 .components()
6489 .any(|component| matches!(component, Component::ParentDir))
6490 {
6491 bail!("config path cannot contain '..' components");
6492 }
6493 if path.file_name().is_none() {
6494 bail!("config path must include a file name");
6495 }
6496 let absolute = if path.is_absolute() {
6497 path
6498 } else {
6499 std::env::current_dir()
6500 .context("failed to resolve current directory for config path")?
6501 .join(path)
6502 };
6503 let file_name = absolute
6504 .file_name()
6505 .map(OsString::from)
6506 .context("config path must include a file name")?;
6507 let parent = absolute
6508 .parent()
6509 .context("config path must include a parent directory")?;
6510 let parent = match parent.canonicalize() {
6511 Ok(parent) => parent,
6512 Err(err) if err.kind() == std::io::ErrorKind::NotFound => parent.to_path_buf(),
6513 Err(err) => {
6514 return Err(err).with_context(|| {
6515 format!("failed to resolve config directory {}", parent.display())
6516 });
6517 }
6518 };
6519 let normalized = parent.join(file_name);
6520 reject_path_symlink(&normalized)?;
6521 Ok(normalized)
6522}
6523
6524fn normalize_project_workspace(workspace: &Path) -> Result<PathBuf> {
6525 if workspace.as_os_str().is_empty() {
6526 bail!("project workspace path cannot be empty");
6527 }
6528 if workspace
6529 .components()
6530 .any(|component| matches!(component, Component::ParentDir))
6531 {
6532 bail!("project workspace path cannot contain '..' components");
6533 }
6534 let absolute = if workspace.is_absolute() {
6535 workspace.to_path_buf()
6536 } else {
6537 std::env::current_dir()
6538 .context("failed to resolve current directory for project workspace")?
6539 .join(workspace)
6540 };
6541 match absolute.canonicalize() {
6542 Ok(path) => Ok(path),
6543 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
6544 Ok(normalize_path_components(&absolute))
6545 }
6546 Err(err) => Err(err).with_context(|| {
6547 format!(
6548 "failed to resolve project workspace {}",
6549 workspace.display()
6550 )
6551 }),
6552 }
6553}
6554
6555fn normalize_path_components(path: &Path) -> PathBuf {
6556 let mut normalized = PathBuf::new();
6557 for component in path.components() {
6558 match component {
6559 Component::Prefix(_) | Component::RootDir => normalized.push(component.as_os_str()),
6560 Component::CurDir => {}
6561 Component::ParentDir => {
6562 normalized.pop();
6563 }
6564 Component::Normal(part) => normalized.push(part),
6565 }
6566 }
6567 if normalized.as_os_str().is_empty() {
6568 PathBuf::from(".")
6569 } else {
6570 normalized
6571 }
6572}
6573
6574fn checked_path_exists(path: &Path) -> Result<bool> {
6575 let path = normalize_config_file_path(path.to_path_buf())?;
6576 path.try_exists()
6577 .with_context(|| format!("failed to inspect config path {}", path.display()))
6578}
6579
6580fn read_checked_config_file(path: &Path) -> Result<String> {
6581 read_checked_toml_file(path, "config")
6582}
6583
6584fn read_checked_permissions_file(path: &Path) -> Result<String> {
6585 read_checked_toml_file(path, "permissions")
6586}
6587
6588fn read_checked_toml_file(path: &Path, label: &str) -> Result<String> {
6589 let path = normalize_config_file_path(path.to_path_buf())?;
6590 read_string_no_follow(&path)
6591 .with_context(|| format!("failed to read {label} at {}", path.display()))
6592}
6593
6594#[cfg(unix)]
6595fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
6596 let mut file = fs::OpenOptions::new()
6597 .read(true)
6598 .custom_flags(libc::O_NOFOLLOW)
6599 .open(path)?;
6600 let mut raw = String::new();
6601 file.read_to_string(&mut raw)?;
6602 Ok(raw)
6603}
6604
6605#[cfg(not(unix))]
6606fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
6607 fs::read_to_string(path)
6608}
6609
6610fn reject_path_symlink(path: &Path) -> Result<()> {
6611 let Ok(metadata) = fs::symlink_metadata(path) else {
6612 return Ok(());
6613 };
6614 if metadata.file_type().is_symlink() {
6615 bail!("config path must not be a symlink: {}", path.display());
6616 }
6617 Ok(())
6618}
6619
6620#[derive(Debug, Clone, Default)]
6621struct EnvRuntimeOverrides {
6622 provider: Option<ProviderKind>,
6623 provider_source: Option<&'static str>,
6624 model: Option<String>,
6625 volcengine_model: Option<String>,
6626 wanjie_ark_model: Option<String>,
6627 openrouter_model: Option<String>,
6628 orcarouter_model: Option<String>,
6629 moonshot_model: Option<String>,
6630 xiaomi_mimo_model: Option<String>,
6631 xiaomi_mimo_mode: Option<String>,
6632 novita_model: Option<String>,
6633 fireworks_model: Option<String>,
6634 arcee_model: Option<String>,
6635 output_mode: Option<String>,
6636 auth_mode: Option<String>,
6637 log_level: Option<String>,
6638 telemetry: Option<bool>,
6639 telemetry_env_invalid: bool,
6644 telemetry_floor: bool,
6649 telemetry_endpoint: Option<String>,
6653 approval_policy: Option<String>,
6654 sandbox_mode: Option<String>,
6655 yolo: Option<bool>,
6656 verbosity: Option<String>,
6657 http_headers: Option<BTreeMap<String, String>>,
6658 deepseek_base_url: Option<String>,
6659 deepseek_anthropic_base_url: Option<String>,
6660 nvidia_base_url: Option<String>,
6661 openai_base_url: Option<String>,
6662 atlascloud_base_url: Option<String>,
6663 volcengine_base_url: Option<String>,
6664 wanjie_ark_base_url: Option<String>,
6665 openrouter_base_url: Option<String>,
6666 orcarouter_base_url: Option<String>,
6667 xiaomi_mimo_base_url: Option<String>,
6668 novita_base_url: Option<String>,
6669 fireworks_base_url: Option<String>,
6670 siliconflow_base_url: Option<String>,
6671 siliconflow_model: Option<String>,
6672 arcee_base_url: Option<String>,
6673 moonshot_base_url: Option<String>,
6674 sglang_base_url: Option<String>,
6675 vllm_base_url: Option<String>,
6676 ollama_base_url: Option<String>,
6677 ollama_cloud_base_url: Option<String>,
6678 ollama_cloud_model: Option<String>,
6679 huggingface_base_url: Option<String>,
6680 huggingface_model: Option<String>,
6681 together_base_url: Option<String>,
6682 together_model: Option<String>,
6683 qianfan_base_url: Option<String>,
6684 qianfan_model: Option<String>,
6685 openai_codex_base_url: Option<String>,
6686 openai_codex_model: Option<String>,
6687 anthropic_base_url: Option<String>,
6688 anthropic_model: Option<String>,
6689 openmodel_base_url: Option<String>,
6690 openmodel_model: Option<String>,
6691 zai_base_url: Option<String>,
6692 zai_model: Option<String>,
6693 stepfun_base_url: Option<String>,
6694 stepfun_model: Option<String>,
6695 minimax_base_url: Option<String>,
6696 minimax_anthropic_base_url: Option<String>,
6697 minimax_model: Option<String>,
6698 deepinfra_base_url: Option<String>,
6699 deepinfra_model: Option<String>,
6700 sakana_base_url: Option<String>,
6701 sakana_model: Option<String>,
6702 longcat_base_url: Option<String>,
6703 longcat_model: Option<String>,
6704 opencode_go_base_url: Option<String>,
6705 opencode_go_model: Option<String>,
6706 opencode_zen_base_url: Option<String>,
6707 opencode_zen_model: Option<String>,
6708 meta_base_url: Option<String>,
6709 meta_model: Option<String>,
6710 xai_base_url: Option<String>,
6711 xai_model: Option<String>,
6712 mistral_base_url: Option<String>,
6713 mistral_model: Option<String>,
6714 google_base_url: Option<String>,
6715 google_model: Option<String>,
6716 antigravity_base_url: Option<String>,
6717 antigravity_model: Option<String>,
6718 telecomjs_base_url: Option<String>,
6719 telecomjs_model: Option<String>,
6720 edenai_base_url: Option<String>,
6721 edenai_model: Option<String>,
6722 modelstudio_token_plan_base_url: Option<String>,
6723 modelstudio_token_plan_model: Option<String>,
6724 modelstudio_coding_plan_base_url: Option<String>,
6725 modelstudio_coding_plan_model: Option<String>,
6726}
6727
6728impl EnvRuntimeOverrides {
6729 fn load() -> Self {
6730 let (provider, provider_source) = Self::load_provider();
6731 let (telemetry, telemetry_env_invalid) = Self::load_telemetry();
6732 let telemetry_floor = telemetry_floor_in_force();
6733 Self {
6734 provider,
6735 provider_source,
6736 model: std::env::var("CODEWHALE_MODEL")
6737 .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
6738 .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
6739 .ok()
6740 .filter(|v| !v.trim().is_empty()),
6741 volcengine_model: std::env::var("VOLCENGINE_MODEL")
6742 .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
6743 .ok()
6744 .filter(|v| !v.trim().is_empty()),
6745 wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
6746 .or_else(|_| std::env::var("WANJIE_MODEL"))
6747 .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
6748 .ok()
6749 .filter(|v| !v.trim().is_empty()),
6750 openrouter_model: std::env::var("OPENROUTER_MODEL")
6751 .ok()
6752 .filter(|v| !v.trim().is_empty()),
6753 orcarouter_model: std::env::var("ORCAROUTER_MODEL")
6754 .ok()
6755 .filter(|v| !v.trim().is_empty()),
6756 moonshot_model: std::env::var("MOONSHOT_MODEL")
6757 .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
6758 .or_else(|_| std::env::var("KIMI_MODEL"))
6759 .ok()
6760 .filter(|v| !v.trim().is_empty()),
6761 xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
6762 .or_else(|_| std::env::var("MIMO_MODEL"))
6763 .ok()
6764 .filter(|v| !v.trim().is_empty()),
6765 xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
6766 .or_else(|_| std::env::var("MIMO_MODE"))
6767 .ok()
6768 .filter(|v| !v.trim().is_empty()),
6769 novita_model: std::env::var("NOVITA_MODEL")
6770 .ok()
6771 .filter(|v| !v.trim().is_empty()),
6772 fireworks_model: std::env::var("FIREWORKS_MODEL")
6773 .ok()
6774 .filter(|v| !v.trim().is_empty()),
6775 arcee_model: std::env::var("ARCEE_MODEL")
6776 .ok()
6777 .filter(|v| !v.trim().is_empty()),
6778 verbosity: std::env::var("CODEWHALE_VERBOSITY")
6779 .or_else(|_| std::env::var("DEEPSEEK_VERBOSITY"))
6780 .ok(),
6781 output_mode: std::env::var("CODEWHALE_OUTPUT_MODE")
6782 .or_else(|_| std::env::var("DEEPSEEK_OUTPUT_MODE"))
6783 .ok(),
6784 auth_mode: std::env::var("CODEWHALE_AUTH_MODE")
6785 .or_else(|_| std::env::var("DEEPSEEK_AUTH_MODE"))
6786 .ok(),
6787 log_level: std::env::var("CODEWHALE_LOG_LEVEL")
6788 .or_else(|_| std::env::var("DEEPSEEK_LOG_LEVEL"))
6789 .ok(),
6790 telemetry,
6791 telemetry_env_invalid,
6792 telemetry_floor,
6793 telemetry_endpoint: std::env::var("CODEWHALE_TELEMETRY_ENDPOINT")
6800 .or_else(|_| std::env::var("DEEPSEEK_TELEMETRY_ENDPOINT"))
6801 .ok(),
6802 approval_policy: std::env::var("CODEWHALE_APPROVAL_POLICY")
6803 .or_else(|_| std::env::var("DEEPSEEK_APPROVAL_POLICY"))
6804 .ok(),
6805 sandbox_mode: std::env::var("CODEWHALE_SANDBOX_MODE")
6806 .or_else(|_| std::env::var("DEEPSEEK_SANDBOX_MODE"))
6807 .ok(),
6808 yolo: std::env::var("CODEWHALE_YOLO")
6809 .or_else(|_| std::env::var("DEEPSEEK_YOLO"))
6810 .ok()
6811 .and_then(|v| match parse_bool(&v) {
6812 Ok(b) => Some(b),
6813 Err(_) => {
6814 tracing::warn!("Invalid CODEWHALE_YOLO/DEEPSEEK_YOLO value '{v}', expected true/false");
6815 None
6816 }
6817 }),
6818 http_headers: std::env::var("CODEWHALE_HTTP_HEADERS")
6819 .or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
6820 .ok()
6821 .and_then(|value| match parse_http_headers(&value) {
6822 Ok(h) => Some(h),
6823 Err(_) => {
6824 tracing::warn!("Invalid CODEWHALE_HTTP_HEADERS/DEEPSEEK_HTTP_HEADERS value, expected format: header1=val1,header2=val2");
6825 None
6826 }
6827 })
6828 .filter(|headers| !headers.is_empty()),
6829 deepseek_base_url: std::env::var("CODEWHALE_BASE_URL")
6830 .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
6831 .ok()
6832 .filter(|v| !v.trim().is_empty()),
6833 deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
6834 .or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
6835 .ok()
6836 .filter(|v| !v.trim().is_empty()),
6837 nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
6838 .or_else(|_| std::env::var("NIM_BASE_URL"))
6839 .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
6840 .ok()
6841 .filter(|v| !v.trim().is_empty()),
6842 openai_base_url: std::env::var("OPENAI_BASE_URL")
6843 .ok()
6844 .filter(|v| !v.trim().is_empty()),
6845 atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
6846 .ok()
6847 .filter(|v| !v.trim().is_empty()),
6848 volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
6849 .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
6850 .or_else(|_| std::env::var("ARK_BASE_URL"))
6851 .ok()
6852 .filter(|v| !v.trim().is_empty()),
6853 wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
6854 .or_else(|_| std::env::var("WANJIE_BASE_URL"))
6855 .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
6856 .ok()
6857 .filter(|v| !v.trim().is_empty()),
6858 openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
6859 .ok()
6860 .filter(|v| !v.trim().is_empty()),
6861 orcarouter_base_url: std::env::var("ORCAROUTER_BASE_URL")
6862 .ok()
6863 .filter(|v| !v.trim().is_empty()),
6864 xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
6865 .or_else(|_| std::env::var("MIMO_BASE_URL"))
6866 .ok()
6867 .filter(|v| !v.trim().is_empty()),
6868 novita_base_url: std::env::var("NOVITA_BASE_URL")
6869 .ok()
6870 .filter(|v| !v.trim().is_empty()),
6871 fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
6872 .ok()
6873 .filter(|v| !v.trim().is_empty()),
6874 siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
6875 .ok()
6876 .filter(|v| !v.trim().is_empty()),
6877 siliconflow_model: std::env::var("SILICONFLOW_MODEL")
6878 .ok()
6879 .filter(|v| !v.trim().is_empty()),
6880 arcee_base_url: std::env::var("ARCEE_BASE_URL")
6881 .ok()
6882 .filter(|v| !v.trim().is_empty()),
6883 moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
6884 .or_else(|_| std::env::var("KIMI_BASE_URL"))
6885 .ok()
6886 .filter(|v| !v.trim().is_empty()),
6887 sglang_base_url: std::env::var("SGLANG_BASE_URL")
6888 .ok()
6889 .filter(|v| !v.trim().is_empty()),
6890 vllm_base_url: std::env::var("VLLM_BASE_URL")
6891 .ok()
6892 .filter(|v| !v.trim().is_empty()),
6893 ollama_base_url: std::env::var("OLLAMA_BASE_URL")
6894 .ok()
6895 .filter(|v| !v.trim().is_empty()),
6896 ollama_cloud_base_url: std::env::var("OLLAMA_CLOUD_BASE_URL")
6897 .ok()
6898 .filter(|v| !v.trim().is_empty()),
6899 ollama_cloud_model: std::env::var("OLLAMA_CLOUD_MODEL")
6900 .ok()
6901 .filter(|v| !v.trim().is_empty()),
6902 huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
6903 .or_else(|_| std::env::var("HF_BASE_URL"))
6904 .ok()
6905 .filter(|v| !v.trim().is_empty()),
6906 huggingface_model: std::env::var("HUGGINGFACE_MODEL")
6907 .or_else(|_| std::env::var("HF_MODEL"))
6908 .ok()
6909 .filter(|v| !v.trim().is_empty()),
6910 together_base_url: std::env::var("TOGETHER_BASE_URL")
6911 .ok()
6912 .filter(|v| !v.trim().is_empty()),
6913 together_model: std::env::var("TOGETHER_MODEL")
6914 .ok()
6915 .filter(|v| !v.trim().is_empty()),
6916 qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
6917 .ok()
6918 .filter(|v| !v.trim().is_empty())
6919 .or_else(|| {
6920 std::env::var("BAIDU_QIANFAN_BASE_URL")
6921 .ok()
6922 .filter(|v| !v.trim().is_empty())
6923 }),
6924 qianfan_model: std::env::var("QIANFAN_MODEL")
6925 .ok()
6926 .filter(|v| !v.trim().is_empty())
6927 .or_else(|| {
6928 std::env::var("BAIDU_QIANFAN_MODEL")
6929 .ok()
6930 .filter(|v| !v.trim().is_empty())
6931 }),
6932 openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
6933 .or_else(|_| std::env::var("CODEX_BASE_URL"))
6934 .ok()
6935 .filter(|v| !v.trim().is_empty()),
6936 openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
6937 .or_else(|_| std::env::var("CODEX_MODEL"))
6938 .ok()
6939 .filter(|v| !v.trim().is_empty()),
6940 anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
6941 .ok()
6942 .filter(|v| !v.trim().is_empty()),
6943 anthropic_model: std::env::var("ANTHROPIC_MODEL")
6944 .ok()
6945 .filter(|v| !v.trim().is_empty()),
6946 openmodel_base_url: std::env::var("OPENMODEL_BASE_URL")
6947 .ok()
6948 .filter(|v| !v.trim().is_empty()),
6949 openmodel_model: std::env::var("OPENMODEL_MODEL")
6950 .ok()
6951 .filter(|v| !v.trim().is_empty()),
6952 zai_base_url: std::env::var("ZAI_BASE_URL")
6953 .or_else(|_| std::env::var("Z_AI_BASE_URL"))
6954 .or_else(|_| std::env::var("ZHIPU_BASE_URL"))
6955 .or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
6956 .or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
6957 .ok()
6958 .filter(|v| !v.trim().is_empty()),
6959 zai_model: std::env::var("ZAI_MODEL")
6960 .or_else(|_| std::env::var("Z_AI_MODEL"))
6961 .or_else(|_| std::env::var("ZHIPU_MODEL"))
6962 .or_else(|_| std::env::var("ZHIPUAI_MODEL"))
6963 .or_else(|_| std::env::var("BIGMODEL_MODEL"))
6964 .or_else(|_| std::env::var("GLM_MODEL"))
6965 .ok()
6966 .filter(|v| !v.trim().is_empty()),
6967 stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
6968 .or_else(|_| std::env::var("STEP_BASE_URL"))
6969 .ok()
6970 .filter(|v| !v.trim().is_empty()),
6971 stepfun_model: std::env::var("STEPFUN_MODEL")
6972 .or_else(|_| std::env::var("STEP_MODEL"))
6973 .ok()
6974 .filter(|v| !v.trim().is_empty()),
6975 minimax_base_url: std::env::var("MINIMAX_BASE_URL")
6976 .ok()
6977 .filter(|v| !v.trim().is_empty()),
6978 minimax_anthropic_base_url: std::env::var("MINIMAX_ANTHROPIC_BASE_URL")
6979 .ok()
6980 .filter(|v| !v.trim().is_empty()),
6981 minimax_model: std::env::var("MINIMAX_MODEL")
6982 .ok()
6983 .filter(|v| !v.trim().is_empty()),
6984 deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
6985 .ok()
6986 .filter(|v| !v.trim().is_empty()),
6987 deepinfra_model: std::env::var("DEEPINFRA_MODEL")
6988 .ok()
6989 .filter(|v| !v.trim().is_empty()),
6990 sakana_base_url: std::env::var("SAKANA_BASE_URL")
6991 .ok()
6992 .filter(|v| !v.trim().is_empty()),
6993 sakana_model: std::env::var("SAKANA_MODEL")
6994 .ok()
6995 .filter(|v| !v.trim().is_empty()),
6996 longcat_base_url: std::env::var("LONGCAT_BASE_URL")
6997 .ok()
6998 .filter(|v| !v.trim().is_empty()),
6999 longcat_model: std::env::var("LONGCAT_MODEL")
7000 .ok()
7001 .filter(|v| !v.trim().is_empty()),
7002 opencode_go_base_url: std::env::var("OPENCODE_GO_BASE_URL")
7003 .ok()
7004 .filter(|v| !v.trim().is_empty()),
7005 opencode_go_model: std::env::var("OPENCODE_GO_MODEL")
7006 .ok()
7007 .filter(|v| !v.trim().is_empty()),
7008 opencode_zen_base_url: std::env::var("OPENCODE_ZEN_BASE_URL")
7009 .ok()
7010 .filter(|v| !v.trim().is_empty()),
7011 opencode_zen_model: std::env::var("OPENCODE_ZEN_MODEL")
7012 .ok()
7013 .filter(|v| !v.trim().is_empty()),
7014 meta_base_url: std::env::var("META_MODEL_API_BASE_URL")
7015 .ok()
7016 .filter(|v| !v.trim().is_empty())
7017 .or_else(|| {
7018 std::env::var("MODEL_API_BASE_URL")
7019 .ok()
7020 .filter(|v| !v.trim().is_empty())
7021 }),
7022 meta_model: std::env::var("META_MODEL_API_MODEL")
7023 .ok()
7024 .filter(|v| !v.trim().is_empty())
7025 .or_else(|| {
7026 std::env::var("MODEL_API_MODEL")
7027 .ok()
7028 .filter(|v| !v.trim().is_empty())
7029 }),
7030 xai_base_url: std::env::var("XAI_BASE_URL")
7031 .ok()
7032 .filter(|v| !v.trim().is_empty()),
7033 xai_model: std::env::var("XAI_MODEL")
7034 .ok()
7035 .filter(|v| !v.trim().is_empty()),
7036 antigravity_base_url: std::env::var("ANTIGRAVITY_BASE_URL")
7037 .ok()
7038 .filter(|v| !v.trim().is_empty()),
7039 antigravity_model: std::env::var("ANTIGRAVITY_MODEL")
7040 .ok()
7041 .filter(|v| !v.trim().is_empty()),
7042 google_base_url: std::env::var("GOOGLE_BASE_URL")
7043 .ok()
7044 .filter(|v| !v.trim().is_empty())
7045 .or_else(|| {
7046 std::env::var("GEMINI_BASE_URL")
7047 .ok()
7048 .filter(|v| !v.trim().is_empty())
7049 }),
7050 google_model: std::env::var("GOOGLE_MODEL")
7051 .ok()
7052 .filter(|v| !v.trim().is_empty())
7053 .or_else(|| {
7054 std::env::var("GEMINI_MODEL")
7055 .ok()
7056 .filter(|v| !v.trim().is_empty())
7057 }),
7058 mistral_base_url: std::env::var("MISTRAL_BASE_URL")
7059 .ok()
7060 .filter(|v| !v.trim().is_empty()),
7061 mistral_model: std::env::var("MISTRAL_MODEL")
7062 .ok()
7063 .filter(|v| !v.trim().is_empty()),
7064 telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL")
7065 .ok()
7066 .filter(|v| !v.trim().is_empty()),
7067 telecomjs_model: std::env::var("TELECOMJS_MODEL")
7068 .ok()
7069 .filter(|v| !v.trim().is_empty()),
7070 edenai_base_url: std::env::var("EDENAI_BASE_URL")
7071 .ok()
7072 .filter(|v| !v.trim().is_empty()),
7073 edenai_model: std::env::var("EDENAI_MODEL")
7074 .ok()
7075 .filter(|v| !v.trim().is_empty()),
7076 modelstudio_token_plan_base_url: std::env::var("MODELSTUDIO_TOKEN_PLAN_BASE_URL")
7077 .ok()
7078 .filter(|v| !v.trim().is_empty()),
7079 modelstudio_token_plan_model: std::env::var("MODELSTUDIO_TOKEN_PLAN_MODEL")
7080 .ok()
7081 .filter(|v| !v.trim().is_empty()),
7082 modelstudio_coding_plan_base_url: std::env::var("MODELSTUDIO_CODING_PLAN_BASE_URL")
7083 .ok()
7084 .filter(|v| !v.trim().is_empty()),
7085 modelstudio_coding_plan_model: std::env::var("MODELSTUDIO_CODING_PLAN_MODEL")
7086 .ok()
7087 .filter(|v| !v.trim().is_empty()),
7088 }
7089 }
7090
7091 fn load_provider() -> (Option<ProviderKind>, Option<&'static str>) {
7092 if let Ok(value) = std::env::var("CODEWHALE_PROVIDER") {
7093 let parsed = ProviderKind::parse_config_identity(&value);
7094 return (parsed, parsed.map(|_| "CODEWHALE_PROVIDER"));
7095 }
7096
7097 if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
7098 let parsed = ProviderKind::parse_config_identity(&value);
7099 return (parsed, parsed.map(|_| "DEEPSEEK_PROVIDER"));
7100 }
7101
7102 (None, None)
7103 }
7104
7105 fn load_telemetry() -> (Option<bool>, bool) {
7112 let Some(raw) = std::env::var("CODEWHALE_TELEMETRY")
7113 .or_else(|_| std::env::var("DEEPSEEK_TELEMETRY"))
7114 .ok()
7115 else {
7116 return (None, false);
7117 };
7118 match parse_bool(&raw) {
7119 Ok(value) => (Some(value), false),
7120 Err(_) => {
7121 tracing::warn!(
7122 "Invalid CODEWHALE_TELEMETRY/DEEPSEEK_TELEMETRY value '{raw}'; expected one of \
7123 1/0, true/false, yes/no, on/off, enabled/disabled. Telemetry is forced off."
7124 );
7125 (None, true)
7126 }
7127 }
7128 }
7129
7130 fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
7131 match provider {
7134 ProviderKind::Deepseek => self.deepseek_base_url.clone(),
7135 ProviderKind::DeepseekAnthropic => self.deepseek_anthropic_base_url.clone(),
7136 ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
7137 ProviderKind::Openai => self.openai_base_url.clone(),
7138 ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
7139 ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
7140 ProviderKind::Volcengine => self.volcengine_base_url.clone(),
7141 ProviderKind::Openrouter => self.openrouter_base_url.clone(),
7142 ProviderKind::Orcarouter => self.orcarouter_base_url.clone(),
7143 ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
7144 ProviderKind::Novita => self.novita_base_url.clone(),
7145 ProviderKind::Fireworks => self.fireworks_base_url.clone(),
7146 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
7147 self.siliconflow_base_url.clone()
7148 }
7149 ProviderKind::Arcee => self.arcee_base_url.clone(),
7150 ProviderKind::Moonshot => self.moonshot_base_url.clone(),
7151 ProviderKind::Sglang => self.sglang_base_url.clone(),
7152 ProviderKind::Vllm => self.vllm_base_url.clone(),
7153 ProviderKind::Ollama => self.ollama_base_url.clone(),
7154 ProviderKind::OllamaCloud => self.ollama_cloud_base_url.clone(),
7155 ProviderKind::Huggingface => self.huggingface_base_url.clone(),
7156 ProviderKind::Together => self.together_base_url.clone(),
7157 ProviderKind::Qianfan => self.qianfan_base_url.clone(),
7158 ProviderKind::OpenaiCodex => self.openai_codex_base_url.clone(),
7159 ProviderKind::Anthropic => self.anthropic_base_url.clone(),
7160 ProviderKind::Openmodel => self.openmodel_base_url.clone(),
7161 ProviderKind::Zai => self.zai_base_url.clone(),
7162 ProviderKind::Stepfun => self.stepfun_base_url.clone(),
7163 ProviderKind::Minimax => self.minimax_base_url.clone(),
7164 ProviderKind::MinimaxAnthropic => self.minimax_anthropic_base_url.clone(),
7165 ProviderKind::Deepinfra => self.deepinfra_base_url.clone(),
7166 ProviderKind::Sakana => self.sakana_base_url.clone(),
7167 ProviderKind::LongCat => self.longcat_base_url.clone(),
7168 ProviderKind::OpencodeGo => self.opencode_go_base_url.clone(),
7169 ProviderKind::OpencodeZen => self.opencode_zen_base_url.clone(),
7170 ProviderKind::Meta => self.meta_base_url.clone(),
7171 ProviderKind::Xai => self.xai_base_url.clone(),
7172 ProviderKind::Mistral => self.mistral_base_url.clone(),
7173 ProviderKind::Google => self.google_base_url.clone(),
7174 ProviderKind::Antigravity => self.antigravity_base_url.clone(),
7175 ProviderKind::Telecomjs => self.telecomjs_base_url.clone(),
7176 ProviderKind::Edenai => self.edenai_base_url.clone(),
7177 ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => {
7178 self.modelstudio_token_plan_base_url.clone()
7179 }
7180 ProviderKind::ModelstudioCodingPlan | ProviderKind::ModelstudioCodingPlanAnthropic => {
7181 self.modelstudio_coding_plan_base_url.clone()
7182 }
7183 ProviderKind::Custom => None,
7186 }
7187 }
7188
7189 fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
7190 let model = match provider {
7191 ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
7192 ProviderKind::Volcengine => self.volcengine_model.clone(),
7193 ProviderKind::Openrouter => self.openrouter_model.clone(),
7194 ProviderKind::Orcarouter => self.orcarouter_model.clone(),
7195 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
7196 self.siliconflow_model.clone()
7197 }
7198 ProviderKind::Arcee => self.arcee_model.clone(),
7199 ProviderKind::Moonshot => self.moonshot_model.clone(),
7200 ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
7201 ProviderKind::Novita => self.novita_model.clone(),
7202 ProviderKind::Fireworks => self.fireworks_model.clone(),
7203 ProviderKind::Huggingface => self.huggingface_model.clone(),
7204 ProviderKind::Together => self.together_model.clone(),
7205 ProviderKind::Qianfan => self.qianfan_model.clone(),
7206 ProviderKind::OpenaiCodex => self.openai_codex_model.clone(),
7207 ProviderKind::Anthropic => self.anthropic_model.clone(),
7208 ProviderKind::Openmodel => self.openmodel_model.clone(),
7209 ProviderKind::Zai => self.zai_model.clone(),
7210 ProviderKind::Stepfun => self.stepfun_model.clone(),
7211 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => self.minimax_model.clone(),
7212 ProviderKind::Deepinfra => self.deepinfra_model.clone(),
7213 ProviderKind::Sakana => self.sakana_model.clone(),
7214 ProviderKind::LongCat => self.longcat_model.clone(),
7215 ProviderKind::OpencodeGo => self.opencode_go_model.clone(),
7216 ProviderKind::OpencodeZen => self.opencode_zen_model.clone(),
7217 ProviderKind::Meta => self.meta_model.clone(),
7218 ProviderKind::Xai => self.xai_model.clone(),
7219 ProviderKind::Mistral => self.mistral_model.clone(),
7220 ProviderKind::Google => self.google_model.clone(),
7221 ProviderKind::Antigravity => self.antigravity_model.clone(),
7222 ProviderKind::Telecomjs => self.telecomjs_model.clone(),
7223 ProviderKind::Edenai => self.edenai_model.clone(),
7224 ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => {
7225 self.modelstudio_token_plan_model.clone()
7226 }
7227 ProviderKind::ModelstudioCodingPlan | ProviderKind::ModelstudioCodingPlanAnthropic => {
7228 self.modelstudio_coding_plan_model.clone()
7229 }
7230 ProviderKind::OllamaCloud => self.ollama_cloud_model.clone(),
7231 _ => None,
7232 }?;
7233
7234 if provider_preserves_custom_base_url_model(provider, base_url) {
7235 Some(model.trim().to_string())
7236 } else {
7237 Some(normalize_model_for_provider(provider, &model))
7238 }
7239 }
7240}
7241
7242#[cfg(test)]
7243mod tests;