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