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