1pub mod auth_source;
2pub mod auto_model;
3pub mod catalog;
4mod config_document;
5pub mod external_credentials;
6mod harness;
7pub mod model_reference;
8pub mod models_dev;
9pub mod persistence;
10pub mod pricing;
11pub mod provider;
12mod provider_defaults;
13mod provider_kind;
14pub mod route;
15pub mod setup_state;
16pub mod user_constitution;
17mod xai_credentials;
18pub use config_document::{
19 create_config_document, mutate_config_document, replace_config_document_if_unchanged,
20 set_config_document_value, unset_config_document_value,
21};
22pub use harness::{
23 HarnessCompactionStrategy, HarnessPosture, HarnessPostureKind, HarnessProfile,
24 HarnessSafetyPosture, HarnessToolSurface, built_in_harness_profiles,
25};
26pub use model_reference::{Modality, ModelReferenceCard, ModelReferenceDatabase};
27pub(crate) use provider_defaults::*;
28pub use provider_kind::ProviderKind;
29pub use setup_state::{
30 ConstitutionAuthoring, ConstitutionChoice, ConstitutionSource, ConstitutionValidity,
31 InheritedConfigFacts, RuntimePostureSource, SetupState, SetupStep, StepEntry, StepStatus,
32 TELEMETRY_NOTICE_VERSION,
33};
34pub use user_constitution::{
35 APPROX_BYTES_PER_TOKEN, AutonomyPreference, CacheProjection, ClauseOrigin, ClauseStatus,
36 ConstitutionClause, ConstitutionRecommendation, MigrationOutcome, MigrationReceipt,
37 MigrationRejection, Ratification, RatificationError, RecommendationParse,
38 USER_CONSTITUTION_SCHEMA_VERSION, USER_CONSTITUTION_SCHEMA_VERSION_V1, UntrustedDraftParse,
39 UserConstitution, UserConstitutionLoad,
40};
41pub use xai_credentials::{
42 LEGACY_XAI_OAUTH_FILE_NAME, XAI_OAUTH_GENERATION_PREFIX, XAI_OAUTH_GENERATION_SUFFIX,
43 XaiOAuthCredentialStore, XaiOAuthRevocation, clear_all_xai_oauth_credentials,
44 is_valid_xai_oauth_generation, legacy_xai_oauth_path, remove_xai_oauth_generation,
45 validate_xai_oauth_generation, with_xai_oauth_lifecycle_lock,
46 with_xai_oauth_revocation_transaction, xai_oauth_credentials_dir, xai_oauth_generation_path,
47};
48
49use std::collections::{BTreeMap, BTreeSet};
50use std::ffi::{OsStr, OsString};
51use std::fmt;
52use std::fs;
53#[cfg(unix)]
54use std::io::Read;
55use std::io::Write;
56use std::path::{Component, Path, PathBuf};
57use std::sync::OnceLock;
58
59use anyhow::{Context, Result, bail};
60pub use auth_source::{AuthSourceKind, ProviderAuthSourceToml};
61pub use codewhale_execpolicy::ToolAskRule;
62use codewhale_execpolicy::{ExecPolicyEngine, PermissionAction, Ruleset};
63use codewhale_secrets::SecretSource;
64pub use codewhale_secrets::Secrets;
65pub use external_credentials::{
66 EXTERNAL_CREDENTIAL_CONSENT_VERSION, EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS,
67 ExternalCredentialAccess, ExternalCredentialConsentStatus, ExternalCredentialConsentToml,
68 ExternalCredentialReadGrant, ExternalCredentialSource, external_credential_consent_status,
69 quote_os_path, resolve_external_credential_path,
70};
71use serde::{Deserialize, Serialize};
72use sha2::{Digest as _, Sha256};
73
74#[cfg(unix)]
75use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
76
77pub const CONFIG_FILE_NAME: &str = "config.toml";
78pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml";
79
80pub const API_KEYRING_SENTINEL: &str = "__KEYRING__";
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum ConfigApiKeyValueKind {
86 Empty,
87 SecretStoreSentinel,
88 Literal,
89}
90
91#[must_use]
92pub fn classify_config_api_key_value(value: &str) -> ConfigApiKeyValueKind {
93 match value.trim() {
94 "" => ConfigApiKeyValueKind::Empty,
95 API_KEYRING_SENTINEL => ConfigApiKeyValueKind::SecretStoreSentinel,
96 _ => ConfigApiKeyValueKind::Literal,
97 }
98}
99
100fn http_headers_are_effectively_empty(headers: &BTreeMap<String, String>) -> bool {
101 !headers
102 .iter()
103 .any(|(name, value)| !name.trim().is_empty() && !value.trim().is_empty())
104}
105
106#[must_use]
112pub fn is_upstream_auth_header(name: &str) -> bool {
113 let name = name.trim();
114 is_sensitive_config_key(name) || name.eq_ignore_ascii_case("cookie")
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, Default)]
123pub struct ProviderConfigToml {
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub api_key: Option<String>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub base_url: Option<String>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub model: Option<String>,
130 #[serde(
131 default,
132 skip_serializing_if = "Option::is_none",
133 alias = "contextWindow",
134 alias = "context_window_tokens",
135 alias = "contextWindowTokens",
136 alias = "context_length",
137 alias = "contextLength"
138 )]
139 pub context_window: Option<u32>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub mode: Option<String>,
142 #[serde(
146 default,
147 skip_serializing_if = "Option::is_none",
148 alias = "api_style",
149 alias = "protocol",
150 alias = "wire_format",
151 alias = "dialect"
152 )]
153 pub wire: Option<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub auth_mode: Option<String>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub insecure_skip_tls_verify: Option<bool>,
158 #[serde(default, skip_serializing_if = "http_headers_are_effectively_empty")]
159 pub http_headers: BTreeMap<String, String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub path_suffix: Option<String>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub auth: Option<ProviderAuthSourceToml>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub external_credentials: Option<ExternalCredentialConsentToml>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub oauth_credential_generation: Option<String>,
173 #[serde(flatten)]
176 pub extras: BTreeMap<String, toml::Value>,
177}
178
179impl ProviderConfigToml {
180 #[must_use]
181 pub fn is_empty(&self) -> bool {
182 let blank = |value: Option<&String>| value.is_none_or(|value| value.trim().is_empty());
183
184 blank(self.api_key.as_ref())
185 && blank(self.base_url.as_ref())
186 && blank(self.model.as_ref())
187 && self.context_window.is_none()
188 && blank(self.mode.as_ref())
189 && blank(self.wire.as_ref())
190 && blank(self.auth_mode.as_ref())
191 && self.insecure_skip_tls_verify.is_none()
192 && http_headers_are_effectively_empty(&self.http_headers)
193 && blank(self.path_suffix.as_ref())
194 && self.auth.is_none()
195 && self.external_credentials.is_none()
196 && self.oauth_credential_generation.is_none()
197 && self.extras.is_empty()
198 }
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, Default)]
202pub struct ProvidersToml {
203 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
204 pub deepseek: ProviderConfigToml,
205 #[serde(
206 default,
207 skip_serializing_if = "ProviderConfigToml::is_empty",
208 alias = "deepseek-anthropic",
209 alias = "deepseekAnthropic",
210 alias = "deepseek-claude",
211 alias = "deepseek_claude"
212 )]
213 pub deepseek_anthropic: ProviderConfigToml,
214 #[serde(
215 default,
216 skip_serializing_if = "ProviderConfigToml::is_empty",
217 alias = "nvidia-nim",
221 alias = "nvidia",
222 alias = "nim"
223 )]
224 pub nvidia_nim: ProviderConfigToml,
225 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
226 pub openai: ProviderConfigToml,
227 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
228 pub atlascloud: ProviderConfigToml,
229 #[serde(
230 default,
231 skip_serializing_if = "ProviderConfigToml::is_empty",
232 alias = "wanjie-ark",
233 alias = "wanjie",
234 alias = "ark-wanjie",
235 alias = "ark_wanjie"
236 )]
237 pub wanjie_ark: ProviderConfigToml,
238 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
239 pub volcengine: ProviderConfigToml,
240 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
241 pub openrouter: ProviderConfigToml,
242 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
243 pub orcarouter: ProviderConfigToml,
244 #[serde(
245 default,
246 skip_serializing_if = "ProviderConfigToml::is_empty",
247 alias = "xiaomi-mimo",
248 alias = "xiaomi",
249 alias = "mimo",
250 alias = "xiaomimimo"
251 )]
252 pub xiaomi_mimo: ProviderConfigToml,
253 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
254 pub novita: ProviderConfigToml,
255 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
256 pub fireworks: ProviderConfigToml,
257 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
258 pub siliconflow: ProviderConfigToml,
259 #[serde(
260 default,
261 skip_serializing_if = "ProviderConfigToml::is_empty",
262 alias = "siliconflow-CN",
263 alias = "siliconflow-cn"
264 )]
265 pub siliconflow_cn: ProviderConfigToml,
266 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
267 pub arcee: ProviderConfigToml,
268 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
269 pub moonshot: ProviderConfigToml,
270 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
271 pub sglang: ProviderConfigToml,
272 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
273 pub vllm: ProviderConfigToml,
274 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
275 pub ollama: ProviderConfigToml,
276 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
277 pub huggingface: ProviderConfigToml,
278 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
279 pub together: ProviderConfigToml,
280 #[serde(
281 default,
282 skip_serializing_if = "ProviderConfigToml::is_empty",
283 alias = "baidu-qianfan",
284 alias = "baidu_qianfan",
285 alias = "baidu"
286 )]
287 pub qianfan: ProviderConfigToml,
288 #[serde(
289 default,
290 skip_serializing_if = "ProviderConfigToml::is_empty",
291 alias = "openai-codex",
292 alias = "openai_codex",
293 alias = "codex",
294 alias = "chatgpt",
295 alias = "chatgpt-codex"
296 )]
297 pub openai_codex: ProviderConfigToml,
298 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
299 pub anthropic: ProviderConfigToml,
300 #[serde(
301 default,
302 skip_serializing_if = "ProviderConfigToml::is_empty",
303 alias = "open-model",
304 alias = "open_model"
305 )]
306 pub openmodel: ProviderConfigToml,
307 #[serde(
308 default,
309 skip_serializing_if = "ProviderConfigToml::is_empty",
310 alias = "z-ai",
311 alias = "z_ai",
312 alias = "z.ai",
313 alias = "zhipu",
314 alias = "zhipuai",
315 alias = "bigmodel",
316 alias = "big-model"
317 )]
318 pub zai: ProviderConfigToml,
319 #[serde(
320 default,
321 skip_serializing_if = "ProviderConfigToml::is_empty",
322 alias = "step-fun",
323 alias = "step_fun",
324 alias = "stepfun",
325 alias = "stepflash",
326 alias = "step-flash",
327 alias = "step_flash"
328 )]
329 pub stepfun: ProviderConfigToml,
330 #[serde(
331 default,
332 skip_serializing_if = "ProviderConfigToml::is_empty",
333 alias = "mini-max",
334 alias = "mini_max",
335 alias = "minimax"
336 )]
337 pub minimax: ProviderConfigToml,
338 #[serde(
339 default,
340 skip_serializing_if = "ProviderConfigToml::is_empty",
341 alias = "minimax-anthropic",
342 alias = "minimaxAnthropic",
343 alias = "mini-max-anthropic",
344 alias = "mini_max_anthropic"
345 )]
346 pub minimax_anthropic: ProviderConfigToml,
347 #[serde(
348 default,
349 skip_serializing_if = "ProviderConfigToml::is_empty",
350 alias = "deep-infra",
351 alias = "deep_infra"
352 )]
353 pub deepinfra: ProviderConfigToml,
354 #[serde(
355 default,
356 skip_serializing_if = "ProviderConfigToml::is_empty",
357 alias = "sakana-ai",
358 alias = "sakana_ai",
359 alias = "fugu"
360 )]
361 pub sakana: ProviderConfigToml,
362 #[serde(
363 default,
364 skip_serializing_if = "ProviderConfigToml::is_empty",
365 alias = "long-cat",
366 alias = "meituan-longcat",
367 alias = "meituan"
368 )]
369 pub longcat: ProviderConfigToml,
370 #[serde(
371 default,
372 skip_serializing_if = "ProviderConfigToml::is_empty",
373 alias = "opencode-go",
374 alias = "opencodego"
375 )]
376 pub opencode_go: ProviderConfigToml,
377 #[serde(
378 default,
379 skip_serializing_if = "ProviderConfigToml::is_empty",
380 alias = "opencode-zen",
381 alias = "opencodezen",
382 alias = "zen",
383 alias = "opencode"
384 )]
385 pub opencode_zen: ProviderConfigToml,
386 #[serde(
387 default,
388 skip_serializing_if = "ProviderConfigToml::is_empty",
389 alias = "meta-ai",
390 alias = "meta_ai",
391 alias = "meta-model-api",
392 alias = "meta_model_api",
393 alias = "muse",
394 alias = "muse-spark"
395 )]
396 pub meta: ProviderConfigToml,
397 #[serde(
398 default,
399 skip_serializing_if = "ProviderConfigToml::is_empty",
400 alias = "x-ai",
401 alias = "x_ai",
402 alias = "grok"
403 )]
404 pub xai: ProviderConfigToml,
405 #[serde(
406 default,
407 skip_serializing_if = "ProviderConfigToml::is_empty",
408 alias = "mistral-ai",
409 alias = "mistral_ai",
410 alias = "mistralai",
411 alias = "la-plateforme",
412 alias = "la_plateforme"
413 )]
414 pub mistral: ProviderConfigToml,
415 #[serde(
417 default,
418 skip_serializing_if = "ProviderConfigToml::is_empty",
419 alias = "telecom-js",
420 alias = "telecom_js",
421 alias = "telecomjs-cn",
422 alias = "tokenhub"
423 )]
424 pub telecomjs: ProviderConfigToml,
425 #[serde(
427 default,
428 skip_serializing_if = "ProviderConfigToml::is_empty",
429 alias = "modelstudio-token-plan",
430 alias = "modelstudio_token_plan",
431 alias = "alibaba-token-plan",
432 alias = "dashscope-token-plan"
433 )]
434 pub modelstudio_token_plan: ProviderConfigToml,
435 #[serde(
437 default,
438 skip_serializing_if = "ProviderConfigToml::is_empty",
439 alias = "modelstudio-token-plan-anthropic",
440 alias = "modelstudio_token_plan_anthropic",
441 alias = "alibaba-token-plan-anthropic"
442 )]
443 pub modelstudio_token_plan_anthropic: ProviderConfigToml,
444 #[serde(
446 default,
447 skip_serializing_if = "ProviderConfigToml::is_empty",
448 alias = "modelstudio-coding-plan",
449 alias = "modelstudio_coding_plan",
450 alias = "alibaba-coding-plan",
451 alias = "dashscope-coding-plan"
452 )]
453 pub modelstudio_coding_plan: ProviderConfigToml,
454 #[serde(
456 default,
457 skip_serializing_if = "ProviderConfigToml::is_empty",
458 alias = "modelstudio-coding-plan-anthropic",
459 alias = "modelstudio_coding_plan_anthropic",
460 alias = "alibaba-coding-plan-anthropic"
461 )]
462 pub modelstudio_coding_plan_anthropic: ProviderConfigToml,
463 #[serde(default, skip_serializing_if = "ProviderConfigToml::is_empty")]
469 pub custom: ProviderConfigToml,
470 #[serde(flatten)]
473 pub extras: BTreeMap<String, toml::Value>,
474}
475
476#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
482#[serde(deny_unknown_fields)]
483pub struct PermissionsToml {
484 #[serde(default, skip_serializing_if = "Vec::is_empty")]
485 pub rules: Vec<ToolAskRule>,
486}
487
488#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490pub enum PermissionsFileState {
491 Missing,
493 Empty,
495 Present,
497}
498
499#[derive(Debug, Clone, PartialEq, Eq)]
506pub struct PermissionsSnapshot {
507 path: PathBuf,
508 file_state: PermissionsFileState,
509 permissions: PermissionsToml,
510 removal_tokens: Vec<String>,
511}
512
513impl PermissionsSnapshot {
514 #[must_use]
515 pub fn path(&self) -> &Path {
516 &self.path
517 }
518
519 #[must_use]
520 pub fn file_exists(&self) -> bool {
521 self.file_state != PermissionsFileState::Missing
522 }
523
524 #[must_use]
525 pub fn file_state(&self) -> PermissionsFileState {
526 self.file_state
527 }
528
529 #[must_use]
530 pub fn permissions(&self) -> &PermissionsToml {
531 &self.permissions
532 }
533
534 #[must_use]
535 pub fn rules(&self) -> &[ToolAskRule] {
536 &self.permissions.rules
537 }
538
539 #[must_use]
541 pub fn removal_token(&self, index: usize) -> Option<&str> {
542 self.removal_tokens.get(index).map(String::as_str)
543 }
544}
545
546impl PermissionsToml {
547 #[must_use]
548 pub fn is_empty(&self) -> bool {
549 self.rules.is_empty()
550 }
551
552 #[must_use]
553 pub fn ruleset(&self) -> Ruleset {
554 let mut denied = Vec::new();
555 let mut trusted = Vec::new();
556 let mut ask_rules = Vec::new();
557
558 for rule in &self.rules {
559 match rule.action {
560 PermissionAction::Deny => {
561 if let Some(cmd) = &rule.command
564 && !rule.command_exact
565 && rule.workspace.is_none()
566 {
567 denied.push(cmd.clone());
568 }
569 ask_rules.push(rule.clone());
571 }
572 PermissionAction::Allow => {
573 if let Some(cmd) = &rule.command
577 && !rule.command_exact
578 && rule.workspace.is_none()
579 {
580 trusted.push(cmd.clone());
581 }
582 ask_rules.push(rule.clone());
584 }
585 PermissionAction::Ask => {
586 ask_rules.push(rule.clone());
587 }
588 }
589 }
590
591 Ruleset::user(trusted, denied).with_ask_rules(ask_rules)
592 }
593}
594
595impl ProvidersToml {
596 #[must_use]
597 pub fn is_empty(&self) -> bool {
598 self.extras.is_empty()
599 && ProviderKind::all()
600 .iter()
601 .all(|provider| self.for_provider(*provider).is_empty())
602 }
603
604 #[must_use]
605 pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
606 match provider {
607 ProviderKind::Deepseek => &self.deepseek,
608 ProviderKind::DeepseekAnthropic => &self.deepseek_anthropic,
609 ProviderKind::NvidiaNim => &self.nvidia_nim,
610 ProviderKind::Openai => &self.openai,
611 ProviderKind::Atlascloud => &self.atlascloud,
612 ProviderKind::WanjieArk => &self.wanjie_ark,
613 ProviderKind::Volcengine => &self.volcengine,
614 ProviderKind::Openrouter => &self.openrouter,
615 ProviderKind::Orcarouter => &self.orcarouter,
616 ProviderKind::XiaomiMimo => &self.xiaomi_mimo,
617 ProviderKind::Novita => &self.novita,
618 ProviderKind::Fireworks => &self.fireworks,
619 ProviderKind::Siliconflow => &self.siliconflow,
620 ProviderKind::SiliconflowCN => &self.siliconflow_cn,
621 ProviderKind::Arcee => &self.arcee,
622 ProviderKind::Moonshot => &self.moonshot,
623 ProviderKind::Sglang => &self.sglang,
624 ProviderKind::Vllm => &self.vllm,
625 ProviderKind::Ollama => &self.ollama,
626 ProviderKind::Huggingface => &self.huggingface,
627 ProviderKind::Together => &self.together,
628 ProviderKind::Qianfan => &self.qianfan,
629 ProviderKind::OpenaiCodex => &self.openai_codex,
630 ProviderKind::Anthropic => &self.anthropic,
631 ProviderKind::Openmodel => &self.openmodel,
632 ProviderKind::Zai => &self.zai,
633 ProviderKind::Stepfun => &self.stepfun,
634 ProviderKind::Minimax => &self.minimax,
635 ProviderKind::MinimaxAnthropic => &self.minimax_anthropic,
636 ProviderKind::Deepinfra => &self.deepinfra,
637 ProviderKind::Sakana => &self.sakana,
638 ProviderKind::LongCat => &self.longcat,
639 ProviderKind::OpencodeGo => &self.opencode_go,
640 ProviderKind::OpencodeZen => &self.opencode_zen,
641 ProviderKind::Meta => &self.meta,
642 ProviderKind::Xai => &self.xai,
643 ProviderKind::Mistral => &self.mistral,
644 ProviderKind::Telecomjs => &self.telecomjs,
645 ProviderKind::ModelstudioTokenPlan => &self.modelstudio_token_plan,
646 ProviderKind::ModelstudioTokenPlanAnthropic => &self.modelstudio_token_plan_anthropic,
647 ProviderKind::ModelstudioCodingPlan => &self.modelstudio_coding_plan,
648 ProviderKind::ModelstudioCodingPlanAnthropic => &self.modelstudio_coding_plan_anthropic,
649 ProviderKind::Custom => &self.custom,
650 }
651 }
652
653 pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
654 match provider {
655 ProviderKind::Deepseek => &mut self.deepseek,
656 ProviderKind::DeepseekAnthropic => &mut self.deepseek_anthropic,
657 ProviderKind::NvidiaNim => &mut self.nvidia_nim,
658 ProviderKind::Openai => &mut self.openai,
659 ProviderKind::Atlascloud => &mut self.atlascloud,
660 ProviderKind::WanjieArk => &mut self.wanjie_ark,
661 ProviderKind::Volcengine => &mut self.volcengine,
662 ProviderKind::Openrouter => &mut self.openrouter,
663 ProviderKind::Orcarouter => &mut self.orcarouter,
664 ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo,
665 ProviderKind::Novita => &mut self.novita,
666 ProviderKind::Fireworks => &mut self.fireworks,
667 ProviderKind::Siliconflow => &mut self.siliconflow,
668 ProviderKind::SiliconflowCN => &mut self.siliconflow_cn,
669 ProviderKind::Arcee => &mut self.arcee,
670 ProviderKind::Moonshot => &mut self.moonshot,
671 ProviderKind::Sglang => &mut self.sglang,
672 ProviderKind::Vllm => &mut self.vllm,
673 ProviderKind::Ollama => &mut self.ollama,
674 ProviderKind::Huggingface => &mut self.huggingface,
675 ProviderKind::Together => &mut self.together,
676 ProviderKind::Qianfan => &mut self.qianfan,
677 ProviderKind::OpenaiCodex => &mut self.openai_codex,
678 ProviderKind::Anthropic => &mut self.anthropic,
679 ProviderKind::Openmodel => &mut self.openmodel,
680 ProviderKind::Zai => &mut self.zai,
681 ProviderKind::Stepfun => &mut self.stepfun,
682 ProviderKind::Minimax => &mut self.minimax,
683 ProviderKind::MinimaxAnthropic => &mut self.minimax_anthropic,
684 ProviderKind::Deepinfra => &mut self.deepinfra,
685 ProviderKind::Sakana => &mut self.sakana,
686 ProviderKind::LongCat => &mut self.longcat,
687 ProviderKind::OpencodeGo => &mut self.opencode_go,
688 ProviderKind::OpencodeZen => &mut self.opencode_zen,
689 ProviderKind::Meta => &mut self.meta,
690 ProviderKind::Xai => &mut self.xai,
691 ProviderKind::Mistral => &mut self.mistral,
692 ProviderKind::Telecomjs => &mut self.telecomjs,
693 ProviderKind::ModelstudioTokenPlan => &mut self.modelstudio_token_plan,
694 ProviderKind::ModelstudioTokenPlanAnthropic => {
695 &mut self.modelstudio_token_plan_anthropic
696 }
697 ProviderKind::ModelstudioCodingPlan => &mut self.modelstudio_coding_plan,
698 ProviderKind::ModelstudioCodingPlanAnthropic => {
699 &mut self.modelstudio_coding_plan_anthropic
700 }
701 ProviderKind::Custom => &mut self.custom,
702 }
703 }
704}
705
706fn deserialize_root_provider<'de, D>(deserializer: D) -> std::result::Result<ProviderKind, D::Error>
707where
708 D: serde::Deserializer<'de>,
709{
710 let value = String::deserialize(deserializer)?;
711 let strict = serde::de::value::StringDeserializer::<D::Error>::new(value);
712 Ok(ProviderKind::deserialize(strict).unwrap_or(ProviderKind::Custom))
713}
714
715#[derive(Debug, Clone, Serialize, Deserialize, Default)]
716pub struct ConfigToml {
717 pub api_key: Option<String>,
720 pub base_url: Option<String>,
722 #[serde(default, skip_serializing_if = "http_headers_are_effectively_empty")]
724 pub http_headers: BTreeMap<String, String>,
725 pub default_text_model: Option<String>,
727 #[serde(default, deserialize_with = "deserialize_root_provider")]
728 pub provider: ProviderKind,
729 #[doc(hidden)]
736 #[serde(skip)]
737 pub selected_provider_id: Option<String>,
738 pub model: Option<String>,
739 pub auth_mode: Option<String>,
740 pub output_mode: Option<String>,
741 pub verbosity: Option<String>,
742 pub log_level: Option<String>,
743 pub telemetry: Option<bool>,
744 pub telemetry_endpoint: Option<String>,
761 pub approval_policy: Option<String>,
762 pub sandbox_mode: Option<String>,
763 #[serde(default)]
765 pub tools: Option<ToolsToml>,
766 #[serde(default, skip_serializing_if = "ProvidersToml::is_empty")]
767 pub providers: ProvidersToml,
768 #[serde(default, skip_serializing_if = "Vec::is_empty")]
772 pub fallback_providers: Vec<ProviderKind>,
773 #[serde(default)]
776 pub network: Option<NetworkPolicyToml>,
777 #[serde(default)]
780 pub verifier: Option<VerifierConfigToml>,
781 #[serde(default)]
785 pub skills: Option<SkillsToml>,
786 #[serde(default)]
789 pub snapshots: Option<SnapshotsToml>,
790 #[serde(default)]
793 pub lsp: Option<LspConfigToml>,
794 #[serde(default)]
797 pub harness_profiles: Vec<HarnessProfile>,
798 #[serde(default, skip_serializing_if = "Option::is_none")]
801 pub hotbar: Option<Vec<HotbarBindingToml>>,
802 #[serde(default)]
805 pub hook_sinks: Option<HookSinksToml>,
806 #[serde(default)]
809 pub fleet: Option<FleetConfigToml>,
810 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
820 pub fleets: BTreeMap<String, NamedFleetConfigToml>,
821 #[serde(default)]
825 pub workflow: Option<WorkflowConfigToml>,
826 #[serde(flatten)]
827 pub extras: BTreeMap<String, toml::Value>,
828}
829
830#[derive(Debug, Clone, Copy, PartialEq, Eq)]
831enum ProviderConfigField {
832 ApiKey,
833 BaseUrl,
834 Model,
835 ContextWindow,
836 Mode,
837 Wire,
838 AuthMode,
839 InsecureSkipTlsVerify,
840 HttpHeaders,
841 PathSuffix,
842}
843
844impl ProviderConfigField {
845 fn parse(key: &str) -> Option<Self> {
846 Some(match key {
847 "api_key" => Self::ApiKey,
848 "base_url" => Self::BaseUrl,
849 "model" => Self::Model,
850 "context_window" | "context_window_tokens" => Self::ContextWindow,
851 "mode" => Self::Mode,
852 "wire" | "api_style" | "protocol" | "wire_format" | "dialect" => Self::Wire,
853 "auth_mode" => Self::AuthMode,
854 "insecure_skip_tls_verify" => Self::InsecureSkipTlsVerify,
855 "http_headers" => Self::HttpHeaders,
856 "path_suffix" => Self::PathSuffix,
857 _ => return None,
858 })
859 }
860
861 fn key(self) -> &'static str {
862 match self {
863 Self::ApiKey => "api_key",
864 Self::BaseUrl => "base_url",
865 Self::Model => "model",
866 Self::ContextWindow => "context_window",
867 Self::Mode => "mode",
868 Self::Wire => "wire",
869 Self::AuthMode => "auth_mode",
870 Self::InsecureSkipTlsVerify => "insecure_skip_tls_verify",
871 Self::HttpHeaders => "http_headers",
872 Self::PathSuffix => "path_suffix",
873 }
874 }
875}
876
877fn parse_provider_config_key(key: &str) -> Option<(ProviderKind, ProviderConfigField)> {
878 let suffix = key.strip_prefix("providers.")?;
879 let (provider_key, field_key) = suffix.split_once('.')?;
880 let field = ProviderConfigField::parse(field_key)?;
881 let provider = provider::all_providers()
884 .iter()
885 .map(|p| p.kind())
886 .find(|kind| kind.provider().provider_config_key() == provider_key)?;
887 Some((provider, field))
888}
889
890fn parse_custom_provider_config_key(key: &str) -> Option<(&str, &str)> {
894 let suffix = key.strip_prefix("providers.")?;
895 let (provider_id, field_key) = suffix.split_once('.')?;
896 (!provider_id.is_empty()).then_some((provider_id, field_key))
897}
898
899fn is_builtin_provider_config_id(provider_id: &str) -> bool {
900 provider::all_providers()
901 .iter()
902 .any(|p| p.provider_config_key() == provider_id)
903}
904
905const CUSTOM_PROVIDER_FIELD_HINT: &str = "api_key, base_url, model, context_window, mode, wire, auth_mode, \
908 insecure_skip_tls_verify, http_headers, path_suffix, kind";
909
910fn provider_config_key(provider: ProviderKind, field: ProviderConfigField) -> String {
911 format!(
912 "providers.{}.{}",
913 provider.provider().provider_config_key(),
914 field.key()
915 )
916}
917
918fn get_provider_config_value(
919 config: &ProviderConfigToml,
920 field: ProviderConfigField,
921) -> Option<String> {
922 match field {
923 ProviderConfigField::ApiKey => config.api_key.clone(),
924 ProviderConfigField::BaseUrl => config.base_url.clone(),
925 ProviderConfigField::Model => config.model.clone(),
926 ProviderConfigField::ContextWindow => config.context_window.map(|value| value.to_string()),
927 ProviderConfigField::Mode => config.mode.clone(),
928 ProviderConfigField::Wire => config.wire.clone(),
929 ProviderConfigField::AuthMode => config.auth_mode.clone(),
930 ProviderConfigField::InsecureSkipTlsVerify => config
931 .insecure_skip_tls_verify
932 .map(|value| value.to_string()),
933 ProviderConfigField::HttpHeaders => serialize_http_headers(&config.http_headers),
934 ProviderConfigField::PathSuffix => config.path_suffix.clone(),
935 }
936}
937
938fn get_provider_config_display_value(
939 config: &ProviderConfigToml,
940 field: ProviderConfigField,
941) -> Option<String> {
942 match field {
943 ProviderConfigField::ApiKey => config.api_key.as_deref().map(redact_secret),
944 ProviderConfigField::HttpHeaders => {
945 serialize_http_headers_for_display(&config.http_headers)
946 }
947 _ => get_provider_config_value(config, field),
948 }
949}
950
951fn parse_context_window(value: &str) -> Result<u32> {
952 let parsed = value.trim().parse::<u32>().with_context(|| {
953 format!("invalid context_window '{value}': expected a positive token count")
954 })?;
955 if parsed == 0 {
956 bail!("context_window must be greater than 0");
957 }
958 Ok(parsed)
959}
960
961fn set_provider_config_value(
962 config: &mut ConfigToml,
963 provider: ProviderKind,
964 field: ProviderConfigField,
965 value: &str,
966) -> Result<()> {
967 match field {
968 ProviderConfigField::ApiKey => {
969 let value = value.to_string();
970 config.providers.for_provider_mut(provider).api_key = Some(value.clone());
971 if provider == ProviderKind::Deepseek {
972 config.api_key = Some(value);
973 }
974 }
975 ProviderConfigField::BaseUrl => {
976 let value = value.to_string();
977 config.providers.for_provider_mut(provider).base_url = Some(value.clone());
978 if provider == ProviderKind::Deepseek {
979 config.base_url = Some(value);
980 }
981 }
982 ProviderConfigField::Model => {
983 let value = value.to_string();
984 config.providers.for_provider_mut(provider).model = Some(value.clone());
985 if provider == ProviderKind::Deepseek {
986 config.default_text_model = Some(value);
987 }
988 }
989 ProviderConfigField::ContextWindow => {
990 config.providers.for_provider_mut(provider).context_window =
991 Some(parse_context_window(value)?);
992 }
993 ProviderConfigField::Mode => {
994 config.providers.for_provider_mut(provider).mode = Some(value.to_string());
995 }
996 ProviderConfigField::Wire => {
997 config.providers.for_provider_mut(provider).wire = Some(value.to_string());
998 }
999 ProviderConfigField::AuthMode => {
1000 config.providers.for_provider_mut(provider).auth_mode = Some(value.to_string());
1001 }
1002 ProviderConfigField::InsecureSkipTlsVerify => {
1003 config
1004 .providers
1005 .for_provider_mut(provider)
1006 .insecure_skip_tls_verify = Some(parse_bool(value)?);
1007 }
1008 ProviderConfigField::HttpHeaders => {
1009 let headers = parse_http_headers(value)?;
1010 config.providers.for_provider_mut(provider).http_headers = headers.clone();
1011 if provider == ProviderKind::Deepseek {
1012 config.http_headers = headers;
1013 }
1014 }
1015 ProviderConfigField::PathSuffix => {
1016 config.providers.for_provider_mut(provider).path_suffix = Some(value.to_string());
1017 }
1018 }
1019 Ok(())
1020}
1021
1022fn unset_provider_config_value(
1023 config: &mut ConfigToml,
1024 provider: ProviderKind,
1025 field: ProviderConfigField,
1026) {
1027 match field {
1028 ProviderConfigField::ApiKey => {
1029 config.providers.for_provider_mut(provider).api_key = None;
1030 if provider == ProviderKind::Deepseek {
1031 config.api_key = None;
1032 }
1033 }
1034 ProviderConfigField::BaseUrl => {
1035 config.providers.for_provider_mut(provider).base_url = None;
1036 if provider == ProviderKind::Deepseek {
1037 config.base_url = None;
1038 }
1039 }
1040 ProviderConfigField::Model => {
1041 config.providers.for_provider_mut(provider).model = None;
1042 if provider == ProviderKind::Deepseek {
1043 config.default_text_model = None;
1044 }
1045 }
1046 ProviderConfigField::ContextWindow => {
1047 config.providers.for_provider_mut(provider).context_window = None;
1048 }
1049 ProviderConfigField::Mode => {
1050 config.providers.for_provider_mut(provider).mode = None;
1051 }
1052 ProviderConfigField::Wire => {
1053 config.providers.for_provider_mut(provider).wire = None;
1054 }
1055 ProviderConfigField::AuthMode => {
1056 config.providers.for_provider_mut(provider).auth_mode = None;
1057 }
1058 ProviderConfigField::InsecureSkipTlsVerify => {
1059 config
1060 .providers
1061 .for_provider_mut(provider)
1062 .insecure_skip_tls_verify = None;
1063 }
1064 ProviderConfigField::HttpHeaders => {
1065 config
1066 .providers
1067 .for_provider_mut(provider)
1068 .http_headers
1069 .clear();
1070 if provider == ProviderKind::Deepseek {
1071 config.http_headers.clear();
1072 }
1073 }
1074 ProviderConfigField::PathSuffix => {
1075 config.providers.for_provider_mut(provider).path_suffix = None;
1076 }
1077 }
1078}
1079
1080fn insert_provider_config_values(
1081 out: &mut BTreeMap<String, String>,
1082 provider: ProviderKind,
1083 config: &ProviderConfigToml,
1084) {
1085 if let Some(v) = config.api_key.as_ref() {
1086 out.insert(
1087 provider_config_key(provider, ProviderConfigField::ApiKey),
1088 redact_secret(v),
1089 );
1090 }
1091 if let Some(v) = config.base_url.as_ref() {
1092 out.insert(
1093 provider_config_key(provider, ProviderConfigField::BaseUrl),
1094 v.clone(),
1095 );
1096 }
1097 if let Some(v) = config.model.as_ref() {
1098 out.insert(
1099 provider_config_key(provider, ProviderConfigField::Model),
1100 v.clone(),
1101 );
1102 }
1103 if let Some(v) = config.context_window {
1104 out.insert(
1105 provider_config_key(provider, ProviderConfigField::ContextWindow),
1106 v.to_string(),
1107 );
1108 }
1109 if let Some(v) = config.mode.as_ref() {
1110 out.insert(
1111 provider_config_key(provider, ProviderConfigField::Mode),
1112 v.clone(),
1113 );
1114 }
1115 if let Some(v) = config.auth_mode.as_ref() {
1116 out.insert(
1117 provider_config_key(provider, ProviderConfigField::AuthMode),
1118 v.clone(),
1119 );
1120 }
1121 if let Some(v) = config.insecure_skip_tls_verify {
1122 out.insert(
1123 provider_config_key(provider, ProviderConfigField::InsecureSkipTlsVerify),
1124 v.to_string(),
1125 );
1126 }
1127 if let Some(v) = serialize_http_headers_for_display(&config.http_headers) {
1128 out.insert(
1129 provider_config_key(provider, ProviderConfigField::HttpHeaders),
1130 v,
1131 );
1132 }
1133 if let Some(v) = config.path_suffix.as_ref() {
1134 out.insert(
1135 provider_config_key(provider, ProviderConfigField::PathSuffix),
1136 v.clone(),
1137 );
1138 }
1139}
1140
1141impl ConfigToml {
1142 #[must_use]
1148 pub fn resolve_harness_profile(
1149 &self,
1150 provider_route: &str,
1151 model: &str,
1152 ) -> Option<&HarnessProfile> {
1153 self.harness_profiles
1154 .iter()
1155 .chain(built_in_harness_profiles().iter())
1156 .find(|profile| profile.matches_route(provider_route, model))
1157 }
1158
1159 #[must_use]
1165 pub fn resolve_hotbar_bindings(&self, known_action_ids: &[&str]) -> HotbarConfigResolution {
1166 resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids)
1167 }
1168
1169 pub fn resolve_fleet(&self, name: &str) -> Result<&NamedFleetConfigToml, FleetResolutionError> {
1183 self.fleets
1184 .get(name)
1185 .ok_or_else(|| FleetResolutionError::UnknownFleet {
1186 name: name.to_string(),
1187 available: self.fleets.keys().cloned().collect(),
1188 })
1189 }
1190
1191 pub fn resolve_fleet_for_operator(
1208 &self,
1209 operator: &str,
1210 ) -> Result<(&str, &NamedFleetConfigToml), FleetResolutionError> {
1211 let matches: Vec<(&str, &NamedFleetConfigToml)> = self
1212 .fleets
1213 .iter()
1214 .filter(|(_, fleet)| fleet.operator == operator)
1215 .map(|(name, fleet)| (name.as_str(), fleet))
1216 .collect();
1217
1218 match matches.len() {
1219 0 => {
1220 let mut available: Vec<String> = self
1221 .fleets
1222 .values()
1223 .map(|f| f.operator.clone())
1224 .filter(|op| !op.is_empty())
1225 .collect::<std::collections::BTreeSet<_>>()
1226 .into_iter()
1227 .collect();
1228 available.sort();
1229 Err(FleetResolutionError::UnknownOperator {
1230 operator: operator.to_string(),
1231 available,
1232 })
1233 }
1234 1 => Ok(matches.into_iter().next().unwrap()),
1235 _ => Err(FleetResolutionError::AmbiguousOperator {
1236 operator: operator.to_string(),
1237 fleet_names: matches
1238 .iter()
1239 .map(|(name, _)| (*name).to_string())
1240 .collect(),
1241 }),
1242 }
1243 }
1244}
1245
1246#[derive(Debug, Clone, PartialEq, Eq)]
1251pub struct ProviderChain {
1252 providers: Vec<ProviderKind>,
1253 position: usize,
1254}
1255
1256pub const HOTBAR_SLOT_COUNT: u8 = 8;
1257
1258pub const DEFAULT_HOTBAR_ACTIONS: [&str; HOTBAR_SLOT_COUNT as usize] = [
1259 "voice.toggle",
1260 "session.compact",
1261 "mode.plan",
1262 "mode.agent",
1263 "mode.operate",
1264 "palette.open",
1265 "sidebar.toggle",
1266 "trust.toggle",
1267];
1268
1269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1271#[serde(deny_unknown_fields)]
1272pub struct HotbarBindingToml {
1273 pub slot: u8,
1274 pub action: String,
1275 #[serde(default)]
1276 pub label: Option<String>,
1277}
1278
1279#[derive(Debug, Clone, PartialEq, Eq)]
1281pub struct HotbarBinding {
1282 pub slot: u8,
1283 pub action: String,
1284 pub label: Option<String>,
1285}
1286
1287#[derive(Debug, Clone, PartialEq, Eq)]
1290pub enum HotbarConfigWarning {
1291 SlotOutOfRange {
1292 slot: u8,
1293 action: String,
1294 },
1295 DuplicateSlot {
1296 slot: u8,
1297 previous_action: String,
1298 replacement_action: String,
1299 },
1300 UnknownAction {
1301 slot: u8,
1302 action: String,
1303 },
1304}
1305
1306impl fmt::Display for HotbarConfigWarning {
1307 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1308 match self {
1309 Self::SlotOutOfRange { slot, action } => write!(
1310 f,
1311 "hotbar slot {slot} for action '{action}' is outside 1-{HOTBAR_SLOT_COUNT}; skipped"
1312 ),
1313 Self::DuplicateSlot {
1314 slot,
1315 previous_action,
1316 replacement_action,
1317 } => write!(
1318 f,
1319 "hotbar slot {slot} was bound to '{previous_action}' more than once; using '{replacement_action}'"
1320 ),
1321 Self::UnknownAction { slot, action } => write!(
1322 f,
1323 "hotbar slot {slot} references unknown action '{action}'; keeping binding"
1324 ),
1325 }
1326 }
1327}
1328
1329#[derive(Debug, Clone, PartialEq, Eq)]
1330pub struct HotbarConfigResolution {
1331 pub bindings: Vec<HotbarBinding>,
1332 pub warnings: Vec<HotbarConfigWarning>,
1333}
1334
1335#[must_use]
1336pub fn default_hotbar_bindings() -> Vec<HotbarBinding> {
1337 DEFAULT_HOTBAR_ACTIONS
1338 .iter()
1339 .enumerate()
1340 .map(|(idx, action)| HotbarBinding {
1341 slot: u8::try_from(idx + 1).expect("default hotbar slot fits in u8"),
1342 action: (*action).to_string(),
1343 label: None,
1344 })
1345 .collect()
1346}
1347
1348#[must_use]
1354pub fn default_hotbar_bindings_toml() -> Vec<HotbarBindingToml> {
1355 default_hotbar_bindings()
1356 .into_iter()
1357 .map(|binding| HotbarBindingToml {
1358 slot: binding.slot,
1359 action: binding.action,
1360 label: binding.label,
1361 })
1362 .collect()
1363}
1364
1365#[must_use]
1366pub fn resolve_hotbar_bindings(
1367 configured: Option<&[HotbarBindingToml]>,
1368 known_action_ids: &[&str],
1369) -> HotbarConfigResolution {
1370 let known = known_action_ids.iter().copied().collect::<BTreeSet<&str>>();
1371 let mut warnings = Vec::new();
1372
1373 let source = match configured {
1374 Some(bindings) => bindings
1375 .iter()
1376 .map(|binding| HotbarBinding {
1377 slot: binding.slot,
1378 action: binding.action.clone(),
1379 label: binding.label.clone(),
1380 })
1381 .collect::<Vec<_>>(),
1382 None => Vec::new(),
1386 };
1387
1388 let mut by_slot: BTreeMap<u8, HotbarBinding> = BTreeMap::new();
1389 for binding in source {
1390 if !(1..=HOTBAR_SLOT_COUNT).contains(&binding.slot) {
1391 warnings.push(HotbarConfigWarning::SlotOutOfRange {
1392 slot: binding.slot,
1393 action: binding.action,
1394 });
1395 continue;
1396 }
1397 if !known.is_empty() && !known.contains(binding.action.as_str()) {
1398 warnings.push(HotbarConfigWarning::UnknownAction {
1399 slot: binding.slot,
1400 action: binding.action.clone(),
1401 });
1402 }
1403 if let Some(previous) = by_slot.insert(binding.slot, binding.clone()) {
1404 warnings.push(HotbarConfigWarning::DuplicateSlot {
1405 slot: binding.slot,
1406 previous_action: previous.action,
1407 replacement_action: binding.action,
1408 });
1409 }
1410 }
1411
1412 HotbarConfigResolution {
1413 bindings: by_slot.into_values().collect(),
1414 warnings,
1415 }
1416}
1417
1418impl ProviderChain {
1419 #[must_use]
1420 pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self {
1421 let mut providers = vec![active];
1422 for fallback in fallbacks {
1423 if *fallback != active && !providers.contains(fallback) {
1424 providers.push(*fallback);
1425 }
1426 }
1427 Self {
1428 providers,
1429 position: 0,
1430 }
1431 }
1432
1433 #[must_use]
1434 pub fn providers(&self) -> &[ProviderKind] {
1435 &self.providers
1436 }
1437
1438 #[must_use]
1439 pub fn position(&self) -> usize {
1440 self.position
1441 }
1442
1443 #[must_use]
1444 pub fn current(&self) -> ProviderKind {
1445 self.providers
1446 .get(self.position)
1447 .copied()
1448 .or_else(|| self.providers.first().copied())
1449 .unwrap_or_default()
1450 }
1451
1452 #[must_use]
1453 pub fn has_next(&self) -> bool {
1454 self.position + 1 < self.providers.len()
1455 }
1456
1457 pub fn advance(&mut self) -> Option<ProviderKind> {
1458 if !self.has_next() {
1459 return None;
1460 }
1461 self.position += 1;
1462 Some(self.current())
1463 }
1464
1465 pub fn reset(&mut self) {
1466 self.position = 0;
1467 }
1468
1469 #[must_use]
1470 pub fn is_fallback_active(&self) -> bool {
1471 self.position > 0
1472 }
1473
1474 #[must_use]
1476 pub fn remaining(&self) -> usize {
1477 self.providers.len() - self.position
1478 }
1479}
1480
1481#[cfg(test)]
1482mod provider_chain_tests {
1483 use super::*;
1484
1485 #[test]
1486 fn current_on_empty_chain_returns_default_provider() {
1487 let chain = ProviderChain {
1488 providers: vec![],
1489 position: 0,
1490 };
1491 assert_eq!(chain.current(), ProviderKind::default());
1492 }
1493}
1494
1495#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1497pub struct HookSinksToml {
1498 #[serde(default)]
1503 pub unix_socket_path: Option<PathBuf>,
1504}
1505
1506#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1509pub struct SkillsToml {
1510 #[serde(default)]
1513 pub registry_url: Option<String>,
1514 #[serde(default)]
1517 pub max_install_size_bytes: Option<u64>,
1518}
1519
1520#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1522pub struct ToolsToml {
1523 #[serde(default)]
1525 pub always_load: Vec<String>,
1526}
1527
1528#[derive(Debug, Clone, Serialize, Deserialize)]
1531pub struct SnapshotsToml {
1532 #[serde(default = "default_snapshots_enabled")]
1533 pub enabled: bool,
1534 #[serde(default = "default_snapshot_max_age_days")]
1535 pub max_age_days: u64,
1536}
1537
1538fn default_snapshots_enabled() -> bool {
1539 true
1540}
1541
1542fn default_snapshot_max_age_days() -> u64 {
1543 7
1544}
1545
1546impl Default for SnapshotsToml {
1547 fn default() -> Self {
1548 Self {
1549 enabled: default_snapshots_enabled(),
1550 max_age_days: default_snapshot_max_age_days(),
1551 }
1552 }
1553}
1554
1555#[derive(Debug, Clone, PartialEq, Eq)]
1560pub enum FleetResolutionError {
1561 UnknownFleet {
1563 name: String,
1565 available: Vec<String>,
1567 },
1568 UnknownOperator {
1570 operator: String,
1572 available: Vec<String>,
1574 },
1575 AmbiguousOperator {
1577 operator: String,
1579 fleet_names: Vec<String>,
1581 },
1582}
1583
1584impl std::fmt::Display for FleetResolutionError {
1585 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1586 match self {
1587 Self::UnknownFleet { name, available } => {
1588 write!(f, "fleet `{name}` is not defined")?;
1589 if available.is_empty() {
1590 write!(
1591 f,
1592 ". No named fleets are configured. Add `[fleets.{name}]` to your \
1593 config.toml or use the default `[fleet]` table."
1594 )
1595 } else {
1596 write!(
1597 f,
1598 ". Available named fleets: {}. Check your config.toml `[fleets.*]` \
1599 tables.",
1600 available.join(", ")
1601 )
1602 }
1603 }
1604 Self::UnknownOperator {
1605 operator,
1606 available,
1607 } => {
1608 write!(f, "no fleet is owned by operator `{operator}`")?;
1609 if available.is_empty() {
1610 write!(
1611 f,
1612 ". No named fleets define an operator. Add \
1613 `operator = \"{operator}\"` inside a `[fleets.<name>]` table."
1614 )
1615 } else {
1616 write!(
1617 f,
1618 ". Operators with configured fleets: {}.",
1619 available.join(", ")
1620 )
1621 }
1622 }
1623 Self::AmbiguousOperator {
1624 operator,
1625 fleet_names,
1626 } => {
1627 write!(
1628 f,
1629 "operator `{operator}` owns multiple fleets ({}); specify a fleet name \
1630 explicitly.",
1631 fleet_names.join(", ")
1632 )
1633 }
1634 }
1635 }
1636}
1637
1638impl std::error::Error for FleetResolutionError {}
1639
1640#[derive(Debug, Clone, Serialize, Deserialize)]
1643pub struct FleetConfigToml {
1644 #[serde(default = "default_fleet_trust_level_str")]
1647 pub default_trust_level: String,
1648 #[serde(default = "default_fleet_require_identity")]
1651 pub require_identity_verification: bool,
1652 #[serde(default = "default_fleet_max_trust_level_str")]
1655 pub max_trust_level: String,
1656 #[serde(default)]
1663 pub roles: BTreeMap<String, FleetRolePreset>,
1664 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1668 pub profiles: BTreeMap<String, FleetProfile>,
1669 #[serde(default)]
1671 pub exec: FleetExecConfig,
1672}
1673
1674pub const DEFAULT_SPAWN_DEPTH: u32 = 3;
1689pub const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 900;
1690pub const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1;
1691pub const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600;
1692
1693pub const MAX_SPAWN_DEPTH_CEILING: u32 = 8;
1699
1700#[derive(Debug, Clone, Serialize, Deserialize)]
1705pub struct FleetExecConfig {
1706 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1708 pub allowed_tools: Vec<String>,
1709 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1711 pub disallowed_tools: Vec<String>,
1712 #[serde(default = "default_fleet_max_turns")]
1716 pub max_turns: u32,
1717 #[serde(default = "default_fleet_max_spawn_depth")]
1723 pub max_spawn_depth: u32,
1724 #[serde(default, skip_serializing_if = "String::is_empty")]
1727 pub append_system_prompt: String,
1728 #[serde(default = "default_fleet_output_format")]
1731 pub output_format: String,
1732}
1733
1734pub const FLEET_DEFAULT_MAX_TURNS: u32 = 500;
1738
1739fn default_fleet_max_turns() -> u32 {
1740 FLEET_DEFAULT_MAX_TURNS
1741}
1742
1743fn default_fleet_max_spawn_depth() -> u32 {
1744 DEFAULT_SPAWN_DEPTH
1745}
1746
1747fn default_fleet_output_format() -> String {
1748 "text".to_string()
1749}
1750
1751impl Default for FleetExecConfig {
1752 fn default() -> Self {
1753 Self {
1754 allowed_tools: Vec::new(),
1755 disallowed_tools: Vec::new(),
1756 max_turns: default_fleet_max_turns(),
1757 max_spawn_depth: default_fleet_max_spawn_depth(),
1758 append_system_prompt: String::new(),
1759 output_format: default_fleet_output_format(),
1760 }
1761 }
1762}
1763
1764#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1770pub struct FleetProfile {
1771 #[serde(default)]
1773 pub slot: FleetSlot,
1774 #[serde(default)]
1776 pub role: FleetRole,
1777 #[serde(default)]
1779 pub loadout: FleetLoadout,
1780 #[serde(default, skip_serializing_if = "Option::is_none")]
1785 pub model: Option<String>,
1786 #[serde(default, skip_serializing_if = "Option::is_none")]
1799 pub provider: Option<String>,
1800 #[serde(default, skip_serializing_if = "Option::is_none")]
1806 pub reasoning_effort: Option<String>,
1807 #[serde(default)]
1809 pub permissions: FleetProfilePermissions,
1810 #[serde(default)]
1812 pub delegation: FleetDelegationHints,
1813}
1814
1815#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
1820pub struct FleetRole {
1821 pub name: String,
1823 #[serde(default, skip_serializing_if = "Option::is_none")]
1825 pub description: Option<String>,
1826 #[serde(default, skip_serializing_if = "Option::is_none")]
1828 pub instructions: Option<String>,
1829}
1830
1831impl Default for FleetRole {
1832 fn default() -> Self {
1833 Self {
1834 name: "general".to_string(),
1835 description: None,
1836 instructions: None,
1837 }
1838 }
1839}
1840
1841impl<'de> Deserialize<'de> for FleetRole {
1842 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1843 where
1844 D: serde::Deserializer<'de>,
1845 {
1846 #[derive(Deserialize)]
1847 #[serde(untagged)]
1848 enum FleetRoleWire {
1849 Name(String),
1850 Full {
1851 #[serde(default)]
1852 name: Option<String>,
1853 #[serde(default)]
1854 description: Option<String>,
1855 #[serde(default)]
1856 instructions: Option<String>,
1857 },
1858 }
1859
1860 match FleetRoleWire::deserialize(deserializer)? {
1861 FleetRoleWire::Name(name) => Ok(Self {
1862 name,
1863 ..Self::default()
1864 }),
1865 FleetRoleWire::Full {
1866 name,
1867 description,
1868 instructions,
1869 } => Ok(Self {
1870 name: name.unwrap_or_else(|| Self::default().name),
1871 description,
1872 instructions,
1873 }),
1874 }
1875 }
1876}
1877
1878#[derive(Debug, Clone, PartialEq, Eq, Default)]
1880pub enum FleetSlot {
1881 Manager,
1882 Scout,
1883 Planner,
1884 Implementer,
1885 Reviewer,
1886 Verifier,
1887 Operator,
1888 Summarizer,
1889 #[default]
1890 General,
1891 Custom(String),
1892}
1893
1894impl FleetSlot {
1895 #[must_use]
1896 pub fn as_str(&self) -> &str {
1897 match self {
1898 Self::Manager => "manager",
1899 Self::Scout => "scout",
1900 Self::Planner => "planner",
1901 Self::Implementer => "implementer",
1902 Self::Reviewer => "reviewer",
1903 Self::Verifier => "verifier",
1904 Self::Operator => "operator",
1905 Self::Summarizer => "summarizer",
1906 Self::General => "general",
1907 Self::Custom(value) => value.as_str(),
1908 }
1909 }
1910
1911 #[must_use]
1912 pub fn from_name(value: &str) -> Self {
1913 match value.trim() {
1914 "manager" | "coordinator" => Self::Manager,
1915 "scout" | "research" | "research-worker" => Self::Scout,
1916 "planner" | "plan" | "awaiter" => Self::Planner,
1917 "implementer" | "builder" => Self::Implementer,
1918 "reviewer" => Self::Reviewer,
1919 "verifier" | "tester" => Self::Verifier,
1920 "operator" | "incident" | "incident-worker" => Self::Operator,
1921 "summarizer" | "reducer" => Self::Summarizer,
1922 "general" | "" => Self::General,
1923 other => Self::Custom(other.to_string()),
1927 }
1928 }
1929}
1930
1931impl Serialize for FleetSlot {
1932 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1933 where
1934 S: serde::Serializer,
1935 {
1936 serializer.serialize_str(self.as_str())
1937 }
1938}
1939
1940impl<'de> Deserialize<'de> for FleetSlot {
1941 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1942 where
1943 D: serde::Deserializer<'de>,
1944 {
1945 let value = String::deserialize(deserializer)?;
1946 Ok(Self::from_name(&value))
1947 }
1948}
1949
1950#[derive(Debug, Clone, PartialEq, Eq, Default)]
1952pub enum FleetLoadout {
1953 #[default]
1955 Inherit,
1956 Fast,
1958 Custom(String),
1962}
1963
1964impl FleetLoadout {
1965 #[must_use]
1966 pub fn as_str(&self) -> &str {
1967 match self {
1968 Self::Inherit => "inherit",
1969 Self::Fast => "fast",
1970 Self::Custom(value) => value.as_str(),
1971 }
1972 }
1973
1974 #[must_use]
1975 pub fn from_name(value: &str) -> Self {
1976 match value.trim() {
1977 "inherit" | "default" | "auto" | "" => Self::Inherit,
1978 "fast" => Self::Fast,
1979 other => Self::Custom(other.to_string()),
1983 }
1984 }
1985}
1986
1987impl Serialize for FleetLoadout {
1988 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1989 where
1990 S: serde::Serializer,
1991 {
1992 serializer.serialize_str(self.as_str())
1993 }
1994}
1995
1996impl<'de> Deserialize<'de> for FleetLoadout {
1997 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1998 where
1999 D: serde::Deserializer<'de>,
2000 {
2001 let value = String::deserialize(deserializer)?;
2002 Ok(Self::from_name(&value))
2003 }
2004}
2005
2006#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2008pub struct FleetProfilePermissions {
2009 #[serde(default)]
2011 pub allow_shell: bool,
2012 #[serde(default)]
2014 pub trust: bool,
2015 #[serde(default = "default_fleet_profile_approval_required")]
2017 pub approval_required: bool,
2018}
2019
2020fn default_fleet_profile_approval_required() -> bool {
2021 true
2022}
2023
2024impl Default for FleetProfilePermissions {
2025 fn default() -> Self {
2026 Self {
2027 allow_shell: false,
2028 trust: false,
2029 approval_required: true,
2030 }
2031 }
2032}
2033
2034#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
2036pub struct FleetDelegationHints {
2037 #[serde(default, skip_serializing_if = "Option::is_none")]
2040 pub max_spawn_depth: Option<u32>,
2041 #[serde(
2043 default,
2044 alias = "concurrency",
2045 skip_serializing_if = "Option::is_none"
2046 )]
2047 pub max_concurrency: Option<usize>,
2048}
2049
2050#[derive(Debug, Clone, Serialize, Deserialize)]
2059pub struct FleetRolePreset {
2060 #[serde(skip_serializing_if = "Option::is_none")]
2062 pub description: Option<String>,
2063 #[serde(skip_serializing_if = "Option::is_none")]
2065 pub tool_profile: Option<String>,
2066 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2068 pub tools: Vec<String>,
2069 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2071 pub capabilities: Vec<String>,
2072 #[serde(skip_serializing_if = "Option::is_none")]
2074 pub timeout_seconds: Option<u64>,
2075 #[serde(skip_serializing_if = "Option::is_none")]
2077 pub trust_level: Option<String>,
2078}
2079
2080fn default_fleet_trust_level_str() -> String {
2081 "sandbox".to_string()
2082}
2083
2084fn default_fleet_require_identity() -> bool {
2085 true
2086}
2087
2088fn default_fleet_max_trust_level_str() -> String {
2089 "operator".to_string()
2090}
2091
2092impl Default for FleetConfigToml {
2093 fn default() -> Self {
2094 Self {
2095 default_trust_level: default_fleet_trust_level_str(),
2096 require_identity_verification: default_fleet_require_identity(),
2097 max_trust_level: default_fleet_max_trust_level_str(),
2098 roles: BTreeMap::new(),
2099 profiles: BTreeMap::new(),
2100 exec: FleetExecConfig::default(),
2101 }
2102 }
2103}
2104
2105impl FleetConfigToml {
2106 #[must_use]
2109 pub fn resolve_role(&self, name: &str) -> Option<FleetRolePreset> {
2110 self.roles
2111 .get(name)
2112 .cloned()
2113 .or_else(|| built_in_role_presets().get(name).cloned())
2114 }
2115}
2116
2117#[derive(Debug, Clone, Serialize, Deserialize)]
2141pub struct NamedFleetConfigToml {
2142 pub operator: String,
2147 #[serde(default = "default_fleet_trust_level_str")]
2150 pub default_trust_level: String,
2151 #[serde(default = "default_fleet_require_identity")]
2154 pub require_identity_verification: bool,
2155 #[serde(default = "default_fleet_max_trust_level_str")]
2157 pub max_trust_level: String,
2158 #[serde(default)]
2160 pub roles: BTreeMap<String, FleetRolePreset>,
2161 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2163 pub profiles: BTreeMap<String, FleetProfile>,
2164 #[serde(default)]
2166 pub exec: FleetExecConfig,
2167}
2168
2169impl NamedFleetConfigToml {
2170 #[must_use]
2173 pub fn resolve_role(&self, name: &str) -> Option<FleetRolePreset> {
2174 self.roles
2175 .get(name)
2176 .cloned()
2177 .or_else(|| built_in_role_presets().get(name).cloned())
2178 }
2179
2180 #[must_use]
2185 pub fn as_fleet_config(&self) -> FleetConfigToml {
2186 FleetConfigToml {
2187 default_trust_level: self.default_trust_level.clone(),
2188 require_identity_verification: self.require_identity_verification,
2189 max_trust_level: self.max_trust_level.clone(),
2190 roles: self.roles.clone(),
2191 profiles: self.profiles.clone(),
2192 exec: self.exec.clone(),
2193 }
2194 }
2195}
2196
2197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2204pub struct WorkflowConfigToml {
2205 #[serde(default = "default_workflow_automatic")]
2208 pub automatic: bool,
2209 #[serde(default = "default_workflow_auto_start_read_only")]
2213 pub auto_start_read_only: bool,
2214 #[serde(default = "default_workflow_require_approval_for_writes")]
2217 pub require_approval_for_writes: bool,
2218 #[serde(default = "default_workflow_auto_start_child_limit")]
2221 pub auto_start_child_limit: u32,
2222 #[serde(default = "default_workflow_max_children")]
2224 pub max_children: u32,
2225 #[serde(default = "default_workflow_max_concurrent")]
2227 pub max_concurrent: u32,
2228 #[serde(default = "default_workflow_max_depth")]
2230 pub max_depth: u32,
2231 #[serde(default = "default_workflow_default_token_budget")]
2233 pub default_token_budget: u64,
2234 #[serde(default = "default_workflow_max_parallel_writes_without_worktree")]
2237 pub max_parallel_writes_without_worktree: u32,
2238 #[serde(default = "default_workflow_persist_completed_activity")]
2241 pub persist_completed_activity: bool,
2242 #[serde(default = "default_workflow_persist_completed_across_restarts")]
2245 pub persist_completed_across_restarts: bool,
2246}
2247
2248fn default_workflow_automatic() -> bool {
2249 true
2250}
2251
2252fn default_workflow_auto_start_read_only() -> bool {
2253 true
2254}
2255
2256fn default_workflow_require_approval_for_writes() -> bool {
2257 true
2258}
2259
2260fn default_workflow_auto_start_child_limit() -> u32 {
2261 16
2263}
2264
2265fn default_workflow_max_children() -> u32 {
2266 1000
2267}
2268
2269fn default_workflow_max_concurrent() -> u32 {
2270 16
2271}
2272
2273fn default_workflow_max_depth() -> u32 {
2274 2
2275}
2276
2277fn default_workflow_default_token_budget() -> u64 {
2278 120_000
2279}
2280
2281fn default_workflow_max_parallel_writes_without_worktree() -> u32 {
2282 0
2283}
2284
2285fn default_workflow_persist_completed_activity() -> bool {
2286 true
2287}
2288
2289fn default_workflow_persist_completed_across_restarts() -> bool {
2290 true
2291}
2292
2293impl Default for WorkflowConfigToml {
2294 fn default() -> Self {
2295 Self {
2296 automatic: default_workflow_automatic(),
2297 auto_start_read_only: default_workflow_auto_start_read_only(),
2298 require_approval_for_writes: default_workflow_require_approval_for_writes(),
2299 auto_start_child_limit: default_workflow_auto_start_child_limit(),
2300 max_children: default_workflow_max_children(),
2301 max_concurrent: default_workflow_max_concurrent(),
2302 max_depth: default_workflow_max_depth(),
2303 default_token_budget: default_workflow_default_token_budget(),
2304 max_parallel_writes_without_worktree:
2305 default_workflow_max_parallel_writes_without_worktree(),
2306 persist_completed_activity: default_workflow_persist_completed_activity(),
2307 persist_completed_across_restarts: default_workflow_persist_completed_across_restarts(),
2308 }
2309 }
2310}
2311
2312#[must_use]
2314pub fn built_in_role_presets() -> BTreeMap<String, FleetRolePreset> {
2315 [
2316 (
2317 "smoke-runner".to_string(),
2318 FleetRolePreset {
2319 description: Some("Lightweight read-only smoke check worker".to_string()),
2320 tool_profile: Some("read-only".to_string()),
2321 tools: vec![],
2322 capabilities: vec![],
2323 timeout_seconds: Some(300),
2324 trust_level: Some("local".to_string()),
2325 },
2326 ),
2327 (
2328 "reviewer".to_string(),
2329 FleetRolePreset {
2330 description: Some("Read-only code and documentation review".to_string()),
2331 tool_profile: Some("read-only".to_string()),
2332 tools: vec![],
2333 capabilities: vec![],
2334 timeout_seconds: Some(600),
2335 trust_level: None,
2336 },
2337 ),
2338 (
2339 "builder".to_string(),
2340 FleetRolePreset {
2341 description: Some(
2342 "Read-write builder with compilation and test access".to_string(),
2343 ),
2344 tool_profile: Some("read-write".to_string()),
2345 tools: vec![],
2346 capabilities: vec![],
2347 timeout_seconds: Some(1800),
2348 trust_level: Some("local".to_string()),
2349 },
2350 ),
2351 (
2352 "read-only".to_string(),
2353 FleetRolePreset {
2354 description: Some(
2355 "Minimal read-only observer with no writes or secrets".to_string(),
2356 ),
2357 tool_profile: Some("read-only".to_string()),
2358 tools: vec![],
2359 capabilities: vec![],
2360 timeout_seconds: Some(300),
2361 trust_level: Some("sandbox".to_string()),
2362 },
2363 ),
2364 ]
2365 .into()
2366}
2367
2368#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2374#[serde(rename_all = "snake_case")]
2375pub enum VerifierVerdictPolicy {
2376 #[default]
2377 Hunt,
2378}
2379
2380#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2382pub struct VerifierConfigToml {
2383 #[serde(default)]
2387 pub enabled: bool,
2388 #[serde(default)]
2390 pub verdict_policy: VerifierVerdictPolicy,
2391}
2392
2393impl Default for VerifierConfigToml {
2394 fn default() -> Self {
2395 Self {
2396 enabled: false,
2397 verdict_policy: VerifierVerdictPolicy::Hunt,
2398 }
2399 }
2400}
2401
2402#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2410pub struct AdvisorConfigToml {
2411 #[serde(default)]
2415 pub enabled: bool,
2416 #[serde(default = "advisor_default_max_tool_calls")]
2420 pub max_tool_calls: u32,
2421 #[serde(default = "advisor_default_rate_limit_secs")]
2425 pub rate_limit_secs: u64,
2426 #[serde(default = "advisor_default_dedup_window_secs")]
2430 pub dedup_window_secs: u64,
2431 #[serde(default)]
2434 pub model: Option<String>,
2435}
2436
2437fn advisor_default_max_tool_calls() -> u32 {
2438 10
2439}
2440fn advisor_default_rate_limit_secs() -> u64 {
2441 60
2442}
2443fn advisor_default_dedup_window_secs() -> u64 {
2444 300
2445}
2446
2447impl Default for AdvisorConfigToml {
2448 fn default() -> Self {
2449 Self {
2450 enabled: false,
2451 max_tool_calls: advisor_default_max_tool_calls(),
2452 rate_limit_secs: advisor_default_rate_limit_secs(),
2453 dedup_window_secs: advisor_default_dedup_window_secs(),
2454 model: None,
2455 }
2456 }
2457}
2458
2459#[derive(Debug, Clone, Serialize, Deserialize)]
2462pub struct NetworkPolicyToml {
2463 #[serde(default = "default_network_decision")]
2466 pub default: String,
2467 #[serde(default)]
2470 pub allow: Vec<String>,
2471 #[serde(default)]
2473 pub deny: Vec<String>,
2474 #[serde(default)]
2477 pub proxy: Vec<String>,
2478 #[serde(default)]
2481 pub proxy_fake_ip_cidrs: Vec<String>,
2482 #[serde(default = "default_network_audit")]
2484 pub audit: bool,
2485}
2486
2487fn default_network_decision() -> String {
2488 "prompt".to_string()
2489}
2490
2491fn default_network_audit() -> bool {
2492 true
2493}
2494
2495impl Default for NetworkPolicyToml {
2496 fn default() -> Self {
2497 Self {
2498 default: default_network_decision(),
2499 allow: Vec::new(),
2500 deny: Vec::new(),
2501 proxy: Vec::new(),
2502 proxy_fake_ip_cidrs: Vec::new(),
2503 audit: default_network_audit(),
2504 }
2505 }
2506}
2507
2508#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
2511pub struct CustomLspDef {
2512 pub language_id: String,
2514 pub command: String,
2516 #[serde(default)]
2518 pub args: Vec<String>,
2519}
2520
2521#[derive(Debug, Clone, Serialize, Deserialize, Default)]
2525pub struct LspConfigToml {
2526 pub enabled: Option<bool>,
2528 pub poll_after_edit_ms: Option<u64>,
2530 pub max_diagnostics_per_file: Option<usize>,
2532 pub include_warnings: Option<bool>,
2534 pub servers: Option<BTreeMap<String, Vec<String>>>,
2536 pub custom: Option<BTreeMap<String, CustomLspDef>>,
2539}
2540
2541impl ConfigToml {
2542 #[must_use]
2545 pub fn provider_id(&self) -> &str {
2546 self.named_custom_provider_id()
2547 .unwrap_or_else(|| self.provider.as_str())
2548 }
2549
2550 #[must_use]
2553 pub fn named_custom_provider_id(&self) -> Option<&str> {
2554 (self.provider == ProviderKind::Custom)
2555 .then_some(self.selected_provider_id.as_deref())
2556 .flatten()
2557 }
2558
2559 fn named_custom_provider_table(&self, provider_id: &str) -> Result<&toml::value::Table> {
2560 let table = self
2561 .providers
2562 .extras
2563 .get(provider_id)
2564 .and_then(toml::Value::as_table)
2565 .with_context(|| {
2566 format!(
2567 "custom provider '{provider_id}' requires a matching [providers.{provider_id}] table"
2568 )
2569 })?;
2570 let compatible = table
2571 .get("kind")
2572 .and_then(toml::Value::as_str)
2573 .is_some_and(|kind| {
2574 kind.trim()
2575 .to_ascii_lowercase()
2576 .replace('_', "-")
2577 .eq("openai-compatible")
2578 });
2579 if !compatible {
2580 bail!(
2581 "custom provider '{provider_id}' must set [providers.{provider_id}].kind = \"openai-compatible\""
2582 );
2583 }
2584 Ok(table)
2585 }
2586
2587 fn named_custom_provider_config(&self) -> Option<ProviderConfigToml> {
2588 let provider_id = self.named_custom_provider_id()?;
2589 self.named_custom_provider_table(provider_id).ok()?;
2590 self.providers
2591 .extras
2592 .get(provider_id)
2593 .cloned()?
2594 .try_into()
2595 .ok()
2596 }
2597
2598 fn custom_provider_table_mut(&mut self, provider_id: &str) -> Result<&mut toml::value::Table> {
2601 let entry = self
2602 .providers
2603 .extras
2604 .entry(provider_id.to_string())
2605 .or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
2606 entry.as_table_mut().with_context(|| {
2607 format!("custom provider '{provider_id}' must be a [providers.{provider_id}] table")
2608 })
2609 }
2610
2611 fn set_custom_provider_value(
2616 &mut self,
2617 provider_id: &str,
2618 field_key: &str,
2619 value: &str,
2620 ) -> Result<()> {
2621 if is_builtin_provider_config_id(provider_id) {
2622 bail!(
2623 "unknown field '{field_key}' for built-in provider '{provider_id}': \
2624 expected one of api_key, base_url, model, context_window, mode, auth_mode, \
2625 insecure_skip_tls_verify, http_headers, path_suffix"
2626 );
2627 }
2628 if field_key == "kind" {
2629 let compatible =
2630 value.trim().to_ascii_lowercase().replace('_', "-") == "openai-compatible";
2631 if !compatible {
2632 bail!(
2633 "custom provider '{provider_id}' must set [providers.{provider_id}].kind = \"openai-compatible\""
2634 );
2635 }
2636 self.custom_provider_table_mut(provider_id)?.insert(
2637 "kind".to_string(),
2638 toml::Value::String(value.trim().to_string()),
2639 );
2640 return Ok(());
2641 }
2642 let Some(field) = ProviderConfigField::parse(field_key) else {
2643 bail!(
2644 "unknown field '{field_key}' for custom provider '{provider_id}': \
2645 expected one of {CUSTOM_PROVIDER_FIELD_HINT}"
2646 );
2647 };
2648 let toml_value = match field {
2649 ProviderConfigField::ApiKey
2650 | ProviderConfigField::BaseUrl
2651 | ProviderConfigField::Model
2652 | ProviderConfigField::Mode
2653 | ProviderConfigField::Wire
2654 | ProviderConfigField::AuthMode
2655 | ProviderConfigField::PathSuffix => toml::Value::String(value.to_string()),
2656 ProviderConfigField::ContextWindow => {
2657 toml::Value::Integer(i64::from(parse_context_window(value)?))
2658 }
2659 ProviderConfigField::InsecureSkipTlsVerify => toml::Value::Boolean(parse_bool(value)?),
2660 ProviderConfigField::HttpHeaders => toml::Value::Table(
2661 parse_http_headers(value)?
2662 .into_iter()
2663 .map(|(name, header)| (name, toml::Value::String(header)))
2664 .collect(),
2665 ),
2666 };
2667 self.custom_provider_table_mut(provider_id)?
2668 .insert(field.key().to_string(), toml_value);
2669 Ok(())
2670 }
2671
2672 fn get_custom_provider_value_with(
2673 &self,
2674 provider_id: &str,
2675 field_key: &str,
2676 render: fn(&ProviderConfigToml, ProviderConfigField) -> Option<String>,
2677 ) -> Option<String> {
2678 let table = self.providers.extras.get(provider_id)?.as_table()?;
2679 if field_key == "kind" {
2680 return table.get("kind")?.as_str().map(str::to_string);
2681 }
2682 let field = ProviderConfigField::parse(field_key)?;
2683 let config: ProviderConfigToml = toml::Value::Table(table.clone()).try_into().ok()?;
2684 render(&config, field)
2685 }
2686
2687 fn unset_custom_provider_value(&mut self, provider_id: &str, field_key: &str) {
2688 let Some(table) = self
2689 .providers
2690 .extras
2691 .get_mut(provider_id)
2692 .and_then(toml::Value::as_table_mut)
2693 else {
2694 return;
2695 };
2696 let leg = if field_key == "kind" {
2697 "kind"
2698 } else {
2699 ProviderConfigField::parse(field_key).map_or(field_key, |field| field.key())
2700 };
2701 table.remove(leg);
2702 }
2703
2704 fn bind_persisted_provider_id(&mut self, provider_id: &str) -> Result<()> {
2705 self.selected_provider_id = None;
2706 if self.provider != ProviderKind::Custom || provider_id == ProviderKind::Custom.as_str() {
2707 return Ok(());
2708 }
2709
2710 self.named_custom_provider_table(provider_id)?;
2711 self.selected_provider_id = Some(provider_id.to_string());
2712 Ok(())
2713 }
2714
2715 pub fn merge_project_overrides(&mut self, project: ConfigToml) {
2724 if project.default_text_model.is_some() {
2725 self.default_text_model = project.default_text_model;
2726 }
2727 if project.model.is_some() {
2728 self.model = project.model;
2729 }
2730 if project.output_mode.is_some() {
2731 self.output_mode = project.output_mode;
2732 }
2733 if project.verbosity.is_some() {
2734 self.verbosity = project.verbosity;
2735 }
2736 if project.log_level.is_some() {
2737 self.log_level = project.log_level;
2738 }
2739 if let Some(policy) = project.approval_policy
2740 && project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy)
2741 {
2742 self.approval_policy = Some(policy);
2743 }
2744 if let Some(mode) = project.sandbox_mode
2745 && project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode)
2746 {
2747 self.sandbox_mode = Some(mode);
2748 }
2749 if project.tools.is_some() {
2750 self.tools = project.tools;
2751 }
2752 for provider in provider::all_providers().iter().map(|p| p.kind()) {
2753 merge_project_provider_config(
2754 self.providers.for_provider_mut(provider),
2755 project.providers.for_provider(provider),
2756 );
2757 }
2758 }
2759
2760 #[must_use]
2761 pub fn get_value(&self, key: &str) -> Option<String> {
2762 if let Some((provider, field)) = parse_provider_config_key(key) {
2763 return get_provider_config_value(self.providers.for_provider(provider), field);
2764 }
2765 if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
2766 return self.get_custom_provider_value_with(
2767 provider_id,
2768 field_key,
2769 get_provider_config_value,
2770 );
2771 }
2772
2773 match key {
2774 "provider" => Some(self.provider_id().to_string()),
2775 "stream_chunk_timeout_secs" | "tui.stream_chunk_timeout_secs" => {
2776 Some(self.stream_chunk_timeout_secs().to_string())
2777 }
2778 "api_key" => self.api_key.clone(),
2779 "base_url" => self.base_url.clone(),
2780 "http_headers" => serialize_http_headers(&self.http_headers),
2781 "default_text_model" => self.default_text_model.clone(),
2782 "model" => self.model.clone(),
2783 "auth.mode" => self.auth_mode.clone(),
2784 "output_mode" => self.output_mode.clone(),
2785 "verbosity" => self.verbosity.clone(),
2786 "log_level" => self.log_level.clone(),
2787 "telemetry" => self.telemetry.map(|v| v.to_string()),
2788 "telemetry_endpoint" => self.telemetry_endpoint.clone(),
2789 "approval_policy" => self.approval_policy.clone(),
2790 "sandbox_mode" => self.sandbox_mode.clone(),
2791 "tools.always_load" => self.tools.as_ref().map(|tools| tools.always_load.join(",")),
2792 "hook_sinks.unix_socket_path" => self
2793 .hook_sinks
2794 .as_ref()
2795 .and_then(|sinks| sinks.unix_socket_path.as_ref())
2796 .map(|path| path.display().to_string()),
2797 _ => self.extras.get(key).map(toml::Value::to_string),
2798 }
2799 }
2800
2801 #[must_use]
2810 pub fn get_raw_string(&self, key: &str) -> Option<&str> {
2811 self.extras.get(key).and_then(toml::Value::as_str)
2812 }
2813
2814 #[must_use]
2815 pub fn get_display_value(&self, key: &str) -> Option<String> {
2816 if let Some((provider, field)) = parse_provider_config_key(key) {
2817 return get_provider_config_display_value(self.providers.for_provider(provider), field);
2818 }
2819 if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
2820 return self.get_custom_provider_value_with(
2821 provider_id,
2822 field_key,
2823 get_provider_config_display_value,
2824 );
2825 }
2826
2827 if key == "http_headers" {
2828 return serialize_http_headers_for_display(&self.http_headers);
2829 }
2830
2831 if let Some(value) = self.extras.get(key) {
2832 return Some(redact_toml_value_for_display(key, value));
2833 }
2834
2835 self.get_value(key).map(|value| {
2836 if is_sensitive_config_key(key) {
2837 redact_secret(&value)
2838 } else {
2839 value
2840 }
2841 })
2842 }
2843
2844 #[must_use]
2845 pub fn stream_chunk_timeout_secs(&self) -> u64 {
2846 let raw = self
2847 .extras
2848 .get("tui")
2849 .and_then(toml::Value::as_table)
2850 .and_then(|table| table.get("stream_chunk_timeout_secs"))
2851 .and_then(toml_value_as_u64)
2852 .or_else(|| {
2853 self.extras
2854 .get("tui.stream_chunk_timeout_secs")
2855 .and_then(toml_value_as_u64)
2856 })
2857 .or_else(|| {
2858 self.extras
2859 .get("stream_chunk_timeout_secs")
2860 .and_then(toml_value_as_u64)
2861 })
2862 .unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS);
2863 if raw == 0 {
2864 DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
2865 } else {
2866 raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS)
2867 }
2868 }
2869
2870 pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
2871 if let Some((provider, field)) = parse_provider_config_key(key) {
2872 return set_provider_config_value(self, provider, field, value);
2873 }
2874 if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
2875 return self.set_custom_provider_value(provider_id, field_key, value);
2876 }
2877
2878 match key {
2879 "provider" => {
2880 if let Some(provider) = ProviderKind::parse_config_identity(value) {
2881 self.provider = provider;
2882 self.selected_provider_id = None;
2883 } else {
2884 let provider_id = value.trim();
2885 self.named_custom_provider_table(provider_id)
2886 .with_context(|| {
2887 format!(
2888 "unknown provider '{value}': expected {} or a configured custom provider",
2889 ProviderKind::names_hint()
2890 )
2891 })?;
2892 self.provider = ProviderKind::Custom;
2893 self.selected_provider_id = Some(provider_id.to_string());
2894 }
2895 }
2896 "api_key" => self.api_key = Some(value.to_string()),
2897 "base_url" => self.base_url = Some(value.to_string()),
2898 "http_headers" => self.http_headers = parse_http_headers(value)?,
2899 "default_text_model" => self.default_text_model = Some(value.to_string()),
2900 "model" => self.model = Some(value.to_string()),
2901 "auth.mode" => self.auth_mode = Some(value.to_string()),
2902 "output_mode" => self.output_mode = Some(value.to_string()),
2903 "verbosity" => self.verbosity = Some(value.to_string()),
2904 "log_level" => self.log_level = Some(value.to_string()),
2905 "telemetry" => {
2906 self.telemetry = Some(parse_bool(value)?);
2907 }
2908 "telemetry_endpoint" => self.telemetry_endpoint = Some(value.to_string()),
2912 "approval_policy" => self.approval_policy = Some(value.to_string()),
2913 "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
2914 "hook_sinks.unix_socket_path" => {
2915 self.hook_sinks
2916 .get_or_insert_with(HookSinksToml::default)
2917 .unix_socket_path = Some(PathBuf::from(value));
2918 }
2919 _ => {
2920 self.extras
2921 .insert(key.to_string(), toml::Value::String(value.to_string()));
2922 }
2923 }
2924 Ok(())
2925 }
2926
2927 pub fn unset_value(&mut self, key: &str) -> Result<()> {
2928 if let Some((provider, field)) = parse_provider_config_key(key) {
2929 unset_provider_config_value(self, provider, field);
2930 return Ok(());
2931 }
2932 if let Some((provider_id, field_key)) = parse_custom_provider_config_key(key) {
2933 self.unset_custom_provider_value(provider_id, field_key);
2934 return Ok(());
2935 }
2936
2937 match key {
2938 "provider" => {
2939 self.provider = ProviderKind::Deepseek;
2940 self.selected_provider_id = None;
2941 }
2942 "api_key" => self.api_key = None,
2943 "base_url" => self.base_url = None,
2944 "http_headers" => self.http_headers.clear(),
2945 "default_text_model" => self.default_text_model = None,
2946 "model" => self.model = None,
2947 "auth.mode" => self.auth_mode = None,
2948 "output_mode" => self.output_mode = None,
2949 "verbosity" => self.verbosity = None,
2950 "log_level" => self.log_level = None,
2951 "telemetry" => self.telemetry = None,
2952 "telemetry_endpoint" => self.telemetry_endpoint = None,
2953 "approval_policy" => self.approval_policy = None,
2954 "sandbox_mode" => self.sandbox_mode = None,
2955 "hook_sinks.unix_socket_path" => {
2956 if let Some(sinks) = self.hook_sinks.as_mut() {
2957 sinks.unix_socket_path = None;
2958 }
2959 }
2960 _ => {
2961 self.extras.remove(key);
2962 }
2963 }
2964 Ok(())
2965 }
2966
2967 #[must_use]
2968 pub fn list_values(&self) -> BTreeMap<String, String> {
2969 let mut out = BTreeMap::new();
2970 out.insert("provider".to_string(), self.provider_id().to_string());
2971
2972 if let Some(v) = self.api_key.as_ref() {
2973 out.insert("api_key".to_string(), redact_secret(v));
2974 }
2975 if let Some(v) = self.base_url.as_ref() {
2976 out.insert("base_url".to_string(), v.clone());
2977 }
2978 if let Some(v) = serialize_http_headers_for_display(&self.http_headers) {
2979 out.insert("http_headers".to_string(), v);
2980 }
2981 if let Some(v) = self.default_text_model.as_ref() {
2982 out.insert("default_text_model".to_string(), v.clone());
2983 }
2984 if let Some(v) = self.model.as_ref() {
2985 out.insert("model".to_string(), v.clone());
2986 }
2987 if let Some(v) = self.auth_mode.as_ref() {
2988 out.insert("auth.mode".to_string(), v.clone());
2989 }
2990 if let Some(v) = self.output_mode.as_ref() {
2991 out.insert("output_mode".to_string(), v.clone());
2992 }
2993 if let Some(v) = self.verbosity.as_ref() {
2994 out.insert("verbosity".to_string(), v.clone());
2995 }
2996 if let Some(v) = self.log_level.as_ref() {
2997 out.insert("log_level".to_string(), v.clone());
2998 }
2999 if let Some(v) = self.telemetry {
3000 out.insert("telemetry".to_string(), v.to_string());
3001 }
3002 if let Some(v) = self.telemetry_endpoint.as_ref() {
3003 out.insert("telemetry_endpoint".to_string(), v.clone());
3004 }
3005 if let Some(v) = self.approval_policy.as_ref() {
3006 out.insert("approval_policy".to_string(), v.clone());
3007 }
3008 if let Some(v) = self.sandbox_mode.as_ref() {
3009 out.insert("sandbox_mode".to_string(), v.clone());
3010 }
3011 if let Some(v) = self
3012 .hook_sinks
3013 .as_ref()
3014 .and_then(|sinks| sinks.unix_socket_path.as_ref())
3015 {
3016 out.insert(
3017 "hook_sinks.unix_socket_path".to_string(),
3018 v.display().to_string(),
3019 );
3020 }
3021
3022 for provider in provider::all_providers().iter().map(|p| p.kind()) {
3023 insert_provider_config_values(
3024 &mut out,
3025 provider,
3026 self.providers.for_provider(provider),
3027 );
3028 }
3029
3030 for (k, v) in &self.extras {
3031 out.insert(k.clone(), redact_toml_value_for_display(k, v));
3032 }
3033 out
3034 }
3035
3036 #[must_use]
3043 pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
3044 let no_keyring = Secrets::new(std::sync::Arc::new(
3045 codewhale_secrets::InMemoryKeyringStore::new(),
3046 ));
3047 self.resolve_runtime_options_with_secrets(cli, &no_keyring)
3048 }
3049
3050 #[must_use]
3054 pub fn resolve_runtime_options_with_secrets(
3055 &self,
3056 cli: &CliRuntimeOverrides,
3057 secrets: &Secrets,
3058 ) -> ResolvedRuntimeOptions {
3059 let env = EnvRuntimeOverrides::load();
3060 let (provider, provider_source) = if let Some(provider) = cli.provider {
3061 (provider, ProviderSource::Cli)
3062 } else if let Some(provider) = env.provider {
3063 (
3064 provider,
3065 ProviderSource::Env(env.provider_source.unwrap_or("CODEWHALE_PROVIDER")),
3066 )
3067 } else {
3068 (self.provider, ProviderSource::Config)
3069 };
3070
3071 let mut provider_cfg = if provider == ProviderKind::Custom
3072 && matches!(provider_source, ProviderSource::Config)
3073 {
3074 self.named_custom_provider_config()
3075 .unwrap_or_else(|| self.providers.for_provider(provider).clone())
3076 } else {
3077 self.providers.for_provider(provider).clone()
3078 };
3079 if provider == ProviderKind::SiliconflowCN {
3080 let fb = &self.providers.siliconflow;
3081 if provider_cfg.api_key.is_none() {
3082 provider_cfg.api_key = fb.api_key.clone();
3083 }
3084 if provider_cfg.base_url.is_none() {
3085 provider_cfg.base_url = fb.base_url.clone();
3086 }
3087 if provider_cfg.model.is_none() {
3088 provider_cfg.model = fb.model.clone();
3089 }
3090 }
3091 let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
3092 .then(|| self.api_key.clone())
3093 .flatten();
3094 let root_base_url = matches!(
3100 provider,
3101 ProviderKind::Deepseek | ProviderKind::XiaomiMimo | ProviderKind::OpenaiCodex
3102 )
3103 .then(|| self.base_url.clone())
3104 .flatten();
3105 let auth_mode = cli
3106 .auth_mode
3107 .clone()
3108 .or_else(|| env.auth_mode.clone())
3109 .or_else(|| provider_cfg.auth_mode.clone())
3110 .or_else(|| self.auth_mode.clone());
3111 let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
3112 let cli_base_url = cli.base_url.clone();
3113 let env_base_url = env.base_url_for(provider);
3114 let file_base_url = provider_cfg.base_url.clone().or(root_base_url);
3115 let base_url_from_file =
3116 cli_base_url.is_none() && env_base_url.is_none() && file_base_url.is_some();
3117 let configured_base_url = cli_base_url.or(env_base_url).or(file_base_url);
3118 let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo {
3119 env.xiaomi_mimo_mode
3120 .clone()
3121 .or_else(|| provider_cfg.mode.clone())
3122 } else {
3123 None
3124 };
3125 let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo {
3126 xiaomi_mimo_env_api_key_for_runtime(
3127 xiaomi_mimo_mode.as_deref(),
3128 configured_base_url.as_deref(),
3129 )
3130 } else {
3131 None
3132 };
3133 let explicit_api_key_for_endpoint = cli
3134 .api_key
3135 .as_deref()
3136 .or(from_file.as_deref().filter(|value| {
3137 classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal
3138 }))
3139 .or(xiaomi_mimo_env_api_key.as_deref());
3140 let provider_wire = provider_cfg.wire.as_deref();
3141 let base_url = if provider == ProviderKind::XiaomiMimo {
3142 resolve_xiaomi_mimo_base_url(
3143 configured_base_url,
3144 explicit_api_key_for_endpoint,
3145 xiaomi_mimo_mode.as_deref(),
3146 )
3147 } else if is_modelstudio_family(provider) {
3148 resolve_modelstudio_base_url(
3149 configured_base_url,
3150 provider,
3151 provider_cfg.mode.as_deref(),
3152 provider_wire,
3153 )
3154 } else if matches!(
3155 provider,
3156 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic
3157 ) {
3158 resolve_minimax_base_url(configured_base_url, provider, provider_wire)
3159 } else if matches!(
3160 provider,
3161 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
3162 ) {
3163 resolve_deepseek_base_url(configured_base_url, provider, provider_wire)
3164 } else {
3165 configured_base_url.unwrap_or_else(|| match provider {
3166 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
3167 ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string(),
3168 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
3169 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
3170 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
3171 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
3172 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
3173 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
3174 ProviderKind::Orcarouter => DEFAULT_ORCAROUTER_BASE_URL.to_string(),
3175 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
3176 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
3177 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
3178 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
3179 ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL.to_string(),
3180 ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL.to_string(),
3181 ProviderKind::Moonshot => {
3182 if auth_mode
3183 .as_deref()
3184 .is_some_and(auth_mode_uses_kimi_imported_token)
3185 {
3186 DEFAULT_KIMI_CODE_BASE_URL.to_string()
3187 } else {
3188 DEFAULT_MOONSHOT_BASE_URL.to_string()
3189 }
3190 }
3191 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
3192 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
3193 ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
3194 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(),
3195 ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL.to_string(),
3196 ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL.to_string(),
3197 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
3198 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(),
3199 ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL.to_string(),
3200 ProviderKind::Zai => DEFAULT_ZAI_BASE_URL.to_string(),
3201 ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL.to_string(),
3202 ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL.to_string(),
3203 ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string(),
3204 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL.to_string(),
3205 ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL.to_string(),
3206 ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL.to_string(),
3207 ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL.to_string(),
3208 ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL.to_string(),
3209 ProviderKind::Meta => DEFAULT_META_BASE_URL.to_string(),
3210 ProviderKind::Xai => DEFAULT_XAI_BASE_URL.to_string(),
3211 ProviderKind::Mistral => DEFAULT_MISTRAL_BASE_URL.to_string(),
3212 ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL.to_string(),
3213 ProviderKind::ModelstudioTokenPlan
3214 | ProviderKind::ModelstudioTokenPlanAnthropic
3215 | ProviderKind::ModelstudioCodingPlan
3216 | ProviderKind::ModelstudioCodingPlanAnthropic => {
3217 DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL.to_string()
3218 }
3219 ProviderKind::Custom => provider.provider().default_base_url().to_string(),
3223 })
3224 };
3225 let uses_kimi_imported_token = provider == ProviderKind::Moonshot
3233 && auth_mode
3234 .as_deref()
3235 .is_some_and(auth_mode_uses_kimi_imported_token);
3236 let auth_disabled = auth_mode_disables_api_key(auth_mode.as_deref());
3237 let custom_endpoint = provider_preserves_custom_base_url_model(provider, &base_url);
3238 let (api_key, api_key_source) = if auth_disabled {
3239 (None, None)
3240 } else if let Some(value) = cli.api_key.clone() {
3241 (Some(value), Some(RuntimeApiKeySource::Cli))
3242 } else if uses_kimi_imported_token && !custom_endpoint {
3243 (None, None)
3244 } else if (!custom_endpoint || base_url_from_file)
3245 && let Some(value) = from_file.clone().filter(|value| {
3246 classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal
3247 })
3248 {
3249 (Some(value), Some(RuntimeApiKeySource::ConfigFile))
3250 } else if !custom_endpoint
3251 && let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty())
3252 {
3253 (Some(value), Some(RuntimeApiKeySource::Env))
3254 } else if custom_endpoint {
3255 (None, None)
3256 } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
3257 match env_api_key_for_provider(provider) {
3258 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
3259 None => (None, None),
3260 }
3261 } else {
3262 match secrets.resolve_with_source(provider.secret_store_slot()) {
3263 Some((value, source)) => {
3264 let source = match source {
3265 SecretSource::Keyring => RuntimeApiKeySource::Keyring,
3266 SecretSource::Env => RuntimeApiKeySource::Env,
3267 };
3268 (Some(value), Some(source))
3269 }
3270 None => match env_api_key_for_provider(provider) {
3271 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
3272 None => (None, None),
3273 },
3274 }
3275 };
3276
3277 let env_provider_model = env.model_for(provider, &base_url);
3278 let root_default_model = self
3291 .default_text_model
3292 .clone()
3293 .filter(|model| !root_default_model_is_foreign_to_provider(provider, model, &base_url));
3294 let model_source = if cli.model.is_some() {
3297 ModelSource::Cli
3298 } else if env.model.is_some() || env_provider_model.is_some() {
3299 ModelSource::Env
3300 } else if provider_cfg.model.is_some() {
3301 ModelSource::ProviderConfig
3302 } else if root_default_model.is_some() {
3303 ModelSource::RootDefaultTextModel
3304 } else if self.model.is_some() {
3305 ModelSource::RootModel
3306 } else {
3307 ModelSource::ProviderDefault
3308 };
3309 let explicit_model = model_source.is_explicit();
3310 let model = cli
3311 .model
3312 .clone()
3313 .or_else(|| env.model.clone())
3314 .or(env_provider_model)
3315 .or_else(|| provider_cfg.model.clone())
3316 .or(root_default_model)
3317 .or_else(|| self.model.clone())
3318 .unwrap_or_else(|| {
3319 if provider == ProviderKind::Moonshot
3320 && (auth_mode
3321 .as_deref()
3322 .is_some_and(auth_mode_uses_kimi_imported_token)
3323 || moonshot_base_url_uses_kimi_code(&base_url))
3324 {
3325 DEFAULT_KIMI_CODE_MODEL.to_string()
3326 } else {
3327 default_model_for_provider(provider).to_string()
3328 }
3329 });
3330 let model = if provider == ProviderKind::OpencodeGo {
3331 normalize_model_for_provider(provider, &model)
3336 } else if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
3337 model.trim().to_string()
3338 } else {
3339 normalize_model_for_provider(provider, &model)
3340 };
3341
3342 let mut http_headers = self.http_headers.clone();
3343 http_headers.extend(provider_cfg.http_headers.clone());
3344 if let Some(env_headers) = env.http_headers {
3345 http_headers.extend(env_headers);
3346 }
3347 http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
3348 if auth_disabled {
3349 http_headers.retain(|name, _| !is_upstream_auth_header(name));
3350 }
3351
3352 let output_mode = cli
3353 .output_mode
3354 .clone()
3355 .or_else(|| env.output_mode.clone())
3356 .or_else(|| self.output_mode.clone());
3357 let log_level = cli
3358 .log_level
3359 .clone()
3360 .or_else(|| env.log_level.clone())
3361 .or_else(|| self.log_level.clone());
3362 let telemetry_allowed = cli
3363 .telemetry
3364 .or(env.telemetry)
3365 .or(self.telemetry)
3366 .unwrap_or(true);
3367 let telemetry_persisted_off = self.telemetry == Some(false);
3378 let telemetry = telemetry_allowed
3384 && env.telemetry != Some(false)
3385 && !env.telemetry_env_invalid
3386 && !env.telemetry_floor
3387 && !telemetry_persisted_off;
3388 let telemetry_explicit_off = telemetry_persisted_off;
3396 let telemetry_endpoint = match env
3406 .telemetry_endpoint
3407 .clone()
3408 .or_else(|| self.telemetry_endpoint.clone())
3409 {
3410 Some(configured) if configured.trim().is_empty() => None,
3411 Some(configured) => Some(configured),
3412 None => Some(DEFAULT_TELEMETRY_ENDPOINT.to_string()),
3413 };
3414 let approval_policy = cli
3415 .approval_policy
3416 .clone()
3417 .or_else(|| env.approval_policy.clone())
3418 .or_else(|| self.approval_policy.clone());
3419 let sandbox_mode = cli
3420 .sandbox_mode
3421 .clone()
3422 .or_else(|| env.sandbox_mode.clone())
3423 .or_else(|| self.sandbox_mode.clone());
3424 let yolo = cli.yolo.or(env.yolo);
3425 let verbosity = cli
3426 .verbosity
3427 .clone()
3428 .or_else(|| env.verbosity.clone())
3429 .or_else(|| self.verbosity.clone());
3430
3431 ResolvedRuntimeOptions {
3432 provider,
3433 provider_source,
3434 model,
3435 model_source,
3436 api_key,
3437 api_key_source,
3438 base_url,
3439 auth_mode,
3440 insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false),
3441 output_mode,
3442 log_level,
3443 telemetry,
3444 telemetry_explicit_off,
3445 telemetry_endpoint,
3446 approval_policy,
3447 sandbox_mode,
3448 yolo,
3449 verbosity,
3450 http_headers,
3451 }
3452 }
3453}
3454
3455fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
3456 if source.model.is_some() {
3457 target.model = source.model.clone();
3458 }
3459}
3460
3461pub const DEFAULT_TELEMETRY_ENDPOINT: &str = "https://telemetry.codewhale.net/v1/telemetry";
3477
3478pub const TELEMETRY_FLOOR_ENV: &str = "CODEWHALE_TELEMETRY_FLOOR";
3483
3484#[must_use]
3497pub fn telemetry_floor_in_force() -> bool {
3498 if let Ok(raw) = std::env::var(TELEMETRY_FLOOR_ENV)
3499 && let Ok(declared) = parse_bool(&raw)
3500 {
3501 return declared;
3502 }
3503 let Ok(raw) =
3504 std::env::var("CODEWHALE_TELEMETRY").or_else(|_| std::env::var("DEEPSEEK_TELEMETRY"))
3505 else {
3506 return false;
3507 };
3508 !matches!(parse_bool(&raw), Ok(true))
3509}
3510
3511#[must_use]
3512pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
3513 let Some(project_rank) = approval_policy_rank(project) else {
3514 return false;
3515 };
3516 match current.and_then(approval_policy_rank) {
3517 Some(current_rank) => project_rank >= current_rank,
3518 None => project_rank >= 2,
3519 }
3520}
3521
3522#[must_use]
3523pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
3524 let normalized_project = project.trim().to_ascii_lowercase();
3525 if normalized_project == "external-sandbox" {
3526 return current
3527 .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
3528 .unwrap_or(false);
3529 }
3530
3531 let Some(project_rank) = sandbox_mode_rank(project) else {
3532 return false;
3533 };
3534 match current.and_then(sandbox_mode_rank) {
3535 Some(current_rank) => project_rank >= current_rank,
3536 None => project_rank >= 2,
3537 }
3538}
3539
3540fn approval_policy_rank(value: &str) -> Option<u8> {
3541 match value.trim().to_ascii_lowercase().as_str() {
3542 "auto" => Some(0),
3543 "suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
3544 "never" | "deny" | "denied" => Some(2),
3545 _ => None,
3546 }
3547}
3548
3549fn sandbox_mode_rank(value: &str) -> Option<u8> {
3550 match value.trim().to_ascii_lowercase().as_str() {
3551 "danger-full-access" => Some(0),
3552 "external-sandbox" => Some(0),
3553 "workspace-write" => Some(1),
3554 "read-only" => Some(2),
3555 _ => None,
3556 }
3557}
3558
3559#[derive(Debug, Clone)]
3568pub enum ProjectConfigOutcome {
3569 Missing,
3571 Loaded(Box<ConfigToml>),
3573 Invalid {
3576 path: PathBuf,
3578 reason: String,
3580 },
3581}
3582
3583impl ProjectConfigOutcome {
3584 #[must_use]
3586 pub fn into_config(self) -> Option<ConfigToml> {
3587 match self {
3588 Self::Loaded(config) => Some(*config),
3589 Self::Missing | Self::Invalid { .. } => None,
3590 }
3591 }
3592
3593 #[must_use]
3595 pub fn invalid(&self) -> Option<(&Path, &str)> {
3596 match self {
3597 Self::Invalid { path, reason } => Some((path.as_path(), reason.as_str())),
3598 Self::Missing | Self::Loaded(_) => None,
3599 }
3600 }
3601}
3602
3603pub fn load_project_config_outcome(workspace: &Path) -> ProjectConfigOutcome {
3609 for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
3610 let path = workspace.join(dir).join(CONFIG_FILE_NAME);
3611 if !project_config_candidate_exists(&path) {
3612 continue;
3613 }
3614 let raw = match read_checked_config_file(&path) {
3615 Ok(raw) => raw,
3616 Err(e) => {
3617 tracing::warn!("Failed to read project config {}: {e:#}", path.display());
3618 return ProjectConfigOutcome::Invalid {
3619 path,
3620 reason: format!("could not be read: {e}"),
3621 };
3622 }
3623 };
3624 match toml::from_str::<ConfigToml>(&raw) {
3625 Ok(config) => {
3626 let raw_provider = toml::from_str::<toml::Value>(&raw)
3627 .ok()
3628 .and_then(|document| document.get("provider").cloned())
3629 .and_then(|provider| provider.as_str().map(str::to_string));
3630 if config.provider == ProviderKind::Custom
3631 && raw_provider.as_deref() != Some(ProviderKind::Custom.as_str())
3632 {
3633 tracing::warn!(
3637 "Failed to parse project config {}; file contents were omitted",
3638 quote_os_path(&path)
3639 );
3640 return ProjectConfigOutcome::Invalid {
3641 path,
3642 reason: match raw_provider {
3643 Some(name) => format!("unknown provider '{name}'"),
3644 None => "unknown provider".to_string(),
3645 },
3646 };
3647 }
3648 return ProjectConfigOutcome::Loaded(Box::new(config));
3649 }
3650 Err(err) => {
3651 tracing::warn!(
3652 "Failed to parse project config {}; file contents were omitted",
3653 quote_os_path(&path)
3654 );
3655 return ProjectConfigOutcome::Invalid {
3656 path,
3657 reason: err.message().to_string(),
3660 };
3661 }
3662 }
3663 }
3664 ProjectConfigOutcome::Missing
3665}
3666
3667pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
3675 load_project_config_outcome(workspace).into_config()
3676}
3677
3678fn project_config_candidate_exists(path: &Path) -> bool {
3679 fs::symlink_metadata(path).is_ok_and(|metadata| {
3680 let file_type = metadata.file_type();
3681 file_type.is_file() || file_type.is_symlink()
3682 })
3683}
3684
3685fn deepseek_family_model_id(model: &str) -> Option<String> {
3692 let trimmed = model.trim();
3693 if trimmed.is_empty() {
3694 return None;
3695 }
3696 match trimmed.to_ascii_lowercase().as_str() {
3697 "pro" | "deepseek-v4pro" => return Some("deepseek-v4-pro".to_string()),
3698 "flash" | "deepseek-v4flash" => return Some("deepseek-v4-flash".to_string()),
3699 _ => {}
3700 }
3701
3702 let normalized = trimmed.to_ascii_lowercase();
3703 if !normalized.starts_with("deepseek") && !normalized.contains("/deepseek") {
3704 return None;
3705 }
3706 if trimmed
3707 .chars()
3708 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))
3709 {
3710 return Some(trimmed.to_string());
3711 }
3712 None
3713}
3714
3715fn provider_passes_model_through(provider: ProviderKind) -> bool {
3720 matches!(
3721 provider,
3722 ProviderKind::Openai
3723 | ProviderKind::Atlascloud
3724 | ProviderKind::WanjieArk
3725 | ProviderKind::Volcengine
3726 | ProviderKind::XiaomiMimo
3727 | ProviderKind::Moonshot
3728 | ProviderKind::Qianfan
3729 | ProviderKind::Openmodel
3730 | ProviderKind::Ollama
3731 | ProviderKind::Huggingface
3732 | ProviderKind::Meta
3733 | ProviderKind::Xai
3734 | ProviderKind::Telecomjs
3735 | ProviderKind::ModelstudioTokenPlan
3736 | ProviderKind::ModelstudioTokenPlanAnthropic
3737 | ProviderKind::ModelstudioCodingPlan
3738 | ProviderKind::ModelstudioCodingPlanAnthropic
3739 | ProviderKind::Custom
3740 )
3741}
3742
3743fn root_default_model_is_foreign_to_provider(
3755 provider: ProviderKind,
3756 model: &str,
3757 base_url: &str,
3758) -> bool {
3759 if deepseek_family_model_id(model).is_none() {
3763 return false;
3764 }
3765 if matches!(
3767 provider,
3768 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
3769 ) {
3770 return false;
3771 }
3772 if provider_preserves_custom_base_url_model(provider, base_url) {
3775 return false;
3776 }
3777 if matches!(
3781 provider,
3782 ProviderKind::Xai | ProviderKind::Openai | ProviderKind::Moonshot
3783 ) {
3784 return true;
3785 }
3786 if provider_passes_model_through(provider) {
3789 return false;
3790 }
3791 if matches!(
3794 provider,
3795 ProviderKind::NvidiaNim
3796 | ProviderKind::Openrouter
3797 | ProviderKind::Orcarouter
3798 | ProviderKind::Novita
3799 | ProviderKind::Fireworks
3800 | ProviderKind::Siliconflow
3801 | ProviderKind::SiliconflowCN
3802 | ProviderKind::Deepinfra
3803 | ProviderKind::Together
3804 | ProviderKind::Sglang
3805 | ProviderKind::Vllm
3806 | ProviderKind::Volcengine
3807 | ProviderKind::Atlascloud
3808 | ProviderKind::OpencodeGo
3809 | ProviderKind::WanjieArk
3810 ) {
3811 return false;
3812 }
3813 true
3816}
3817
3818#[must_use]
3826pub fn known_foreign_model_owner(
3827 provider: ProviderKind,
3828 model: &str,
3829 base_url: &str,
3830) -> Option<ProviderKind> {
3831 root_default_model_is_foreign_to_provider(provider, model, base_url)
3832 .then_some(ProviderKind::Deepseek)
3833}
3834
3835fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
3836 if matches!(provider, ProviderKind::OpencodeGo) {
3837 return opencode_go_chat_model_id(model)
3842 .map(str::to_string)
3843 .unwrap_or_else(|| model.trim().to_string());
3844 }
3845 if matches!(provider, ProviderKind::XiaomiMimo)
3846 && let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
3847 {
3848 return canonical.to_string();
3849 }
3850 if matches!(
3851 provider,
3852 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic
3853 ) && let Some(canonical) = canonical_minimax_model_id(model)
3854 {
3855 return canonical.to_string();
3856 }
3857 if matches!(provider, ProviderKind::Zai)
3858 && let Some(canonical) = canonical_zai_model_id(model)
3859 {
3860 return canonical.to_string();
3861 }
3862
3863 if matches!(
3864 provider,
3865 ProviderKind::Atlascloud
3866 | ProviderKind::WanjieArk
3867 | ProviderKind::Volcengine
3868 | ProviderKind::XiaomiMimo
3869 | ProviderKind::Zai
3870 | ProviderKind::Stepfun
3871 | ProviderKind::Minimax
3872 | ProviderKind::MinimaxAnthropic
3873 | ProviderKind::Qianfan
3874 | ProviderKind::Ollama
3875 | ProviderKind::Meta
3876 | ProviderKind::Xai
3877 ) {
3878 return model.to_string();
3879 }
3880
3881 let normalized = model.trim().to_ascii_lowercase();
3882 if provider == ProviderKind::Openrouter
3883 && let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
3884 {
3885 return canonical.to_string();
3886 }
3887 if provider == ProviderKind::Orcarouter
3888 && let Some(canonical) = canonical_orcarouter_recent_model_id(&normalized)
3889 {
3890 return canonical.to_string();
3891 }
3892 match (provider, normalized.as_str()) {
3893 (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
3894 DEFAULT_NVIDIA_NIM_MODEL.to_string()
3895 }
3896 (
3897 ProviderKind::NvidiaNim,
3898 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3899 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3900 ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
3901 (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
3902 DEFAULT_OPENROUTER_MODEL.to_string()
3903 }
3904 (
3905 ProviderKind::Openrouter,
3906 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3907 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3908 ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
3909 (ProviderKind::Orcarouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
3910 DEFAULT_ORCAROUTER_MODEL.to_string()
3911 }
3912 (
3913 ProviderKind::Orcarouter,
3914 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3915 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3916 ) => DEFAULT_ORCAROUTER_FLASH_MODEL.to_string(),
3917 (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
3918 DEFAULT_NOVITA_MODEL.to_string()
3919 }
3920 (
3921 ProviderKind::Novita,
3922 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3923 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3924 ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
3925 (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
3926 DEFAULT_FIREWORKS_MODEL.to_string()
3927 }
3928 (
3929 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
3930 "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
3931 ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
3932 (
3933 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
3934 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
3935 ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
3936 (
3937 ProviderKind::Arcee,
3938 "trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking",
3939 ) => DEFAULT_ARCEE_MODEL.to_string(),
3940 (ProviderKind::Arcee, "trinity-mini" | "arcee-trinity-mini") => {
3941 ARCEE_TRINITY_MINI_MODEL.to_string()
3942 }
3943 (ProviderKind::Arcee, "arcee-trinity-large-preview") => {
3944 ARCEE_TRINITY_LARGE_PREVIEW_MODEL.to_string()
3945 }
3946 (
3947 ProviderKind::Moonshot,
3948 "kimi"
3949 | "kimi-k2"
3950 | "kimi-k2.7"
3951 | "kimi-k2-7"
3952 | "kimi-k2.7-code"
3953 | "kimi-k2-7-code"
3954 | "kimi-code"
3955 | "moonshot-kimi-k2.7-code",
3956 ) => DEFAULT_MOONSHOT_MODEL.to_string(),
3957 (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
3958 MOONSHOT_KIMI_K2_6_MODEL.to_string()
3959 }
3960 (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
3961 DEFAULT_SGLANG_MODEL.to_string()
3962 }
3963 (
3964 ProviderKind::Sglang,
3965 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3966 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3967 ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
3968 (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
3969 DEFAULT_VLLM_MODEL.to_string()
3970 }
3971 (
3972 ProviderKind::Vllm,
3973 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3974 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3975 ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
3976 (ProviderKind::Huggingface, "deepseek-v4-pro" | "deepseek-v4pro") => {
3977 DEFAULT_HUGGINGFACE_MODEL.to_string()
3978 }
3979 (
3980 ProviderKind::Huggingface,
3981 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3982 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3983 ) => DEFAULT_HUGGINGFACE_FLASH_MODEL.to_string(),
3984 (ProviderKind::Together, "deepseek-v4-pro" | "deepseek-v4pro") => {
3985 DEFAULT_TOGETHER_MODEL.to_string()
3986 }
3987 (
3988 ProviderKind::Together,
3989 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3990 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3991 ) => DEFAULT_TOGETHER_FLASH_MODEL.to_string(),
3992 (ProviderKind::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
3993 DEFAULT_DEEPINFRA_MODEL.to_string()
3994 }
3995 (
3996 ProviderKind::Deepinfra,
3997 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
3998 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
3999 ) => DEFAULT_DEEPINFRA_FLASH_MODEL.to_string(),
4000 _ => model.to_string(),
4001 }
4002}
4003
4004pub const OPENCODE_GO_CHAT_MODELS: &[&str] = &[
4017 DEFAULT_OPENCODE_GO_MODEL,
4018 OPENCODE_GO_GROK_4_5_MODEL,
4019 OPENCODE_GO_GLM_5_2_MODEL,
4020 OPENCODE_GO_GLM_5_1_MODEL,
4021 OPENCODE_GO_KIMI_K3_MODEL,
4022 OPENCODE_GO_KIMI_K2_7_CODE_MODEL,
4023 OPENCODE_GO_KIMI_K2_6_MODEL,
4024 OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL,
4025 OPENCODE_GO_MIMO_V2_5_MODEL,
4026 OPENCODE_GO_MIMO_V2_5_PRO_MODEL,
4027];
4028
4029#[must_use]
4034pub fn opencode_go_chat_model_id(model: &str) -> Option<&'static str> {
4035 let normalized = model.trim().to_ascii_lowercase().replace(['_', ' '], "-");
4036 let normalized = normalized
4037 .strip_prefix("opencode-go/")
4038 .unwrap_or(&normalized);
4039 let familiar_alias = match normalized {
4040 "grok-4-5" => Some(OPENCODE_GO_GROK_4_5_MODEL),
4041 "glm-5-2" => Some(OPENCODE_GO_GLM_5_2_MODEL),
4042 "glm-5-1" => Some(OPENCODE_GO_GLM_5_1_MODEL),
4043 "kimi-k2-7-code" => Some(OPENCODE_GO_KIMI_K2_7_CODE_MODEL),
4044 "kimi-k2-6" => Some(OPENCODE_GO_KIMI_K2_6_MODEL),
4045 "deepseek-v4pro" => Some(DEFAULT_OPENCODE_GO_MODEL),
4046 "deepseek-v4flash" => Some(OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL),
4047 "mimo-v2-5" => Some(OPENCODE_GO_MIMO_V2_5_MODEL),
4048 "mimo-v2-5-pro" => Some(OPENCODE_GO_MIMO_V2_5_PRO_MODEL),
4049 _ => None,
4050 };
4051 familiar_alias.or_else(|| {
4052 OPENCODE_GO_CHAT_MODELS
4053 .iter()
4054 .copied()
4055 .find(|candidate| *candidate == normalized)
4056 })
4057}
4058
4059fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> {
4060 let normalized = model.trim().to_ascii_lowercase();
4061 let normalized = normalized.replace(['_', ' '], "-");
4062 match normalized.as_str() {
4063 "mimo"
4064 | DEFAULT_XIAOMI_MIMO_MODEL
4065 | "mimo-v2-5-pro"
4066 | "xiaomi-mimo-v2.5-pro"
4067 | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL),
4068 XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL
4069 | "mimo-v2-5-pro-ultraspeed"
4070 | "xiaomi-mimo-v2.5-pro-ultraspeed"
4071 | "xiaomi-mimo-v2-5-pro-ultraspeed"
4072 | "ultraspeed"
4073 | "pro-ultraspeed" => Some(XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL),
4074 "omni"
4075 | "mimo-omni"
4076 | "v2.5-omni"
4077 | "v25-omni"
4078 | "mimo-v2.5"
4079 | "mimo-v25"
4080 | "mimo-v2-5"
4081 | "mimo-v2.5-omni"
4082 | "mimo-v25-omni"
4083 | "mimo-v2-5-omni"
4084 | "xiaomi-mimo-v2.5"
4085 | "xiaomi-mimo-v2-5"
4086 | "xiaomi-mimo-v2.5-omni"
4087 | "xiaomi-mimo-v2-5-omni" => Some(XIAOMI_MIMO_V2_5_OMNI_MODEL),
4088 "asr" | "mimo-asr" | "mimo-v2.5-asr" | "speech-to-text" | "transcribe" => {
4089 Some(XIAOMI_MIMO_ASR_MODEL)
4090 }
4091 "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => {
4092 Some(XIAOMI_MIMO_TTS_MODEL)
4093 }
4094 "mimo-tts-voicedesign"
4095 | "mimo-voice-design"
4096 | "mimo-v25-tts-voicedesign"
4097 | "mimo-v2.5-tts-voicedesign"
4098 | "voicedesign"
4099 | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL),
4100 "mimo-tts-voiceclone"
4101 | "mimo-voice-clone"
4102 | "mimo-v25-tts-voiceclone"
4103 | "mimo-v2.5-tts-voiceclone"
4104 | "voiceclone"
4105 | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL),
4106 "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL),
4107 _ => None,
4108 }
4109}
4110
4111fn canonical_minimax_model_id(model: &str) -> Option<&'static str> {
4112 let normalized = model.trim().to_ascii_lowercase();
4113 let normalized = normalized.replace(['_', ' '], "-");
4114 match normalized.as_str() {
4115 "minimax" | "minimax-m3" | "minimax-m-3" | "minimax-m-3-thinking" => {
4116 Some(DEFAULT_MINIMAX_MODEL)
4117 }
4118 "minimax-m2.7" | "minimax-m2-7" | "minimax-m-2.7" | "minimax-m-2-7" => {
4119 Some(MINIMAX_M2_7_MODEL)
4120 }
4121 "minimax-m2.7-highspeed"
4122 | "minimax-m2-7-highspeed"
4123 | "minimax-m-2.7-highspeed"
4124 | "minimax-m-2-7-highspeed" => Some(MINIMAX_M2_7_HIGHSPEED_MODEL),
4125 "minimax-m2.5" | "minimax-m2-5" | "minimax-m-2.5" | "minimax-m-2-5" => {
4126 Some(MINIMAX_M2_5_MODEL)
4127 }
4128 "minimax-m2.5-highspeed"
4129 | "minimax-m2-5-highspeed"
4130 | "minimax-m-2.5-highspeed"
4131 | "minimax-m-2-5-highspeed" => Some(MINIMAX_M2_5_HIGHSPEED_MODEL),
4132 "minimax-m2.1" | "minimax-m2-1" | "minimax-m-2.1" | "minimax-m-2-1" => {
4133 Some(MINIMAX_M2_1_MODEL)
4134 }
4135 "minimax-m2.1-highspeed"
4136 | "minimax-m2-1-highspeed"
4137 | "minimax-m-2.1-highspeed"
4138 | "minimax-m-2-1-highspeed" => Some(MINIMAX_M2_1_HIGHSPEED_MODEL),
4139 "minimax-m2" | "minimax-m-2" => Some(MINIMAX_M2_MODEL),
4140 _ => None,
4141 }
4142}
4143
4144fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
4145 let normalized = model.trim().to_ascii_lowercase();
4146 let normalized = normalized.replace(['_', ' '], "-");
4147 match normalized.as_str() {
4148 "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
4149 "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(DEFAULT_ZAI_MODEL),
4150 "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => Some(ZAI_GLM_5_3_MODEL),
4153 "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
4154 _ => None,
4155 }
4156}
4157
4158fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
4159 let normalized = model.trim().to_ascii_lowercase();
4160 let normalized = normalized.replace(['_', ' '], "-");
4161 match normalized.as_str() {
4162 OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
4163 | "trinity"
4164 | "trinity-large-thinking"
4165 | "arcee-trinity"
4166 | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
4167 OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
4168 Some(OPENROUTER_GEMMA_4_31B_MODEL)
4169 }
4170 OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
4171 Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
4172 }
4173 OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
4174 Some(OPENROUTER_GLM_5_1_MODEL)
4175 }
4176 OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
4177 Some(OPENROUTER_GLM_5_2_MODEL)
4178 }
4179 OPENROUTER_GLM_5_3_MODEL | "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => {
4180 Some(OPENROUTER_GLM_5_3_MODEL)
4181 }
4182 OPENROUTER_KIMI_K2_7_CODE_MODEL
4183 | "kimi"
4184 | "kimi-k2"
4185 | "kimi-k2.7"
4186 | "kimi-k2-7"
4187 | "kimi-k2.7-code"
4188 | "kimi-k2-7-code"
4189 | "kimi-code"
4190 | "moonshot-kimi-k2.7-code"
4191 | "openrouter-kimi-k2.7-code" => Some(OPENROUTER_KIMI_K2_7_CODE_MODEL),
4192 OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
4193 Some(OPENROUTER_KIMI_K2_6_MODEL)
4194 }
4195 OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => {
4196 Some(OPENROUTER_MINIMAX_M3_MODEL)
4197 }
4198 OPENROUTER_MINIMAX_M2_7_MODEL
4199 | "minimax-2.7"
4200 | "minimax-2-7"
4201 | "minimax-m2.7"
4202 | "minimax-m2-7"
4203 | "minimax-m-2.7"
4204 | "minimax-m-2-7" => Some(OPENROUTER_MINIMAX_M2_7_MODEL),
4205 OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
4206 | "nemotron-3-nano-omni"
4207 | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
4208 OPENROUTER_QWEN_3_6_35B_A3B_MODEL
4209 | "qwen3.6-35b-a3b"
4210 | "qwen-3.6-35b-a3b"
4211 | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
4212 OPENROUTER_QWEN_3_6_FLASH_MODEL | "qwen3.6-flash" | "qwen-3.6-flash" => {
4213 Some(OPENROUTER_QWEN_3_6_FLASH_MODEL)
4214 }
4215 OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL
4216 | "qwen3.6-max-preview"
4217 | "qwen-3.6-max-preview"
4218 | "qwen-max-preview" => Some(OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
4219 OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
4220 Some(OPENROUTER_QWEN_3_6_27B_MODEL)
4221 }
4222 OPENROUTER_QWEN_3_6_PLUS_MODEL | "qwen3.6-plus" | "qwen-3.6-plus" => {
4223 Some(OPENROUTER_QWEN_3_6_PLUS_MODEL)
4224 }
4225 OPENROUTER_QWEN_3_7_PLUS_MODEL | "qwen3.7-plus" | "qwen-3.7-plus" => {
4226 Some(OPENROUTER_QWEN_3_7_PLUS_MODEL)
4227 }
4228 OPENROUTER_QWEN_3_7_MAX_MODEL | "qwen3.7-max" | "qwen-3.7-max" => {
4229 Some(OPENROUTER_QWEN_3_7_MAX_MODEL)
4230 }
4231 OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
4232 Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
4233 }
4234 OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
4235 | "mimo-v2.5-pro"
4236 | "mimo-v2-5-pro"
4237 | "xiaomi-mimo-v2.5-pro"
4238 | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
4239 OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
4240 | "mimo-v2.5"
4241 | "mimo-v2-5"
4242 | "xiaomi-mimo-v2.5"
4243 | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
4244 _ => None,
4245 }
4246}
4247
4248fn canonical_orcarouter_recent_model_id(model: &str) -> Option<&'static str> {
4256 let normalized = model.trim().to_ascii_lowercase();
4257 let normalized = normalized.replace(['_', ' '], "-");
4258 match normalized.as_str() {
4259 ORCAROUTER_AUTO_MODEL | "auto" | "orcarouter-auto" | "orca-auto" => {
4260 Some(ORCAROUTER_AUTO_MODEL)
4261 }
4262 _ => None,
4263 }
4264}
4265
4266fn default_model_for_provider(provider: ProviderKind) -> &'static str {
4267 match provider {
4268 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
4269 ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_MODEL,
4270 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
4271 ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
4272 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
4273 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
4274 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
4275 ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
4276 ProviderKind::Orcarouter => DEFAULT_ORCAROUTER_MODEL,
4277 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
4278 ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
4279 ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
4280 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_MODEL,
4281 ProviderKind::Arcee => DEFAULT_ARCEE_MODEL,
4282 ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
4283 ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
4284 ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
4285 ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
4286 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
4287 ProviderKind::Together => DEFAULT_TOGETHER_MODEL,
4288 ProviderKind::Qianfan => DEFAULT_QIANFAN_MODEL,
4289 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL,
4290 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_MODEL,
4291 ProviderKind::Openmodel => DEFAULT_OPENMODEL_MODEL,
4292 ProviderKind::Zai => DEFAULT_ZAI_MODEL,
4293 ProviderKind::Stepfun => DEFAULT_STEPFUN_MODEL,
4294 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_MODEL,
4295 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
4296 ProviderKind::Sakana => DEFAULT_SAKANA_MODEL,
4297 ProviderKind::LongCat => DEFAULT_LONGCAT_MODEL,
4298 ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_MODEL,
4299 ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_MODEL,
4300 ProviderKind::Meta => DEFAULT_META_MODEL,
4301 ProviderKind::Xai => DEFAULT_XAI_MODEL,
4302 ProviderKind::Mistral => DEFAULT_MISTRAL_MODEL,
4303 ProviderKind::Telecomjs => DEFAULT_TELECOMJS_MODEL,
4304 ProviderKind::ModelstudioTokenPlan
4305 | ProviderKind::ModelstudioTokenPlanAnthropic
4306 | ProviderKind::ModelstudioCodingPlan
4307 | ProviderKind::ModelstudioCodingPlanAnthropic => DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL,
4308 ProviderKind::Custom => provider.provider().default_model(),
4310 }
4311}
4312
4313fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
4314 match provider {
4315 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
4316 ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
4317 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
4318 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
4319 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
4320 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
4321 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
4322 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
4323 ProviderKind::Orcarouter => DEFAULT_ORCAROUTER_BASE_URL,
4324 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
4325 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
4326 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
4327 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
4328 ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL,
4329 ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL,
4330 ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
4331 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
4332 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
4333 ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
4334 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
4335 ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL,
4336 ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL,
4337 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
4338 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
4339 ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL,
4340 ProviderKind::Zai => DEFAULT_ZAI_BASE_URL,
4341 ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL,
4342 ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL,
4343 ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
4344 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
4345 ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL,
4346 ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL,
4347 ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL,
4348 ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL,
4349 ProviderKind::Meta => DEFAULT_META_BASE_URL,
4350 ProviderKind::Xai => DEFAULT_XAI_BASE_URL,
4351 ProviderKind::Mistral => DEFAULT_MISTRAL_BASE_URL,
4352 ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL,
4353 ProviderKind::ModelstudioTokenPlan => DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
4354 ProviderKind::ModelstudioTokenPlanAnthropic => MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL,
4355 ProviderKind::ModelstudioCodingPlan => DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL,
4356 ProviderKind::ModelstudioCodingPlanAnthropic => MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL,
4357 ProviderKind::Custom => provider.provider().default_base_url(),
4359 }
4360}
4361
4362fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
4363 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
4364 normalized == DEFAULT_KIMI_CODE_BASE_URL
4365 || normalized == "https://api.kimi.com/coding"
4366 || normalized.starts_with("https://api.kimi.com/coding/")
4367}
4368
4369fn wire_prefers_anthropic(kind: ProviderKind, wire: Option<&str>) -> bool {
4371 if matches!(
4372 kind,
4373 ProviderKind::DeepseekAnthropic
4374 | ProviderKind::MinimaxAnthropic
4375 | ProviderKind::ModelstudioTokenPlanAnthropic
4376 | ProviderKind::ModelstudioCodingPlanAnthropic
4377 ) {
4378 return true;
4379 }
4380 let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else {
4381 return false;
4382 };
4383 let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-");
4384 matches!(
4385 normalized.as_str(),
4386 "anthropic"
4387 | "anthropic-messages"
4388 | "messages"
4389 | "claude"
4390 | "anthropic-compatible"
4391 | "anthropic-compat"
4392 )
4393}
4394
4395fn modelstudio_mode_is_coding_plan(kind: ProviderKind, mode: Option<&str>) -> bool {
4396 if matches!(
4397 kind,
4398 ProviderKind::ModelstudioCodingPlan | ProviderKind::ModelstudioCodingPlanAnthropic
4399 ) {
4400 return true;
4401 }
4402 let Some(raw) = mode.map(str::trim).filter(|value| !value.is_empty()) else {
4403 return false;
4404 };
4405 let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-");
4406 matches!(
4407 normalized.as_str(),
4408 "coding-plan" | "coding" | "codingplan" | "dashscope-coding" | "code"
4409 )
4410}
4411
4412fn is_modelstudio_family(kind: ProviderKind) -> bool {
4413 matches!(
4414 kind,
4415 ProviderKind::ModelstudioTokenPlan
4416 | ProviderKind::ModelstudioTokenPlanAnthropic
4417 | ProviderKind::ModelstudioCodingPlan
4418 | ProviderKind::ModelstudioCodingPlanAnthropic
4419 )
4420}
4421
4422fn resolve_modelstudio_base_url(
4423 configured: Option<String>,
4424 kind: ProviderKind,
4425 mode: Option<&str>,
4426 wire: Option<&str>,
4427) -> String {
4428 if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
4429 return url;
4430 }
4431 let coding = modelstudio_mode_is_coding_plan(kind, mode);
4432 let anthropic = wire_prefers_anthropic(kind, wire);
4433 match (coding, anthropic) {
4434 (true, true) => MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL.to_string(),
4435 (true, false) => DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL.to_string(),
4436 (false, true) => MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL.to_string(),
4437 (false, false) => DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL.to_string(),
4438 }
4439}
4440
4441fn resolve_minimax_base_url(
4442 configured: Option<String>,
4443 kind: ProviderKind,
4444 wire: Option<&str>,
4445) -> String {
4446 if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
4447 return url;
4448 }
4449 if wire_prefers_anthropic(kind, wire) {
4450 DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string()
4451 } else {
4452 DEFAULT_MINIMAX_BASE_URL.to_string()
4453 }
4454}
4455
4456fn resolve_deepseek_base_url(
4457 configured: Option<String>,
4458 kind: ProviderKind,
4459 wire: Option<&str>,
4460) -> String {
4461 if let Some(url) = configured.filter(|value| !value.trim().is_empty()) {
4462 return url;
4463 }
4464 if wire_prefers_anthropic(kind, wire) {
4465 DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string()
4466 } else {
4467 DEFAULT_DEEPSEEK_BASE_URL.to_string()
4468 }
4469}
4470
4471fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
4472 let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
4473 if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
4474 return None;
4475 }
4476 Some(match normalized.as_str() {
4477 "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => {
4478 DEFAULT_XIAOMI_MIMO_BASE_URL
4479 }
4480 "token-plan-cn"
4481 | "token-plan-china"
4482 | "token-plan-mainland"
4483 | "token-plan-mainland-china"
4484 | "cn"
4485 | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
4486 "token-plan-sgp"
4487 | "token-plan-sg"
4488 | "token-plan-singapore"
4489 | "sgp"
4490 | "sg"
4491 | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
4492 "token-plan-ams"
4493 | "token-plan-eu"
4494 | "token-plan-europe"
4495 | "token-plan-amsterdam"
4496 | "ams"
4497 | "eu"
4498 | "europe"
4499 | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
4500 _ => DEFAULT_XIAOMI_MIMO_BASE_URL,
4501 })
4502}
4503
4504fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
4505 matches!(
4506 normalized_mode,
4507 "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
4508 )
4509}
4510
4511fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
4512 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
4513 normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
4514 || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL
4515 || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
4516}
4517
4518fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option<String> {
4519 candidates.iter().find_map(|name| {
4520 std::env::var(name)
4521 .ok()
4522 .filter(|value| !value.trim().is_empty())
4523 })
4524}
4525
4526fn xiaomi_mimo_env_api_key_for_runtime(
4527 mode: Option<&str>,
4528 base_url: Option<&str>,
4529) -> Option<String> {
4530 const TOKEN_PLAN_ENV_VARS: &[&str] =
4531 &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"];
4532 const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"];
4533
4534 let normalized_mode =
4535 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
4536 let standard_selected = normalized_mode
4537 .as_deref()
4538 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint)
4539 || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go);
4540 if standard_selected {
4541 return xiaomi_mimo_env_var(STANDARD_ENV_VARS);
4542 }
4543
4544 let token_plan_selected = normalized_mode
4545 .as_deref()
4546 .and_then(xiaomi_mimo_base_url_for_mode)
4547 .is_some()
4548 || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan);
4549 if token_plan_selected {
4550 return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS);
4551 }
4552
4553 xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS))
4554}
4555
4556fn resolve_xiaomi_mimo_base_url(
4557 configured: Option<String>,
4558 api_key: Option<&str>,
4559 mode: Option<&str>,
4560) -> String {
4561 let normalized_mode =
4562 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
4563 let uses_standard_mode = normalized_mode
4564 .as_deref()
4565 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint);
4566 let mode_base_url = normalized_mode
4567 .as_deref()
4568 .and_then(xiaomi_mimo_base_url_for_mode);
4569 let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key);
4570 match configured {
4571 Some(base_url) if uses_standard_mode => base_url,
4572 Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => {
4573 mode_base_url
4574 .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL)
4575 .to_string()
4576 }
4577 Some(base_url) => base_url,
4578 None => {
4579 if let Some(base_url) = mode_base_url {
4580 base_url.to_string()
4581 } else if uses_standard_mode {
4582 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
4583 } else if uses_token_plan || api_key.is_none() {
4584 DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()
4585 } else {
4586 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
4587 }
4588 }
4589 }
4590}
4591
4592fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
4593 api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
4594}
4595
4596fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool {
4597 matches!(
4598 base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
4599 "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
4600 )
4601}
4602
4603#[must_use]
4609pub fn provider_base_url_is_official(provider: ProviderKind, base_url: &str) -> bool {
4610 let normalized = base_url.trim().trim_end_matches('/').to_ascii_lowercase();
4611 match provider {
4612 ProviderKind::Deepseek => matches!(
4613 normalized.as_str(),
4614 "https://api.deepseek.com"
4615 | "https://api.deepseek.com/v1"
4616 | "https://api.deepseek.com/beta"
4617 ),
4618 ProviderKind::DeepseekAnthropic => matches!(
4619 normalized.as_str(),
4620 "https://api.deepseek.com/anthropic" | "https://api.deepseek.com/anthropic/v1"
4621 ),
4622 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => matches!(
4623 normalized.as_str(),
4624 "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
4625 ),
4626 ProviderKind::Moonshot => {
4627 normalized == DEFAULT_MOONSHOT_BASE_URL || moonshot_base_url_uses_kimi_code(base_url)
4628 }
4629 ProviderKind::XiaomiMimo => {
4630 xiaomi_mimo_base_url_uses_token_plan(base_url)
4631 || xiaomi_mimo_base_url_is_pay_as_you_go(base_url)
4632 }
4633 ProviderKind::Custom => false,
4636 _ => {
4637 normalized
4638 == default_base_url_for_provider(provider)
4639 .trim()
4640 .trim_end_matches('/')
4641 .to_ascii_lowercase()
4642 }
4643 }
4644}
4645
4646fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
4647 !provider_base_url_is_official(provider, base_url)
4648}
4649
4650#[must_use]
4657pub fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
4658 base_url_is_custom_for_provider(provider, base_url)
4659}
4660
4661fn should_skip_secret_store_for_provider(
4662 provider: ProviderKind,
4663 base_url: &str,
4664 auth_mode: Option<&str>,
4665) -> bool {
4666 if auth_mode_disables_api_key(auth_mode) {
4667 return true;
4668 }
4669 if base_url_is_custom_for_provider(provider, base_url) {
4670 return true;
4671 }
4672 if auth_mode_requires_api_key(auth_mode) {
4673 return false;
4674 }
4675
4676 matches!(
4677 provider,
4678 ProviderKind::Sglang | ProviderKind::Vllm | ProviderKind::Ollama
4679 ) || base_url_uses_local_host(base_url)
4680}
4681
4682fn env_api_key_for_provider(provider: ProviderKind) -> Option<String> {
4683 if provider == ProviderKind::Huggingface {
4684 return std::env::var("HUGGINGFACE_API_KEY")
4685 .ok()
4686 .filter(|value| !value.trim().is_empty())
4687 .or_else(|| {
4688 std::env::var("HF_TOKEN")
4689 .ok()
4690 .filter(|value| !value.trim().is_empty())
4691 });
4692 }
4693
4694 codewhale_secrets::env_for(provider.as_str())
4695}
4696
4697#[must_use]
4699pub fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
4700 matches!(
4701 auth_mode
4702 .map(str::trim)
4703 .filter(|value| !value.is_empty())
4704 .map(|value| value.to_ascii_lowercase()),
4705 Some(value)
4706 if matches!(
4707 value.as_str(),
4708 "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
4709 )
4710 )
4711}
4712
4713#[must_use]
4715pub fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
4716 matches!(
4717 auth_mode
4718 .map(str::trim)
4719 .filter(|value| !value.is_empty())
4720 .map(|value| value.to_ascii_lowercase()),
4721 Some(value)
4722 if matches!(
4723 value.as_str(),
4724 "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
4725 )
4726 )
4727}
4728
4729#[must_use]
4731pub fn auth_mode_uses_kimi_imported_token(auth_mode: &str) -> bool {
4732 matches!(
4733 auth_mode
4734 .trim()
4735 .to_ascii_lowercase()
4736 .replace('-', "_")
4737 .as_str(),
4738 "kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
4739 )
4740}
4741
4742fn base_url_uses_local_host(base_url: &str) -> bool {
4743 let Some(host) = base_url_host(base_url) else {
4744 return false;
4745 };
4746 let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
4747 if matches!(host.as_str(), "localhost" | "0.0.0.0") {
4748 return true;
4749 }
4750 host.parse::<std::net::IpAddr>()
4751 .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
4752}
4753
4754fn base_url_host(base_url: &str) -> Option<&str> {
4755 let without_scheme = base_url
4756 .split_once("://")
4757 .map_or(base_url, |(_, rest)| rest);
4758 let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
4759 if let Some(rest) = authority.strip_prefix('[') {
4760 return rest.split_once(']').map(|(host, _)| host);
4761 }
4762 authority.split(':').next().filter(|host| !host.is_empty())
4763}
4764
4765#[derive(Debug, Clone, Default)]
4766pub struct CliRuntimeOverrides {
4767 pub provider: Option<ProviderKind>,
4768 pub model: Option<String>,
4769 pub api_key: Option<String>,
4770 pub base_url: Option<String>,
4771 pub auth_mode: Option<String>,
4772 pub output_mode: Option<String>,
4773 pub log_level: Option<String>,
4774 pub telemetry: Option<bool>,
4775 pub approval_policy: Option<String>,
4776 pub sandbox_mode: Option<String>,
4777 pub yolo: Option<bool>,
4778 pub verbosity: Option<String>,
4779}
4780
4781#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4782pub enum RuntimeApiKeySource {
4783 Cli,
4784 ConfigFile,
4785 Keyring,
4786 Env,
4787}
4788
4789impl RuntimeApiKeySource {
4790 #[must_use]
4791 pub fn as_env_value(self) -> &'static str {
4792 match self {
4793 Self::Cli => "cli",
4794 Self::ConfigFile => "config",
4795 Self::Keyring => "keyring",
4796 Self::Env => "env",
4797 }
4798 }
4799}
4800
4801#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4802pub enum ProviderSource {
4803 Cli,
4804 Env(&'static str),
4805 Config,
4806}
4807
4808#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4816pub enum ModelSource {
4817 Cli,
4819 Env,
4821 ProviderConfig,
4823 RootDefaultTextModel,
4825 RootModel,
4827 ProviderDefault,
4829}
4830
4831impl ModelSource {
4832 #[must_use]
4834 pub fn is_explicit(self) -> bool {
4835 !matches!(self, Self::ProviderDefault)
4836 }
4837
4838 #[must_use]
4839 pub fn as_str(self) -> &'static str {
4840 match self {
4841 Self::Cli => "--model",
4842 Self::Env => "environment",
4843 Self::ProviderConfig => "config [providers.*].model",
4844 Self::RootDefaultTextModel => "config default_text_model",
4845 Self::RootModel => "config model",
4846 Self::ProviderDefault => "provider default",
4847 }
4848 }
4849}
4850
4851#[derive(Debug, Clone)]
4852pub struct ResolvedRuntimeOptions {
4853 pub provider: ProviderKind,
4854 pub provider_source: ProviderSource,
4855 pub model: String,
4856 pub model_source: ModelSource,
4857 pub api_key: Option<String>,
4858 pub api_key_source: Option<RuntimeApiKeySource>,
4859 pub base_url: String,
4860 pub auth_mode: Option<String>,
4861 pub insecure_skip_tls_verify: bool,
4862 pub output_mode: Option<String>,
4863 pub log_level: Option<String>,
4864 pub telemetry: bool,
4865 pub telemetry_explicit_off: bool,
4875 pub telemetry_endpoint: Option<String>,
4883 pub approval_policy: Option<String>,
4884 pub sandbox_mode: Option<String>,
4885 pub yolo: Option<bool>,
4886 pub verbosity: Option<String>,
4887 pub http_headers: BTreeMap<String, String>,
4888}
4889
4890#[derive(Debug, Clone)]
4891pub struct ConfigStore {
4892 path: PathBuf,
4893 pub config: ConfigToml,
4894 permissions: PermissionsToml,
4895 original_raw: Option<String>,
4898}
4899
4900impl ConfigStore {
4901 pub fn load(path: Option<PathBuf>) -> Result<Self> {
4902 let path = resolve_config_path(path)?;
4903 let (config, original_raw) = if checked_path_exists(&path)? {
4904 let raw = read_checked_config_file(&path)?;
4905 let mut parsed: ConfigToml = toml::from_str(&raw).map_err(|_| {
4906 anyhow::anyhow!(
4907 "failed to parse config at {}; file contents were omitted",
4908 quote_os_path(&path)
4909 )
4910 })?;
4911 let raw_document: toml::Value = toml::from_str(&raw).map_err(|_| {
4912 anyhow::anyhow!(
4913 "failed to parse config at {}; file contents were omitted",
4914 quote_os_path(&path)
4915 )
4916 })?;
4917 if let Some(provider_id) = raw_document.get("provider").and_then(toml::Value::as_str) {
4918 parsed
4919 .bind_persisted_provider_id(provider_id)
4920 .with_context(|| {
4921 format!("failed to parse config at {}", quote_os_path(&path))
4922 })?;
4923 }
4924 (parsed, Some(raw))
4925 } else {
4926 (ConfigToml::default(), None)
4927 };
4928 let permissions = load_sibling_permissions(&path)?;
4929
4930 Ok(Self {
4931 path,
4932 config,
4933 permissions,
4934 original_raw,
4935 })
4936 }
4937
4938 pub fn rendered_body(&self) -> Result<String> {
4944 let mut serialized =
4945 toml::to_string_pretty(&self.config).context("failed to serialize config")?;
4946 if let Some(provider_id) = self.config.named_custom_provider_id() {
4947 let mut document = serialized
4948 .parse::<toml_edit::DocumentMut>()
4949 .context("failed to edit serialized config")?;
4950 document["provider"] = toml_edit::value(provider_id);
4951 serialized = document.to_string();
4952 }
4953 if let Some(ref original_raw) = self.original_raw {
4954 merge_and_preserve_comments(&serialized, original_raw).with_context(|| {
4955 format!(
4956 "cannot safely preserve config at {}; reload it and retry instead of replacing an unmergeable snapshot",
4957 quote_os_path(&self.path)
4958 )
4959 })
4960 } else {
4961 Ok(serialized)
4962 }
4963 }
4964
4965 pub fn save(&mut self) -> Result<()> {
4966 let path = normalize_config_file_path(self.path.clone())?;
4967 let body = self.rendered_body()?;
4968 replace_config_document_if_unchanged(&path, self.original_raw.as_deref(), &body)?;
4969 self.original_raw = Some(body);
4970 Ok(())
4971 }
4972
4973 pub fn reload(&mut self) -> Result<()> {
4977 *self = Self::load(Some(self.path.clone()))?;
4978 Ok(())
4979 }
4980
4981 #[must_use]
4982 pub fn path(&self) -> &Path {
4983 &self.path
4984 }
4985
4986 #[must_use]
4987 pub fn permissions(&self) -> &PermissionsToml {
4988 &self.permissions
4989 }
4990
4991 #[must_use]
4992 pub fn permissions_path(&self) -> PathBuf {
4993 checked_permissions_path_for_config_path(&self.path)
4994 .expect("ConfigStore path is validated before construction")
4995 }
4996
4997 #[must_use]
4998 pub fn exec_policy_engine(&self) -> ExecPolicyEngine {
4999 if self.permissions.is_empty() {
5000 ExecPolicyEngine::new(Vec::new(), Vec::new())
5001 } else {
5002 ExecPolicyEngine::with_rulesets(vec![self.permissions.ruleset()])
5003 }
5004 }
5005
5006 pub fn append_ask_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
5013 self.append_permission_rules(rules, PermissionAction::Ask)
5014 }
5015
5016 pub fn append_allow_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
5023 for rule in rules {
5024 if rule.action != PermissionAction::Allow {
5025 bail!("append_allow_rules only accepts action = \"allow\"");
5026 }
5027 let Some(workspace) = rule
5028 .workspace
5029 .as_deref()
5030 .and_then(codewhale_execpolicy::normalize_workspace_scope)
5031 else {
5032 bail!("persistent allow rules must be scoped to a workspace");
5033 };
5034 if rule.command.is_some() && !rule.command_exact {
5035 bail!("persistent command allow rules must use exact matching");
5036 }
5037 if rule.command.is_none() && rule.path.is_none() {
5038 bail!("persistent allow rules must match an exact command or path");
5039 }
5040 if let Some(command) = rule.command.as_deref()
5041 && command.trim().is_empty()
5042 {
5043 bail!("persistent command allow rules must not be empty");
5044 }
5045 if let Some(path) = rule.path.as_deref()
5046 && codewhale_execpolicy::normalize_workspace_relative_path(path, &workspace)
5047 .is_none_or(|path| path.is_empty())
5048 {
5049 bail!("persistent path allow rules must stay within the workspace");
5050 }
5051 }
5052 self.append_permission_rules(rules, PermissionAction::Allow)
5053 }
5054
5055 fn append_permission_rules(
5056 &mut self,
5057 rules: &[ToolAskRule],
5058 expected_action: PermissionAction,
5059 ) -> Result<usize> {
5060 if rules.is_empty() {
5061 return Ok(0);
5062 }
5063 if rules.iter().any(|rule| rule.action != expected_action) {
5064 bail!(
5065 "permission rule action does not match requested {:?} persistence",
5066 expected_action
5067 );
5068 }
5069
5070 let path = checked_permissions_path_for_config_path(&self.path)?;
5071 let (added, persisted) = config_document::with_config_write_lock(&path, |path| {
5072 let (_, raw, mut permissions) = read_permissions_state(path)?;
5073 let mut document = parse_permissions_document(path, &raw)?;
5074
5075 if !document.contains_key("rules") {
5076 document["rules"] = toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
5077 }
5078 let rules_item = document
5079 .get_mut("rules")
5080 .expect("rules entry was inserted above");
5081
5082 let mut added = 0;
5083 for rule in rules {
5084 if permissions.rules.contains(rule) {
5085 continue;
5086 }
5087 append_permission_rule(rules_item, rule)?;
5088 permissions.rules.push(rule.clone());
5089 added += 1;
5090 }
5091 if added == 0 {
5092 return Ok((0, permissions));
5093 }
5094
5095 let body = document.to_string();
5096 let persisted = parse_generated_permissions(path, &body)?;
5097 write_permissions_atomic(path, body.as_bytes())?;
5098 Ok((added, persisted))
5099 })?;
5100 self.permissions = persisted;
5101 Ok(added)
5102 }
5103}
5104
5105fn config_backup_file_name(path: &Path) -> OsString {
5106 let mut file_name = path
5107 .file_name()
5108 .map(OsString::from)
5109 .unwrap_or_else(|| OsString::from(CONFIG_FILE_NAME));
5110 file_name.push(".bak");
5111 file_name
5112}
5113
5114fn config_sibling_path_unchecked(config_path: &Path, file_name: &OsStr) -> PathBuf {
5115 config_path
5116 .parent()
5117 .unwrap_or_else(|| Path::new("."))
5118 .join(file_name)
5119}
5120
5121fn checked_config_sibling_path(config_path: &Path, file_name: &OsStr) -> Result<PathBuf> {
5122 let config_path = normalize_config_file_path(config_path.to_path_buf())?;
5123 let parent = config_path
5124 .parent()
5125 .context("config path must include a parent directory")?;
5126 let path = parent.join(file_name);
5127 reject_path_symlink(&path)?;
5128 Ok(path)
5129}
5130
5131#[cfg(test)]
5132fn config_backup_path(path: &Path) -> PathBuf {
5133 config_sibling_path_unchecked(path, &config_backup_file_name(path))
5134}
5135
5136fn checked_config_backup_path(path: &Path) -> Result<PathBuf> {
5137 checked_config_sibling_path(path, &config_backup_file_name(path))
5138}
5139
5140pub fn scrub_plaintext_api_keys_from_config_backup(path: &Path) -> Result<()> {
5147 let backup = checked_config_backup_path(path)?;
5148 if !backup.exists() {
5149 return Ok(());
5150 }
5151
5152 let raw = read_checked_toml_file(&backup, "config backup")?;
5153 let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
5154 format!(
5155 "failed to scrub plaintext API keys from config backup {}",
5156 backup.display()
5157 )
5158 })?;
5159 if scrubbed != raw {
5160 persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
5161 format!(
5162 "failed to write credential-free config backup {}",
5163 backup.display()
5164 )
5165 })?;
5166 }
5167 Ok(())
5168}
5169
5170fn write_one_time_config_backup(path: &Path) -> Result<()> {
5171 let backup = checked_config_backup_path(path)?;
5172 if backup.exists() {
5173 return scrub_plaintext_api_keys_from_config_backup(path);
5174 }
5175
5176 let raw = read_checked_config_file(path)?;
5177 let scrubbed = config_toml_without_plaintext_api_keys(&raw).with_context(|| {
5178 format!(
5179 "failed to scrub plaintext API keys while creating config backup {}",
5180 backup.display()
5181 )
5182 })?;
5183 persistence::atomic_write(&backup, scrubbed.as_bytes()).with_context(|| {
5184 format!(
5185 "failed to create credential-free config backup {} from {}",
5186 backup.display(),
5187 path.display()
5188 )
5189 })?;
5190 Ok(())
5191}
5192
5193fn config_toml_without_plaintext_api_keys(raw: &str) -> Result<String> {
5194 let mut document = raw
5195 .parse::<toml_edit::DocumentMut>()
5196 .map_err(|_| {
5197 anyhow::anyhow!(
5198 "failed to parse config TOML while removing plaintext API keys; file contents were omitted"
5199 )
5200 })?;
5201 remove_plaintext_api_keys_recursive(document.as_table_mut());
5202 Ok(document.to_string())
5203}
5204
5205fn remove_plaintext_api_keys_recursive(table: &mut dyn toml_edit::TableLike) {
5206 table.remove("api_key");
5207 for (_, item) in table.iter_mut() {
5208 if let toml_edit::Item::ArrayOfTables(tables) = item {
5209 for nested in tables.iter_mut() {
5210 remove_plaintext_api_keys_recursive(nested);
5211 }
5212 } else if let Some(nested) = item.as_table_like_mut() {
5213 remove_plaintext_api_keys_recursive(nested);
5214 }
5215 }
5216}
5217
5218pub fn merge_and_preserve_comments(serialized: &str, original_raw: &str) -> Result<String> {
5226 let original = original_raw
5227 .parse::<toml_edit::DocumentMut>()
5228 .map_err(|_| {
5229 anyhow::anyhow!(
5230 "failed to parse original config for comment merge; file contents were omitted"
5231 )
5232 })?;
5233
5234 let mut new_doc = serialized.parse::<toml_edit::DocumentMut>().map_err(|_| {
5235 anyhow::anyhow!(
5236 "failed to parse serialized config for comment merge; file contents were omitted"
5237 )
5238 })?;
5239
5240 new_doc.set_trailing(original.trailing().clone());
5243
5244 *new_doc.as_table_mut().decor_mut() = original.as_table().decor().clone();
5247
5248 merge_decor_table(new_doc.as_table_mut(), original.as_table());
5249
5250 Ok(new_doc.to_string())
5251}
5252
5253fn merge_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
5256 let keys: Vec<String> = source.iter().map(|(k, _)| k.to_owned()).collect();
5259 for key in &keys {
5260 let Some((source_key, source_item)) = source.get_key_value(key) else {
5261 continue;
5262 };
5263 let Some((mut target_key_mut, target_item)) = target.get_key_value_mut(key) else {
5264 continue;
5265 };
5266
5267 *target_key_mut.leaf_decor_mut() = source_key.leaf_decor().clone();
5269
5270 copy_item_decor(target_item, source_item);
5271
5272 if let (Some(tt), Some(st)) = (target_item.as_table_mut(), source_item.as_table()) {
5273 merge_decor_table(tt, st);
5274 }
5275
5276 if let (Some(ta), Some(sa)) = (
5277 target_item.as_array_of_tables_mut(),
5278 source_item.as_array_of_tables(),
5279 ) {
5280 for (i, source_table) in sa.iter().enumerate() {
5281 if let Some(target_table) = ta.get_mut(i) {
5282 copy_item_decor_table(target_table, source_table);
5283 merge_decor_table(target_table, source_table);
5284 }
5285 }
5286 }
5287 }
5288}
5289
5290fn copy_item_decor(target: &mut toml_edit::Item, source: &toml_edit::Item) {
5294 match (target, source) {
5295 (toml_edit::Item::Table(tt), toml_edit::Item::Table(st)) => {
5296 *tt.decor_mut() = st.decor().clone();
5297 }
5298 (toml_edit::Item::Value(tv), toml_edit::Item::Value(sv)) => {
5299 *tv.decor_mut() = sv.decor().clone();
5300 }
5301 _ => {}
5302 }
5303}
5304
5305fn copy_item_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
5306 *target.decor_mut() = source.decor().clone();
5307}
5308
5309pub fn default_secrets() -> &'static Secrets {
5314 static SECRETS: OnceLock<Secrets> = OnceLock::new();
5315 SECRETS.get_or_init(|| {
5316 #[cfg(test)]
5321 {
5322 Secrets::new(std::sync::Arc::new(
5323 codewhale_secrets::InMemoryKeyringStore::new(),
5324 ))
5325 }
5326 #[cfg(not(test))]
5327 {
5328 Secrets::auto_detect()
5329 }
5330 })
5331}
5332
5333pub use codewhale_paths::{CODEWHALE_APP_DIR, LEGACY_APP_DIR};
5341
5342pub fn codewhale_home() -> Result<PathBuf> {
5347 codewhale_paths::codewhale_home()
5348 .map_err(anyhow::Error::new)?
5349 .context("failed to resolve home directory")
5350}
5351
5352pub fn codewhale_home_is_explicit() -> bool {
5357 codewhale_paths::codewhale_home_is_explicit()
5358}
5359
5360pub fn legacy_deepseek_home() -> Result<PathBuf> {
5364 codewhale_paths::legacy_deepseek_home().context("failed to resolve home directory")
5365}
5366
5367fn ensure_safe_state_subdir(subdir: &str) -> Result<()> {
5375 if subdir.is_empty() {
5376 bail!("state subdir must not be empty");
5377 }
5378 let path = std::path::Path::new(subdir);
5379 if path.is_absolute() {
5380 bail!("state subdir must not be an absolute path: {subdir}");
5381 }
5382 if path.components().any(|c| {
5383 matches!(
5384 c,
5385 std::path::Component::RootDir | std::path::Component::Prefix(_)
5386 )
5387 }) {
5388 bail!("state subdir must not contain a root or prefix: {subdir}");
5389 }
5390 if path
5391 .components()
5392 .any(|c| matches!(c, std::path::Component::ParentDir))
5393 {
5394 bail!("state subdir must not contain parent-dir (..) components: {subdir}");
5395 }
5396 Ok(())
5397}
5398
5399pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
5406 ensure_safe_state_subdir(subdir)?;
5407 let explicit_codewhale_home = codewhale_home_is_explicit();
5408 let primary = codewhale_home()?.join(subdir);
5409 if explicit_codewhale_home || primary.exists() {
5410 return Ok(primary);
5411 }
5412 let legacy = legacy_deepseek_home()?.join(subdir);
5413 if legacy.exists() {
5414 return Ok(legacy);
5415 }
5416 Ok(primary)
5418}
5419
5420pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
5430 let (dir, migration) = ensure_state_dir_with_migration(subdir)?;
5431 if let Some(migration) = migration {
5432 eprintln!("{}", migration.user_notice());
5433 }
5434 Ok(dir)
5435}
5436
5437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5438pub enum StateMigrationKind {
5439 Relocated,
5440 Copied,
5441}
5442
5443#[derive(Debug, Clone, PartialEq, Eq)]
5444pub struct StateMigration {
5445 pub subdir: String,
5446 pub legacy_path: PathBuf,
5447 pub primary_path: PathBuf,
5448 pub kind: StateMigrationKind,
5449}
5450
5451impl StateMigration {
5452 pub fn user_notice(&self) -> String {
5453 let action = match self.kind {
5454 StateMigrationKind::Relocated => "relocated",
5455 StateMigrationKind::Copied => "copied",
5456 };
5457 let legacy_detail = match self.kind {
5458 StateMigrationKind::Relocated => {
5459 "The legacy .deepseek copy for this state path was removed by the move."
5460 }
5461 StateMigrationKind::Copied => {
5462 "The legacy .deepseek copy was left in place because a direct move failed."
5463 }
5464 };
5465
5466 format!(
5467 "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.",
5468 self.legacy_path.display(),
5469 self.primary_path.display(),
5470 )
5471 }
5472}
5473
5474pub fn ensure_state_dir_with_migration(subdir: &str) -> Result<(PathBuf, Option<StateMigration>)> {
5478 ensure_safe_state_subdir(subdir)?;
5479 let explicit_codewhale_home = codewhale_home_is_explicit();
5480 let dir = codewhale_home()?.join(subdir);
5481 let migration = if !explicit_codewhale_home {
5482 migrate_legacy_state_dir(&dir, subdir)?
5483 } else {
5484 None
5485 };
5486 std::fs::create_dir_all(&dir)
5487 .with_context(|| format!("failed to create {}/", dir.display()))?;
5488 Ok((dir, migration))
5489}
5490
5491fn migrate_legacy_state_dir(primary: &Path, subdir: &str) -> Result<Option<StateMigration>> {
5496 if primary.exists() || subdir == "." || subdir.is_empty() {
5497 return Ok(None);
5498 }
5499 let legacy = match legacy_deepseek_home() {
5500 Ok(home) => home.join(subdir),
5501 Err(_) => return Ok(None),
5502 };
5503 if !legacy.exists() {
5504 return Ok(None);
5505 }
5506 if let Some(parent) = primary.parent()
5508 && let Err(err) = std::fs::create_dir_all(parent)
5509 {
5510 tracing::warn!(
5511 target: "config::migration",
5512 "Could not create {} for state migration ({}); writing to primary anyway",
5513 parent.display(),
5514 err
5515 );
5516 }
5517 match std::fs::rename(&legacy, primary) {
5518 Ok(()) => {
5519 tracing::info!(
5520 target: "config::migration",
5521 "Migrated legacy state directory {} -> {} (relocated). The .deepseek copy was removed.",
5522 legacy.display(),
5523 primary.display()
5524 );
5525 return Ok(Some(StateMigration {
5526 subdir: subdir.to_string(),
5527 legacy_path: legacy,
5528 primary_path: primary.to_path_buf(),
5529 kind: StateMigrationKind::Relocated,
5530 }));
5531 }
5532 Err(err) => {
5533 match copy_dir_recursive(&legacy, primary) {
5538 Ok(()) => {
5539 tracing::info!(
5540 target: "config::migration",
5541 "Migrated legacy state directory {} -> {} (copied; rename failed: {err}). \
5542 The legacy .deepseek copy was left in place.",
5543 legacy.display(),
5544 primary.display()
5545 );
5546 return Ok(Some(StateMigration {
5547 subdir: subdir.to_string(),
5548 legacy_path: legacy,
5549 primary_path: primary.to_path_buf(),
5550 kind: StateMigrationKind::Copied,
5551 }));
5552 }
5553 Err(copy_err) => {
5554 tracing::warn!(
5555 target: "config::migration",
5556 "Could not migrate legacy state {} -> {} (rename: {err}; copy: {copy_err}). \
5557 New data is written to the primary path; the legacy tree remains untouched.",
5558 legacy.display(),
5559 primary.display()
5560 );
5561 }
5562 }
5563 }
5564 }
5565 Ok(None)
5566}
5567
5568fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
5571 std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
5572 for entry in
5573 std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
5574 {
5575 let entry = entry.with_context(|| format!("failed to read entry in {}", src.display()))?;
5576 let path = entry.path();
5577 let target = dst.join(entry.file_name());
5578 let file_type = entry
5579 .file_type()
5580 .with_context(|| format!("failed to read file type for {}", path.display()))?;
5581 if file_type.is_dir() {
5582 copy_dir_recursive(&path, &target)?;
5583 } else if file_type.is_file() {
5584 std::fs::copy(&path, &target).with_context(|| {
5585 format!("failed to copy {} -> {}", path.display(), target.display())
5586 })?;
5587 }
5588 }
5589 Ok(())
5590}
5591
5592pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> Result<(bool, PathBuf)> {
5599 ensure_safe_state_subdir(subdir)?;
5600 let workspace = normalize_project_workspace(workspace)?;
5601 let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
5602 if primary.exists() {
5603 return Ok((true, primary));
5604 }
5605 let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
5606 Ok((false, legacy))
5607}
5608
5609pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
5612 ensure_safe_state_subdir(subdir)?;
5613 let workspace = normalize_project_workspace(workspace)?;
5614 let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
5615 std::fs::create_dir_all(&dir)
5616 .with_context(|| format!("failed to create {}/", dir.display()))?;
5617 Ok(dir)
5618}
5619
5620pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
5621 if let Some(path) = explicit {
5622 return normalize_config_file_path(path);
5623 }
5624 if let Some(path) = codewhale_paths::config_path_override().map_err(anyhow::Error::new)? {
5625 return normalize_config_file_path(path);
5626 }
5627 default_config_path()
5628}
5629
5630#[must_use]
5650pub fn config_path_is_workspace_scoped(path: &Path) -> bool {
5651 config_path_is_workspace_scoped_with_context(
5652 path,
5653 codewhale_paths::codewhale_home_override()
5654 .ok()
5655 .flatten()
5656 .as_deref(),
5657 codewhale_paths::user_home().as_deref(),
5658 std::env::current_dir().ok().as_deref(),
5659 )
5660}
5661
5662fn config_path_is_workspace_scoped_with_context(
5665 path: &Path,
5666 explicit_codewhale_home: Option<&Path>,
5667 user_home: Option<&Path>,
5668 current_dir: Option<&Path>,
5669) -> bool {
5670 if let Some(home) = explicit_codewhale_home
5671 && same_lexical_or_canonical_path(path, &home.join(CONFIG_FILE_NAME))
5672 {
5673 return false;
5674 }
5675 let Some(parent) = path.parent() else {
5676 return false;
5677 };
5678 let parent_is_app_dir = parent
5679 .file_name()
5680 .and_then(OsStr::to_str)
5681 .is_some_and(|name| name == CODEWHALE_APP_DIR || name == LEGACY_APP_DIR);
5682 if !parent_is_app_dir {
5683 return false;
5684 }
5685 let Some(base) = parent.parent() else {
5686 return true;
5687 };
5688 if let Some(home) = user_home
5689 && same_lexical_or_canonical_path(base, home)
5690 {
5691 return false;
5692 }
5693 if path.is_relative() {
5694 return true;
5696 }
5697 if let Some(cwd) = current_dir
5699 && canonicalize_or_keep(cwd).starts_with(canonicalize_or_keep(base))
5700 {
5701 return true;
5702 }
5703 base.join(".git").exists()
5705}
5706
5707fn same_lexical_or_canonical_path(a: &Path, b: &Path) -> bool {
5710 a == b || canonicalize_or_keep(a) == canonicalize_or_keep(b)
5711}
5712
5713fn canonicalize_or_keep(path: &Path) -> PathBuf {
5714 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
5715}
5716
5717#[cfg(test)]
5718mod credential_scope_tests {
5719 use super::config_path_is_workspace_scoped_with_context;
5720 use std::path::Path;
5721
5722 #[test]
5723 fn config_inside_current_workspace_is_workspace_scoped() {
5724 let temp = tempfile::tempdir().expect("tempdir");
5725 let repo = temp.path().join("repo");
5726 let cwd = repo.join("nested/dir");
5727 for app_dir in [".codewhale", ".deepseek"] {
5728 let config = repo.join(app_dir).join("config.toml");
5729 assert!(
5730 config_path_is_workspace_scoped_with_context(
5731 &config,
5732 None,
5733 Some(Path::new("/home/user")),
5734 Some(&cwd),
5735 ),
5736 "{} should be workspace-scoped when cwd sits inside the repo",
5737 config.display()
5738 );
5739 }
5740 }
5741
5742 #[test]
5743 fn relative_app_dir_config_is_workspace_scoped() {
5744 assert!(config_path_is_workspace_scoped_with_context(
5745 Path::new(".codewhale/config.toml"),
5746 None,
5747 Some(Path::new("/home/user")),
5748 Some(Path::new("/somewhere/else")),
5749 ));
5750 }
5751
5752 #[test]
5753 fn checkout_config_outside_cwd_is_workspace_scoped_via_git_marker() {
5754 let temp = tempfile::tempdir().expect("tempdir");
5755 let repo = temp.path().join("repo");
5756 std::fs::create_dir_all(repo.join(".git")).expect("git marker");
5757 std::fs::create_dir_all(repo.join(".codewhale")).expect("app dir");
5758 assert!(config_path_is_workspace_scoped_with_context(
5759 &repo.join(".codewhale/config.toml"),
5760 None,
5761 Some(Path::new("/home/user")),
5762 Some(Path::new("/somewhere/else")),
5763 ));
5764 }
5765
5766 #[test]
5767 fn user_global_and_custom_locations_are_not_workspace_scoped() {
5768 let home = Path::new("/home/user");
5769 let elsewhere = Some(Path::new("/somewhere/else"));
5770 for global_config in [
5771 "/home/user/.codewhale/config.toml",
5772 "/home/user/.deepseek/config.toml",
5773 "/home/user/team-config.toml",
5774 "/etc/codewhale/config.toml",
5775 ] {
5776 assert!(
5777 !config_path_is_workspace_scoped_with_context(
5778 Path::new(global_config),
5779 None,
5780 Some(home),
5781 elsewhere,
5782 ),
5783 "{global_config} should stay user-global"
5784 );
5785 }
5786 let temp = tempfile::tempdir().expect("tempdir");
5790 assert!(!config_path_is_workspace_scoped_with_context(
5791 &temp.path().join(".codewhale/config.toml"),
5792 None,
5793 Some(home),
5794 elsewhere,
5795 ));
5796 }
5797
5798 #[test]
5799 fn explicit_codewhale_home_config_is_user_global_even_when_dir_is_app_named() {
5800 let temp = tempfile::tempdir().expect("tempdir");
5801 let repo = temp.path().join("repo");
5802 let explicit = repo.join(".codewhale");
5803 assert!(!config_path_is_workspace_scoped_with_context(
5806 &explicit.join("config.toml"),
5807 Some(&explicit),
5808 Some(Path::new("/home/user")),
5809 Some(&repo),
5810 ));
5811 assert!(config_path_is_workspace_scoped_with_context(
5813 &repo.join("other/.codewhale/config.toml"),
5814 Some(&explicit),
5815 Some(Path::new("/home/user")),
5816 Some(&repo.join("other")),
5817 ));
5818 }
5819}
5820
5821#[must_use]
5822pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
5823 config_sibling_path_unchecked(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
5824}
5825
5826fn checked_permissions_path_for_config_path(config_path: &Path) -> Result<PathBuf> {
5827 checked_config_sibling_path(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
5828}
5829
5830pub fn resolve_permissions_path(config_path: Option<PathBuf>) -> Result<PathBuf> {
5831 checked_permissions_path_for_config_path(&resolve_config_path(config_path)?)
5832}
5833
5834pub fn load_permissions_snapshot(config_path: Option<PathBuf>) -> Result<PermissionsSnapshot> {
5837 let path = resolve_permissions_path(config_path)?;
5838 let (file_exists, raw, permissions) = read_permissions_state(&path)?;
5839 let file_state = if !file_exists {
5840 PermissionsFileState::Missing
5841 } else if raw.is_empty() {
5842 PermissionsFileState::Empty
5843 } else {
5844 PermissionsFileState::Present
5845 };
5846 let removal_tokens = (0..permissions.rules.len())
5847 .map(|index| permission_removal_token(&path, &raw, index))
5848 .collect();
5849 Ok(PermissionsSnapshot {
5850 path,
5851 file_state,
5852 permissions,
5853 removal_tokens,
5854 })
5855}
5856
5857pub fn remove_permission_rule(
5864 config_path: Option<PathBuf>,
5865 index: usize,
5866 expected_token: &str,
5867) -> Result<ToolAskRule> {
5868 let path = resolve_permissions_path(config_path)?;
5869 config_document::with_config_write_lock(&path, |path| {
5870 let (file_exists, raw, permissions) = read_permissions_state(path)?;
5871 if !file_exists {
5872 bail!(
5873 "permissions changed after they were listed; reload {} and retry",
5874 quote_os_path(path)
5875 );
5876 }
5877 let rule = permissions.rules.get(index).cloned().with_context(|| {
5878 format!(
5879 "permission rule {} no longer exists in {}; list rules again",
5880 index + 1,
5881 quote_os_path(path)
5882 )
5883 })?;
5884 let current_token = permission_removal_token(path, &raw, index);
5885 if current_token != expected_token {
5886 bail!(
5887 "permissions changed after they were listed; reload {} and retry",
5888 quote_os_path(path)
5889 );
5890 }
5891
5892 let mut document = parse_permissions_document(path, &raw)?;
5893 let rules_item = document.get_mut("rules").with_context(|| {
5894 format!(
5895 "permissions at {} no longer contain a rules array",
5896 quote_os_path(path)
5897 )
5898 })?;
5899 let orphaned_header = remove_permission_rule_item(rules_item, index)?;
5900 if let Some(header) = orphaned_header {
5901 let trailing = format!(
5902 "{header}{}",
5903 document.trailing().as_str().unwrap_or_default()
5904 );
5905 document.set_trailing(trailing);
5906 }
5907 let body = document.to_string();
5908 let persisted = parse_generated_permissions(path, &body)?;
5909 if persisted.rules.len() + 1 != permissions.rules.len() {
5910 bail!(
5911 "refusing inconsistent permission removal at {}",
5912 quote_os_path(path)
5913 );
5914 }
5915 write_permissions_atomic(path, body.as_bytes())?;
5916 Ok(rule)
5917 })
5918}
5919
5920pub fn read_permissions_file(path: &Path) -> Result<String> {
5923 read_checked_permissions_file(path)
5924}
5925
5926fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
5927 let permissions_path = checked_permissions_path_for_config_path(config_path)?;
5928 let (_, _, permissions) = read_permissions_state(&permissions_path)?;
5929 Ok(permissions)
5930}
5931
5932fn read_permissions_state(path: &Path) -> Result<(bool, String, PermissionsToml)> {
5933 let file_exists = checked_path_exists(path)?;
5934 let raw = if file_exists {
5935 read_checked_permissions_file(path)?
5936 } else {
5937 String::new()
5938 };
5939 let permissions = if raw.trim().is_empty() {
5940 PermissionsToml::default()
5941 } else {
5942 toml::from_str(&raw).map_err(|_| {
5943 anyhow::anyhow!(
5944 "failed to parse permissions at {}; file contents were omitted",
5945 quote_os_path(path)
5946 )
5947 })?
5948 };
5949 Ok((file_exists, raw, permissions))
5950}
5951
5952fn parse_permissions_document(path: &Path, raw: &str) -> Result<toml_edit::DocumentMut> {
5953 if raw.trim().is_empty() {
5954 Ok(toml_edit::DocumentMut::new())
5955 } else {
5956 raw.parse::<toml_edit::DocumentMut>().map_err(|_| {
5957 anyhow::anyhow!(
5958 "failed to edit permissions at {}; file contents were omitted",
5959 quote_os_path(path)
5960 )
5961 })
5962 }
5963}
5964
5965fn parse_generated_permissions(path: &Path, body: &str) -> Result<PermissionsToml> {
5966 toml::from_str(body).map_err(|_| {
5967 anyhow::anyhow!(
5968 "generated invalid permissions document for {}; file contents were omitted",
5969 quote_os_path(path)
5970 )
5971 })
5972}
5973
5974fn permission_removal_token(path: &Path, raw: &str, index: usize) -> String {
5975 let mut hasher = Sha256::new();
5976 hasher.update(b"codewhale-permission-removal-v1\0");
5977 hasher.update(quote_os_path(path).as_bytes());
5978 hasher.update(b"\0");
5979 hasher.update(index.to_le_bytes());
5980 hasher.update(b"\0");
5981 hasher.update(raw.as_bytes());
5982 let digest = hasher.finalize();
5983 let mut token = String::with_capacity(24);
5984 for byte in &digest[..12] {
5985 use std::fmt::Write as _;
5986 let _ = write!(&mut token, "{byte:02x}");
5987 }
5988 token
5989}
5990
5991fn append_permission_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
5992 match item {
5993 toml_edit::Item::ArrayOfTables(rules) => {
5994 rules.push(permission_rule_table(rule));
5995 Ok(())
5996 }
5997 toml_edit::Item::Value(value) => {
5998 let Some(rules) = value.as_array_mut() else {
5999 bail!("`rules` in permissions.toml must be an array");
6000 };
6001 rules.push(toml_edit::Value::InlineTable(permission_rule_inline_table(
6002 rule,
6003 )));
6004 Ok(())
6005 }
6006 _ => bail!("`rules` in permissions.toml must be an array"),
6007 }
6008}
6009
6010fn remove_permission_rule_item(item: &mut toml_edit::Item, index: usize) -> Result<Option<String>> {
6011 match item {
6012 toml_edit::Item::ArrayOfTables(rules) => {
6013 if index >= rules.len() {
6014 bail!("permission rule index changed before removal");
6015 }
6016 let file_header = if index == 0 {
6017 rules
6018 .get(index)
6019 .and_then(|rule| rule.decor().prefix())
6020 .and_then(toml_edit::RawString::as_str)
6021 .map(str::to_owned)
6022 } else {
6023 None
6024 };
6025 rules.remove(index);
6026 if let Some(header) = file_header.as_deref()
6027 && let Some(next_rule) = rules.get_mut(0)
6028 {
6029 let next_prefix = next_rule
6030 .decor()
6031 .prefix()
6032 .and_then(toml_edit::RawString::as_str)
6033 .unwrap_or_default()
6034 .to_owned();
6035 next_rule
6036 .decor_mut()
6037 .set_prefix(format!("{header}{next_prefix}"));
6038 return Ok(None);
6039 }
6040 Ok(file_header)
6041 }
6042 toml_edit::Item::Value(value) => {
6043 let Some(rules) = value.as_array_mut() else {
6044 bail!("`rules` in permissions.toml must be an array");
6045 };
6046 if index >= rules.len() {
6047 bail!("permission rule index changed before removal");
6048 }
6049 rules.remove(index);
6050 Ok(None)
6051 }
6052 _ => bail!("`rules` in permissions.toml must be an array"),
6053 }
6054}
6055
6056fn permission_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
6057 let mut table = toml_edit::Table::new();
6058 table["tool"] = toml_edit::value(rule.tool.clone());
6059 if let Some(command) = rule.command.as_deref() {
6060 table["command"] = toml_edit::value(command);
6061 }
6062 if rule.command_exact {
6063 table["command_exact"] = toml_edit::value(true);
6064 }
6065 if let Some(path) = rule.path.as_deref() {
6066 table["path"] = toml_edit::value(path);
6067 }
6068 if let Some(workspace) = rule.workspace.as_deref() {
6069 table["workspace"] = toml_edit::value(workspace);
6070 }
6071 if rule.action != PermissionAction::Ask {
6072 table["action"] = toml_edit::value(match rule.action {
6073 PermissionAction::Allow => "allow",
6074 PermissionAction::Ask => "ask",
6075 PermissionAction::Deny => "deny",
6076 });
6077 }
6078 table
6079}
6080
6081fn permission_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
6082 let mut table = toml_edit::InlineTable::new();
6083 table.insert("tool", toml_edit::Value::from(rule.tool.clone()));
6084 if let Some(command) = rule.command.as_deref() {
6085 table.insert("command", toml_edit::Value::from(command));
6086 }
6087 if rule.command_exact {
6088 table.insert("command_exact", toml_edit::Value::from(true));
6089 }
6090 if let Some(path) = rule.path.as_deref() {
6091 table.insert("path", toml_edit::Value::from(path));
6092 }
6093 if let Some(workspace) = rule.workspace.as_deref() {
6094 table.insert("workspace", toml_edit::Value::from(workspace));
6095 }
6096 if rule.action != PermissionAction::Ask {
6097 table.insert(
6098 "action",
6099 toml_edit::Value::from(match rule.action {
6100 PermissionAction::Allow => "allow",
6101 PermissionAction::Ask => "ask",
6102 PermissionAction::Deny => "deny",
6103 }),
6104 );
6105 }
6106 table
6107}
6108
6109fn write_permissions_atomic(path: &Path, body: &[u8]) -> Result<()> {
6110 let parent = path.parent().with_context(|| {
6111 format!(
6112 "permissions path has no parent directory: {}",
6113 path.display()
6114 )
6115 })?;
6116 fs::create_dir_all(parent).with_context(|| {
6117 format!(
6118 "failed to create permissions directory {}",
6119 parent.display()
6120 )
6121 })?;
6122
6123 let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
6124 format!(
6125 "failed to create temporary permissions file in {}",
6126 parent.display()
6127 )
6128 })?;
6129 #[cfg(unix)]
6130 temporary
6131 .as_file()
6132 .set_permissions(fs::Permissions::from_mode(0o600))
6133 .with_context(|| {
6134 format!(
6135 "failed to secure temporary permissions file for {}",
6136 path.display()
6137 )
6138 })?;
6139 temporary
6140 .write_all(body)
6141 .with_context(|| format!("failed to write permissions at {}", path.display()))?;
6142 temporary
6143 .as_file()
6144 .sync_all()
6145 .with_context(|| format!("failed to sync permissions at {}", path.display()))?;
6146 temporary
6147 .persist(path)
6148 .map_err(|error| error.error)
6149 .with_context(|| format!("failed to replace permissions at {}", path.display()))?;
6150 Ok(())
6151}
6152
6153pub fn default_config_path() -> Result<PathBuf> {
6154 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
6157 if codewhale_home_is_explicit() || primary.exists() {
6158 return Ok(primary);
6159 }
6160 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
6161 if legacy.exists() {
6162 return Ok(legacy);
6163 }
6164 Ok(primary)
6166}
6167
6168#[derive(Debug, Clone, PartialEq, Eq)]
6169pub struct ConfigMigration {
6170 pub legacy_path: PathBuf,
6171 pub primary_path: PathBuf,
6172}
6173
6174impl ConfigMigration {
6175 pub fn user_notice(&self) -> String {
6176 format!(
6177 "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.",
6178 self.legacy_path.display(),
6179 self.primary_path.display()
6180 )
6181 }
6182}
6183
6184pub fn migrate_config_if_needed() -> Result<Option<ConfigMigration>> {
6189 if codewhale_home_is_explicit() {
6190 return Ok(None);
6191 }
6192 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
6193 if primary.exists() {
6194 return Ok(None);
6195 }
6196 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
6197 if !legacy.exists() {
6198 return Ok(None);
6199 }
6200 if let Some(parent) = primary.parent() {
6202 std::fs::create_dir_all(parent).context("failed to create codewhale config directory")?;
6203 }
6204 std::fs::copy(&legacy, &primary)
6205 .context("failed to migrate config from deepseek to codewhale home")?;
6206 tracing::info!(
6207 "Migrated config from {} to {}",
6208 legacy.display(),
6209 primary.display()
6210 );
6211 Ok(Some(ConfigMigration {
6212 legacy_path: legacy,
6213 primary_path: primary,
6214 }))
6215}
6216
6217fn parse_bool(raw: &str) -> Result<bool> {
6218 match raw.trim().to_ascii_lowercase().as_str() {
6219 "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
6220 "0" | "false" | "no" | "off" | "disabled" => Ok(false),
6221 _ => bail!("invalid boolean '{raw}'"),
6222 }
6223}
6224
6225fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
6226 let mut headers = BTreeMap::new();
6227 for pair in raw.trim().split(',') {
6228 let pair = pair.trim();
6229 if pair.is_empty() {
6230 continue;
6231 }
6232 let Some((name, value)) = pair.split_once('=') else {
6233 bail!("invalid header pair '{pair}', expected name=value");
6234 };
6235 let name = name.trim();
6236 let value = value.trim();
6237 if name.is_empty() {
6238 bail!("header name cannot be empty");
6239 }
6240 if value.is_empty() {
6241 continue;
6242 }
6243 headers.insert(name.to_string(), value.to_string());
6244 }
6245 Ok(headers)
6246}
6247
6248fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
6249 if headers.is_empty() {
6250 return None;
6251 }
6252 Some(
6253 headers
6254 .iter()
6255 .map(|(name, value)| format!("{name}={value}"))
6256 .collect::<Vec<_>>()
6257 .join(","),
6258 )
6259}
6260
6261fn serialize_http_headers_for_display(headers: &BTreeMap<String, String>) -> Option<String> {
6262 if headers.is_empty() {
6263 return None;
6264 }
6265 Some(
6266 headers
6267 .iter()
6268 .map(|(name, value)| {
6269 let display_value = if is_sensitive_config_key(name) {
6270 redact_secret(value)
6271 } else {
6272 value.clone()
6273 };
6274 format!("{name}={display_value}")
6275 })
6276 .collect::<Vec<_>>()
6277 .join(","),
6278 )
6279}
6280
6281fn redact_secret(secret: &str) -> String {
6282 let chars: Vec<char> = secret.chars().collect();
6283 if chars.len() <= 16 {
6284 return "********".to_string();
6285 }
6286 let prefix: String = chars.iter().take(4).collect();
6287 let suffix: String = chars
6288 .iter()
6289 .rev()
6290 .take(4)
6291 .collect::<Vec<_>>()
6292 .into_iter()
6293 .rev()
6294 .collect();
6295 format!("{prefix}***{suffix}")
6296}
6297
6298#[must_use]
6299pub fn is_sensitive_config_key(key: &str) -> bool {
6300 let Some(segment) = key.rsplit('.').next() else {
6301 return false;
6302 };
6303 let normalized = segment
6304 .trim()
6305 .trim_matches('"')
6306 .replace('-', "_")
6307 .to_ascii_lowercase();
6308
6309 matches!(
6310 normalized.as_str(),
6311 "api_key"
6312 | "apikey"
6313 | "api_keys"
6314 | "authorization"
6315 | "bearer"
6316 | "client_secret"
6317 | "credential"
6318 | "credentials"
6319 | "id_token"
6320 | "password"
6321 | "passwords"
6322 | "passwd"
6323 | "proxy_authorization"
6324 | "refresh_token"
6325 | "secret"
6326 | "secrets"
6327 | "token"
6328 | "tokens"
6329 ) || normalized.ends_with("_api_key")
6330 || normalized.ends_with("_authorization")
6331 || normalized.ends_with("_password")
6332 || normalized.ends_with("_secret")
6333 || normalized.ends_with("_token")
6334}
6335
6336fn redact_toml_value_for_display(key: &str, value: &toml::Value) -> String {
6337 redact_toml_value_for_display_inner(key, false, value).to_string()
6338}
6339
6340fn toml_value_as_u64(value: &toml::Value) -> Option<u64> {
6341 match value {
6342 toml::Value::Integer(value) => u64::try_from(*value).ok(),
6343 toml::Value::String(value) => value.trim().parse().ok(),
6344 _ => None,
6345 }
6346}
6347
6348fn redact_toml_value_for_display_inner(
6349 key: &str,
6350 sensitive_ancestor: bool,
6351 value: &toml::Value,
6352) -> toml::Value {
6353 let sensitive = sensitive_ancestor || is_sensitive_config_key(key);
6354 match value {
6355 toml::Value::String(value) if sensitive => toml::Value::String(redact_secret(value)),
6356 toml::Value::Array(values) => toml::Value::Array(
6357 values
6358 .iter()
6359 .map(|value| redact_toml_value_for_display_inner(key, sensitive, value))
6360 .collect(),
6361 ),
6362 toml::Value::Table(table) => {
6363 let mut redacted = toml::map::Map::new();
6364 for (child_key, child_value) in table {
6365 let path = if key.is_empty() {
6366 child_key.clone()
6367 } else {
6368 format!("{key}.{child_key}")
6369 };
6370 redacted.insert(
6371 child_key.clone(),
6372 redact_toml_value_for_display_inner(&path, sensitive, child_value),
6373 );
6374 }
6375 toml::Value::Table(redacted)
6376 }
6377 _ if sensitive => toml::Value::String("********".to_string()),
6378 _ => value.clone(),
6379 }
6380}
6381
6382fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
6383 if path.as_os_str().is_empty() {
6384 bail!("config path cannot be empty");
6385 }
6386 if path
6387 .components()
6388 .any(|component| matches!(component, Component::ParentDir))
6389 {
6390 bail!("config path cannot contain '..' components");
6391 }
6392 if path.file_name().is_none() {
6393 bail!("config path must include a file name");
6394 }
6395 let absolute = if path.is_absolute() {
6396 path
6397 } else {
6398 std::env::current_dir()
6399 .context("failed to resolve current directory for config path")?
6400 .join(path)
6401 };
6402 let file_name = absolute
6403 .file_name()
6404 .map(OsString::from)
6405 .context("config path must include a file name")?;
6406 let parent = absolute
6407 .parent()
6408 .context("config path must include a parent directory")?;
6409 let parent = match parent.canonicalize() {
6410 Ok(parent) => parent,
6411 Err(err) if err.kind() == std::io::ErrorKind::NotFound => parent.to_path_buf(),
6412 Err(err) => {
6413 return Err(err).with_context(|| {
6414 format!("failed to resolve config directory {}", parent.display())
6415 });
6416 }
6417 };
6418 let normalized = parent.join(file_name);
6419 reject_path_symlink(&normalized)?;
6420 Ok(normalized)
6421}
6422
6423fn normalize_project_workspace(workspace: &Path) -> Result<PathBuf> {
6424 if workspace.as_os_str().is_empty() {
6425 bail!("project workspace path cannot be empty");
6426 }
6427 if workspace
6428 .components()
6429 .any(|component| matches!(component, Component::ParentDir))
6430 {
6431 bail!("project workspace path cannot contain '..' components");
6432 }
6433 let absolute = if workspace.is_absolute() {
6434 workspace.to_path_buf()
6435 } else {
6436 std::env::current_dir()
6437 .context("failed to resolve current directory for project workspace")?
6438 .join(workspace)
6439 };
6440 match absolute.canonicalize() {
6441 Ok(path) => Ok(path),
6442 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
6443 Ok(normalize_path_components(&absolute))
6444 }
6445 Err(err) => Err(err).with_context(|| {
6446 format!(
6447 "failed to resolve project workspace {}",
6448 workspace.display()
6449 )
6450 }),
6451 }
6452}
6453
6454fn normalize_path_components(path: &Path) -> PathBuf {
6455 let mut normalized = PathBuf::new();
6456 for component in path.components() {
6457 match component {
6458 Component::Prefix(_) | Component::RootDir => normalized.push(component.as_os_str()),
6459 Component::CurDir => {}
6460 Component::ParentDir => {
6461 normalized.pop();
6462 }
6463 Component::Normal(part) => normalized.push(part),
6464 }
6465 }
6466 if normalized.as_os_str().is_empty() {
6467 PathBuf::from(".")
6468 } else {
6469 normalized
6470 }
6471}
6472
6473fn checked_path_exists(path: &Path) -> Result<bool> {
6474 let path = normalize_config_file_path(path.to_path_buf())?;
6475 path.try_exists()
6476 .with_context(|| format!("failed to inspect config path {}", path.display()))
6477}
6478
6479fn read_checked_config_file(path: &Path) -> Result<String> {
6480 read_checked_toml_file(path, "config")
6481}
6482
6483fn read_checked_permissions_file(path: &Path) -> Result<String> {
6484 read_checked_toml_file(path, "permissions")
6485}
6486
6487fn read_checked_toml_file(path: &Path, label: &str) -> Result<String> {
6488 let path = normalize_config_file_path(path.to_path_buf())?;
6489 read_string_no_follow(&path)
6490 .with_context(|| format!("failed to read {label} at {}", path.display()))
6491}
6492
6493#[cfg(unix)]
6494fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
6495 let mut file = fs::OpenOptions::new()
6496 .read(true)
6497 .custom_flags(libc::O_NOFOLLOW)
6498 .open(path)?;
6499 let mut raw = String::new();
6500 file.read_to_string(&mut raw)?;
6501 Ok(raw)
6502}
6503
6504#[cfg(not(unix))]
6505fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
6506 fs::read_to_string(path)
6507}
6508
6509fn reject_path_symlink(path: &Path) -> Result<()> {
6510 let Ok(metadata) = fs::symlink_metadata(path) else {
6511 return Ok(());
6512 };
6513 if metadata.file_type().is_symlink() {
6514 bail!("config path must not be a symlink: {}", path.display());
6515 }
6516 Ok(())
6517}
6518
6519#[derive(Debug, Clone, Default)]
6520struct EnvRuntimeOverrides {
6521 provider: Option<ProviderKind>,
6522 provider_source: Option<&'static str>,
6523 model: Option<String>,
6524 volcengine_model: Option<String>,
6525 wanjie_ark_model: Option<String>,
6526 openrouter_model: Option<String>,
6527 orcarouter_model: Option<String>,
6528 moonshot_model: Option<String>,
6529 xiaomi_mimo_model: Option<String>,
6530 xiaomi_mimo_mode: Option<String>,
6531 novita_model: Option<String>,
6532 fireworks_model: Option<String>,
6533 arcee_model: Option<String>,
6534 output_mode: Option<String>,
6535 auth_mode: Option<String>,
6536 log_level: Option<String>,
6537 telemetry: Option<bool>,
6538 telemetry_env_invalid: bool,
6543 telemetry_floor: bool,
6548 telemetry_endpoint: Option<String>,
6552 approval_policy: Option<String>,
6553 sandbox_mode: Option<String>,
6554 yolo: Option<bool>,
6555 verbosity: Option<String>,
6556 http_headers: Option<BTreeMap<String, String>>,
6557 deepseek_base_url: Option<String>,
6558 deepseek_anthropic_base_url: Option<String>,
6559 nvidia_base_url: Option<String>,
6560 openai_base_url: Option<String>,
6561 atlascloud_base_url: Option<String>,
6562 volcengine_base_url: Option<String>,
6563 wanjie_ark_base_url: Option<String>,
6564 openrouter_base_url: Option<String>,
6565 orcarouter_base_url: Option<String>,
6566 xiaomi_mimo_base_url: Option<String>,
6567 novita_base_url: Option<String>,
6568 fireworks_base_url: Option<String>,
6569 siliconflow_base_url: Option<String>,
6570 siliconflow_model: Option<String>,
6571 arcee_base_url: Option<String>,
6572 moonshot_base_url: Option<String>,
6573 sglang_base_url: Option<String>,
6574 vllm_base_url: Option<String>,
6575 ollama_base_url: Option<String>,
6576 huggingface_base_url: Option<String>,
6577 huggingface_model: Option<String>,
6578 together_base_url: Option<String>,
6579 together_model: Option<String>,
6580 qianfan_base_url: Option<String>,
6581 qianfan_model: Option<String>,
6582 openai_codex_base_url: Option<String>,
6583 openai_codex_model: Option<String>,
6584 anthropic_base_url: Option<String>,
6585 anthropic_model: Option<String>,
6586 openmodel_base_url: Option<String>,
6587 openmodel_model: Option<String>,
6588 zai_base_url: Option<String>,
6589 zai_model: Option<String>,
6590 stepfun_base_url: Option<String>,
6591 stepfun_model: Option<String>,
6592 minimax_base_url: Option<String>,
6593 minimax_anthropic_base_url: Option<String>,
6594 minimax_model: Option<String>,
6595 deepinfra_base_url: Option<String>,
6596 deepinfra_model: Option<String>,
6597 sakana_base_url: Option<String>,
6598 sakana_model: Option<String>,
6599 longcat_base_url: Option<String>,
6600 longcat_model: Option<String>,
6601 opencode_go_base_url: Option<String>,
6602 opencode_go_model: Option<String>,
6603 opencode_zen_base_url: Option<String>,
6604 opencode_zen_model: Option<String>,
6605 meta_base_url: Option<String>,
6606 meta_model: Option<String>,
6607 xai_base_url: Option<String>,
6608 xai_model: Option<String>,
6609 mistral_base_url: Option<String>,
6610 mistral_model: Option<String>,
6611 telecomjs_base_url: Option<String>,
6612 telecomjs_model: Option<String>,
6613 modelstudio_token_plan_base_url: Option<String>,
6614 modelstudio_token_plan_model: Option<String>,
6615 modelstudio_coding_plan_base_url: Option<String>,
6616 modelstudio_coding_plan_model: Option<String>,
6617}
6618
6619impl EnvRuntimeOverrides {
6620 fn load() -> Self {
6621 let (provider, provider_source) = Self::load_provider();
6622 let (telemetry, telemetry_env_invalid) = Self::load_telemetry();
6623 let telemetry_floor = telemetry_floor_in_force();
6624 Self {
6625 provider,
6626 provider_source,
6627 model: std::env::var("CODEWHALE_MODEL")
6628 .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
6629 .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
6630 .ok()
6631 .filter(|v| !v.trim().is_empty()),
6632 volcengine_model: std::env::var("VOLCENGINE_MODEL")
6633 .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
6634 .ok()
6635 .filter(|v| !v.trim().is_empty()),
6636 wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
6637 .or_else(|_| std::env::var("WANJIE_MODEL"))
6638 .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
6639 .ok()
6640 .filter(|v| !v.trim().is_empty()),
6641 openrouter_model: std::env::var("OPENROUTER_MODEL")
6642 .ok()
6643 .filter(|v| !v.trim().is_empty()),
6644 orcarouter_model: std::env::var("ORCAROUTER_MODEL")
6645 .ok()
6646 .filter(|v| !v.trim().is_empty()),
6647 moonshot_model: std::env::var("MOONSHOT_MODEL")
6648 .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
6649 .or_else(|_| std::env::var("KIMI_MODEL"))
6650 .ok()
6651 .filter(|v| !v.trim().is_empty()),
6652 xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
6653 .or_else(|_| std::env::var("MIMO_MODEL"))
6654 .ok()
6655 .filter(|v| !v.trim().is_empty()),
6656 xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
6657 .or_else(|_| std::env::var("MIMO_MODE"))
6658 .ok()
6659 .filter(|v| !v.trim().is_empty()),
6660 novita_model: std::env::var("NOVITA_MODEL")
6661 .ok()
6662 .filter(|v| !v.trim().is_empty()),
6663 fireworks_model: std::env::var("FIREWORKS_MODEL")
6664 .ok()
6665 .filter(|v| !v.trim().is_empty()),
6666 arcee_model: std::env::var("ARCEE_MODEL")
6667 .ok()
6668 .filter(|v| !v.trim().is_empty()),
6669 verbosity: std::env::var("CODEWHALE_VERBOSITY")
6670 .or_else(|_| std::env::var("DEEPSEEK_VERBOSITY"))
6671 .ok(),
6672 output_mode: std::env::var("CODEWHALE_OUTPUT_MODE")
6673 .or_else(|_| std::env::var("DEEPSEEK_OUTPUT_MODE"))
6674 .ok(),
6675 auth_mode: std::env::var("CODEWHALE_AUTH_MODE")
6676 .or_else(|_| std::env::var("DEEPSEEK_AUTH_MODE"))
6677 .ok(),
6678 log_level: std::env::var("CODEWHALE_LOG_LEVEL")
6679 .or_else(|_| std::env::var("DEEPSEEK_LOG_LEVEL"))
6680 .ok(),
6681 telemetry,
6682 telemetry_env_invalid,
6683 telemetry_floor,
6684 telemetry_endpoint: std::env::var("CODEWHALE_TELEMETRY_ENDPOINT")
6691 .or_else(|_| std::env::var("DEEPSEEK_TELEMETRY_ENDPOINT"))
6692 .ok(),
6693 approval_policy: std::env::var("CODEWHALE_APPROVAL_POLICY")
6694 .or_else(|_| std::env::var("DEEPSEEK_APPROVAL_POLICY"))
6695 .ok(),
6696 sandbox_mode: std::env::var("CODEWHALE_SANDBOX_MODE")
6697 .or_else(|_| std::env::var("DEEPSEEK_SANDBOX_MODE"))
6698 .ok(),
6699 yolo: std::env::var("CODEWHALE_YOLO")
6700 .or_else(|_| std::env::var("DEEPSEEK_YOLO"))
6701 .ok()
6702 .and_then(|v| match parse_bool(&v) {
6703 Ok(b) => Some(b),
6704 Err(_) => {
6705 tracing::warn!("Invalid CODEWHALE_YOLO/DEEPSEEK_YOLO value '{v}', expected true/false");
6706 None
6707 }
6708 }),
6709 http_headers: std::env::var("CODEWHALE_HTTP_HEADERS")
6710 .or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
6711 .ok()
6712 .and_then(|value| match parse_http_headers(&value) {
6713 Ok(h) => Some(h),
6714 Err(_) => {
6715 tracing::warn!("Invalid CODEWHALE_HTTP_HEADERS/DEEPSEEK_HTTP_HEADERS value, expected format: header1=val1,header2=val2");
6716 None
6717 }
6718 })
6719 .filter(|headers| !headers.is_empty()),
6720 deepseek_base_url: std::env::var("CODEWHALE_BASE_URL")
6721 .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
6722 .ok()
6723 .filter(|v| !v.trim().is_empty()),
6724 deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
6725 .or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
6726 .ok()
6727 .filter(|v| !v.trim().is_empty()),
6728 nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
6729 .or_else(|_| std::env::var("NIM_BASE_URL"))
6730 .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
6731 .ok()
6732 .filter(|v| !v.trim().is_empty()),
6733 openai_base_url: std::env::var("OPENAI_BASE_URL")
6734 .ok()
6735 .filter(|v| !v.trim().is_empty()),
6736 atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
6737 .ok()
6738 .filter(|v| !v.trim().is_empty()),
6739 volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
6740 .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
6741 .or_else(|_| std::env::var("ARK_BASE_URL"))
6742 .ok()
6743 .filter(|v| !v.trim().is_empty()),
6744 wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
6745 .or_else(|_| std::env::var("WANJIE_BASE_URL"))
6746 .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
6747 .ok()
6748 .filter(|v| !v.trim().is_empty()),
6749 openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
6750 .ok()
6751 .filter(|v| !v.trim().is_empty()),
6752 orcarouter_base_url: std::env::var("ORCAROUTER_BASE_URL")
6753 .ok()
6754 .filter(|v| !v.trim().is_empty()),
6755 xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
6756 .or_else(|_| std::env::var("MIMO_BASE_URL"))
6757 .ok()
6758 .filter(|v| !v.trim().is_empty()),
6759 novita_base_url: std::env::var("NOVITA_BASE_URL")
6760 .ok()
6761 .filter(|v| !v.trim().is_empty()),
6762 fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
6763 .ok()
6764 .filter(|v| !v.trim().is_empty()),
6765 siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
6766 .ok()
6767 .filter(|v| !v.trim().is_empty()),
6768 siliconflow_model: std::env::var("SILICONFLOW_MODEL")
6769 .ok()
6770 .filter(|v| !v.trim().is_empty()),
6771 arcee_base_url: std::env::var("ARCEE_BASE_URL")
6772 .ok()
6773 .filter(|v| !v.trim().is_empty()),
6774 moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
6775 .or_else(|_| std::env::var("KIMI_BASE_URL"))
6776 .ok()
6777 .filter(|v| !v.trim().is_empty()),
6778 sglang_base_url: std::env::var("SGLANG_BASE_URL")
6779 .ok()
6780 .filter(|v| !v.trim().is_empty()),
6781 vllm_base_url: std::env::var("VLLM_BASE_URL")
6782 .ok()
6783 .filter(|v| !v.trim().is_empty()),
6784 ollama_base_url: std::env::var("OLLAMA_BASE_URL")
6785 .ok()
6786 .filter(|v| !v.trim().is_empty()),
6787 huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
6788 .or_else(|_| std::env::var("HF_BASE_URL"))
6789 .ok()
6790 .filter(|v| !v.trim().is_empty()),
6791 huggingface_model: std::env::var("HUGGINGFACE_MODEL")
6792 .or_else(|_| std::env::var("HF_MODEL"))
6793 .ok()
6794 .filter(|v| !v.trim().is_empty()),
6795 together_base_url: std::env::var("TOGETHER_BASE_URL")
6796 .ok()
6797 .filter(|v| !v.trim().is_empty()),
6798 together_model: std::env::var("TOGETHER_MODEL")
6799 .ok()
6800 .filter(|v| !v.trim().is_empty()),
6801 qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
6802 .ok()
6803 .filter(|v| !v.trim().is_empty())
6804 .or_else(|| {
6805 std::env::var("BAIDU_QIANFAN_BASE_URL")
6806 .ok()
6807 .filter(|v| !v.trim().is_empty())
6808 }),
6809 qianfan_model: std::env::var("QIANFAN_MODEL")
6810 .ok()
6811 .filter(|v| !v.trim().is_empty())
6812 .or_else(|| {
6813 std::env::var("BAIDU_QIANFAN_MODEL")
6814 .ok()
6815 .filter(|v| !v.trim().is_empty())
6816 }),
6817 openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
6818 .or_else(|_| std::env::var("CODEX_BASE_URL"))
6819 .ok()
6820 .filter(|v| !v.trim().is_empty()),
6821 openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
6822 .or_else(|_| std::env::var("CODEX_MODEL"))
6823 .ok()
6824 .filter(|v| !v.trim().is_empty()),
6825 anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
6826 .ok()
6827 .filter(|v| !v.trim().is_empty()),
6828 anthropic_model: std::env::var("ANTHROPIC_MODEL")
6829 .ok()
6830 .filter(|v| !v.trim().is_empty()),
6831 openmodel_base_url: std::env::var("OPENMODEL_BASE_URL")
6832 .ok()
6833 .filter(|v| !v.trim().is_empty()),
6834 openmodel_model: std::env::var("OPENMODEL_MODEL")
6835 .ok()
6836 .filter(|v| !v.trim().is_empty()),
6837 zai_base_url: std::env::var("ZAI_BASE_URL")
6838 .or_else(|_| std::env::var("Z_AI_BASE_URL"))
6839 .or_else(|_| std::env::var("ZHIPU_BASE_URL"))
6840 .or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
6841 .or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
6842 .ok()
6843 .filter(|v| !v.trim().is_empty()),
6844 zai_model: std::env::var("ZAI_MODEL")
6845 .or_else(|_| std::env::var("Z_AI_MODEL"))
6846 .or_else(|_| std::env::var("ZHIPU_MODEL"))
6847 .or_else(|_| std::env::var("ZHIPUAI_MODEL"))
6848 .or_else(|_| std::env::var("BIGMODEL_MODEL"))
6849 .or_else(|_| std::env::var("GLM_MODEL"))
6850 .ok()
6851 .filter(|v| !v.trim().is_empty()),
6852 stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
6853 .or_else(|_| std::env::var("STEP_BASE_URL"))
6854 .ok()
6855 .filter(|v| !v.trim().is_empty()),
6856 stepfun_model: std::env::var("STEPFUN_MODEL")
6857 .or_else(|_| std::env::var("STEP_MODEL"))
6858 .ok()
6859 .filter(|v| !v.trim().is_empty()),
6860 minimax_base_url: std::env::var("MINIMAX_BASE_URL")
6861 .ok()
6862 .filter(|v| !v.trim().is_empty()),
6863 minimax_anthropic_base_url: std::env::var("MINIMAX_ANTHROPIC_BASE_URL")
6864 .ok()
6865 .filter(|v| !v.trim().is_empty()),
6866 minimax_model: std::env::var("MINIMAX_MODEL")
6867 .ok()
6868 .filter(|v| !v.trim().is_empty()),
6869 deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
6870 .ok()
6871 .filter(|v| !v.trim().is_empty()),
6872 deepinfra_model: std::env::var("DEEPINFRA_MODEL")
6873 .ok()
6874 .filter(|v| !v.trim().is_empty()),
6875 sakana_base_url: std::env::var("SAKANA_BASE_URL")
6876 .ok()
6877 .filter(|v| !v.trim().is_empty()),
6878 sakana_model: std::env::var("SAKANA_MODEL")
6879 .ok()
6880 .filter(|v| !v.trim().is_empty()),
6881 longcat_base_url: std::env::var("LONGCAT_BASE_URL")
6882 .ok()
6883 .filter(|v| !v.trim().is_empty()),
6884 longcat_model: std::env::var("LONGCAT_MODEL")
6885 .ok()
6886 .filter(|v| !v.trim().is_empty()),
6887 opencode_go_base_url: std::env::var("OPENCODE_GO_BASE_URL")
6888 .ok()
6889 .filter(|v| !v.trim().is_empty()),
6890 opencode_go_model: std::env::var("OPENCODE_GO_MODEL")
6891 .ok()
6892 .filter(|v| !v.trim().is_empty()),
6893 opencode_zen_base_url: std::env::var("OPENCODE_ZEN_BASE_URL")
6894 .ok()
6895 .filter(|v| !v.trim().is_empty()),
6896 opencode_zen_model: std::env::var("OPENCODE_ZEN_MODEL")
6897 .ok()
6898 .filter(|v| !v.trim().is_empty()),
6899 meta_base_url: std::env::var("META_MODEL_API_BASE_URL")
6900 .ok()
6901 .filter(|v| !v.trim().is_empty())
6902 .or_else(|| {
6903 std::env::var("MODEL_API_BASE_URL")
6904 .ok()
6905 .filter(|v| !v.trim().is_empty())
6906 }),
6907 meta_model: std::env::var("META_MODEL_API_MODEL")
6908 .ok()
6909 .filter(|v| !v.trim().is_empty())
6910 .or_else(|| {
6911 std::env::var("MODEL_API_MODEL")
6912 .ok()
6913 .filter(|v| !v.trim().is_empty())
6914 }),
6915 xai_base_url: std::env::var("XAI_BASE_URL")
6916 .ok()
6917 .filter(|v| !v.trim().is_empty()),
6918 xai_model: std::env::var("XAI_MODEL")
6919 .ok()
6920 .filter(|v| !v.trim().is_empty()),
6921 mistral_base_url: std::env::var("MISTRAL_BASE_URL")
6922 .ok()
6923 .filter(|v| !v.trim().is_empty()),
6924 mistral_model: std::env::var("MISTRAL_MODEL")
6925 .ok()
6926 .filter(|v| !v.trim().is_empty()),
6927 telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL")
6928 .ok()
6929 .filter(|v| !v.trim().is_empty()),
6930 telecomjs_model: std::env::var("TELECOMJS_MODEL")
6931 .ok()
6932 .filter(|v| !v.trim().is_empty()),
6933 modelstudio_token_plan_base_url: std::env::var("MODELSTUDIO_TOKEN_PLAN_BASE_URL")
6934 .ok()
6935 .filter(|v| !v.trim().is_empty()),
6936 modelstudio_token_plan_model: std::env::var("MODELSTUDIO_TOKEN_PLAN_MODEL")
6937 .ok()
6938 .filter(|v| !v.trim().is_empty()),
6939 modelstudio_coding_plan_base_url: std::env::var("MODELSTUDIO_CODING_PLAN_BASE_URL")
6940 .ok()
6941 .filter(|v| !v.trim().is_empty()),
6942 modelstudio_coding_plan_model: std::env::var("MODELSTUDIO_CODING_PLAN_MODEL")
6943 .ok()
6944 .filter(|v| !v.trim().is_empty()),
6945 }
6946 }
6947
6948 fn load_provider() -> (Option<ProviderKind>, Option<&'static str>) {
6949 if let Ok(value) = std::env::var("CODEWHALE_PROVIDER") {
6950 let parsed = ProviderKind::parse_config_identity(&value);
6951 return (parsed, parsed.map(|_| "CODEWHALE_PROVIDER"));
6952 }
6953
6954 if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
6955 let parsed = ProviderKind::parse_config_identity(&value);
6956 return (parsed, parsed.map(|_| "DEEPSEEK_PROVIDER"));
6957 }
6958
6959 (None, None)
6960 }
6961
6962 fn load_telemetry() -> (Option<bool>, bool) {
6969 let Some(raw) = std::env::var("CODEWHALE_TELEMETRY")
6970 .or_else(|_| std::env::var("DEEPSEEK_TELEMETRY"))
6971 .ok()
6972 else {
6973 return (None, false);
6974 };
6975 match parse_bool(&raw) {
6976 Ok(value) => (Some(value), false),
6977 Err(_) => {
6978 tracing::warn!(
6979 "Invalid CODEWHALE_TELEMETRY/DEEPSEEK_TELEMETRY value '{raw}'; expected one of \
6980 1/0, true/false, yes/no, on/off, enabled/disabled. Telemetry is forced off."
6981 );
6982 (None, true)
6983 }
6984 }
6985 }
6986
6987 fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
6988 match provider {
6991 ProviderKind::Deepseek => self.deepseek_base_url.clone(),
6992 ProviderKind::DeepseekAnthropic => self.deepseek_anthropic_base_url.clone(),
6993 ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
6994 ProviderKind::Openai => self.openai_base_url.clone(),
6995 ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
6996 ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
6997 ProviderKind::Volcengine => self.volcengine_base_url.clone(),
6998 ProviderKind::Openrouter => self.openrouter_base_url.clone(),
6999 ProviderKind::Orcarouter => self.orcarouter_base_url.clone(),
7000 ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
7001 ProviderKind::Novita => self.novita_base_url.clone(),
7002 ProviderKind::Fireworks => self.fireworks_base_url.clone(),
7003 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
7004 self.siliconflow_base_url.clone()
7005 }
7006 ProviderKind::Arcee => self.arcee_base_url.clone(),
7007 ProviderKind::Moonshot => self.moonshot_base_url.clone(),
7008 ProviderKind::Sglang => self.sglang_base_url.clone(),
7009 ProviderKind::Vllm => self.vllm_base_url.clone(),
7010 ProviderKind::Ollama => self.ollama_base_url.clone(),
7011 ProviderKind::Huggingface => self.huggingface_base_url.clone(),
7012 ProviderKind::Together => self.together_base_url.clone(),
7013 ProviderKind::Qianfan => self.qianfan_base_url.clone(),
7014 ProviderKind::OpenaiCodex => self.openai_codex_base_url.clone(),
7015 ProviderKind::Anthropic => self.anthropic_base_url.clone(),
7016 ProviderKind::Openmodel => self.openmodel_base_url.clone(),
7017 ProviderKind::Zai => self.zai_base_url.clone(),
7018 ProviderKind::Stepfun => self.stepfun_base_url.clone(),
7019 ProviderKind::Minimax => self.minimax_base_url.clone(),
7020 ProviderKind::MinimaxAnthropic => self.minimax_anthropic_base_url.clone(),
7021 ProviderKind::Deepinfra => self.deepinfra_base_url.clone(),
7022 ProviderKind::Sakana => self.sakana_base_url.clone(),
7023 ProviderKind::LongCat => self.longcat_base_url.clone(),
7024 ProviderKind::OpencodeGo => self.opencode_go_base_url.clone(),
7025 ProviderKind::OpencodeZen => self.opencode_zen_base_url.clone(),
7026 ProviderKind::Meta => self.meta_base_url.clone(),
7027 ProviderKind::Xai => self.xai_base_url.clone(),
7028 ProviderKind::Mistral => self.mistral_base_url.clone(),
7029 ProviderKind::Telecomjs => self.telecomjs_base_url.clone(),
7030 ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => {
7031 self.modelstudio_token_plan_base_url.clone()
7032 }
7033 ProviderKind::ModelstudioCodingPlan | ProviderKind::ModelstudioCodingPlanAnthropic => {
7034 self.modelstudio_coding_plan_base_url.clone()
7035 }
7036 ProviderKind::Custom => None,
7039 }
7040 }
7041
7042 fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
7043 let model = match provider {
7044 ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
7045 ProviderKind::Volcengine => self.volcengine_model.clone(),
7046 ProviderKind::Openrouter => self.openrouter_model.clone(),
7047 ProviderKind::Orcarouter => self.orcarouter_model.clone(),
7048 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
7049 self.siliconflow_model.clone()
7050 }
7051 ProviderKind::Arcee => self.arcee_model.clone(),
7052 ProviderKind::Moonshot => self.moonshot_model.clone(),
7053 ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
7054 ProviderKind::Novita => self.novita_model.clone(),
7055 ProviderKind::Fireworks => self.fireworks_model.clone(),
7056 ProviderKind::Huggingface => self.huggingface_model.clone(),
7057 ProviderKind::Together => self.together_model.clone(),
7058 ProviderKind::Qianfan => self.qianfan_model.clone(),
7059 ProviderKind::OpenaiCodex => self.openai_codex_model.clone(),
7060 ProviderKind::Anthropic => self.anthropic_model.clone(),
7061 ProviderKind::Openmodel => self.openmodel_model.clone(),
7062 ProviderKind::Zai => self.zai_model.clone(),
7063 ProviderKind::Stepfun => self.stepfun_model.clone(),
7064 ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => self.minimax_model.clone(),
7065 ProviderKind::Deepinfra => self.deepinfra_model.clone(),
7066 ProviderKind::Sakana => self.sakana_model.clone(),
7067 ProviderKind::LongCat => self.longcat_model.clone(),
7068 ProviderKind::OpencodeGo => self.opencode_go_model.clone(),
7069 ProviderKind::OpencodeZen => self.opencode_zen_model.clone(),
7070 ProviderKind::Meta => self.meta_model.clone(),
7071 ProviderKind::Xai => self.xai_model.clone(),
7072 ProviderKind::Mistral => self.mistral_model.clone(),
7073 ProviderKind::Telecomjs => self.telecomjs_model.clone(),
7074 ProviderKind::ModelstudioTokenPlan | ProviderKind::ModelstudioTokenPlanAnthropic => {
7075 self.modelstudio_token_plan_model.clone()
7076 }
7077 ProviderKind::ModelstudioCodingPlan | ProviderKind::ModelstudioCodingPlanAnthropic => {
7078 self.modelstudio_coding_plan_model.clone()
7079 }
7080 _ => None,
7081 }?;
7082
7083 if provider_preserves_custom_base_url_model(provider, base_url) {
7084 Some(model.trim().to_string())
7085 } else {
7086 Some(normalize_model_for_provider(provider, &model))
7087 }
7088 }
7089}
7090
7091#[cfg(test)]
7092mod tests;