1pub mod provider;
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::ffi::{OsStr, OsString};
5use std::fmt;
6use std::fs;
7#[cfg(unix)]
8use std::io::Read;
9use std::io::Write;
10use std::path::{Component, Path, PathBuf};
11use std::sync::OnceLock;
12
13use anyhow::{Context, Result, bail};
14pub use codewhale_execpolicy::ToolAskRule;
15use codewhale_execpolicy::{ExecPolicyEngine, Ruleset};
16use codewhale_secrets::SecretSource;
17pub use codewhale_secrets::Secrets;
18use serde::{Deserialize, Serialize};
19
20#[cfg(unix)]
21use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
22
23pub const CONFIG_FILE_NAME: &str = "config.toml";
24pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml";
25const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro";
26const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
27const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
28const DEFAULT_OPENAI_MODEL: &str = "deepseek-v4-pro";
29const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta";
30const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
31const DEFAULT_OPENAI_CODEX_MODEL: &str = "gpt-5.5";
32const DEFAULT_ANTHROPIC_MODEL: &str = "claude-sonnet-4-6";
33const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com";
34const DEFAULT_OPENAI_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api";
35const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
36const DEFAULT_ATLASCLOUD_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
37const DEFAULT_ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1";
38const DEFAULT_WANJIE_ARK_MODEL: &str = "deepseek-reasoner";
39const DEFAULT_WANJIE_ARK_BASE_URL: &str = "https://maas-openapi.wanjiedata.com/api/v1";
40const DEFAULT_VOLCENGINE_MODEL: &str = "DeepSeek-V4-Pro";
41const DEFAULT_VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/coding/v3";
42const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro";
43const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
44const OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL: &str = "arcee-ai/trinity-large-thinking";
45const OPENROUTER_GEMMA_4_31B_MODEL: &str = "google/gemma-4-31b-it";
46const OPENROUTER_GEMMA_4_26B_A4B_MODEL: &str = "google/gemma-4-26b-a4b-it";
47const OPENROUTER_GLM_5_1_MODEL: &str = "z-ai/glm-5.1";
48const OPENROUTER_GLM_5_2_MODEL: &str = "z-ai/glm-5.2";
49const OPENROUTER_KIMI_K2_7_CODE_MODEL: &str = "moonshotai/kimi-k2.7-code";
50const OPENROUTER_KIMI_K2_6_MODEL: &str = "moonshotai/kimi-k2.6";
51const OPENROUTER_MINIMAX_M3_MODEL: &str = "minimax/minimax-m3";
52const OPENROUTER_MINIMAX_2_7_MODEL: &str = "minimax/minimax-2.7";
53const OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL: &str =
54 "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free";
55const OPENROUTER_QWEN_3_6_FLASH_MODEL: &str = "qwen/qwen3.6-flash";
56const OPENROUTER_QWEN_3_6_35B_A3B_MODEL: &str = "qwen/qwen3.6-35b-a3b";
57const OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL: &str = "qwen/qwen3.6-max-preview";
58const OPENROUTER_QWEN_3_6_27B_MODEL: &str = "qwen/qwen3.6-27b";
59const OPENROUTER_QWEN_3_6_PLUS_MODEL: &str = "qwen/qwen3.6-plus";
60const OPENROUTER_QWEN_3_7_MAX_MODEL: &str = "qwen/qwen3.7-max";
61const OPENROUTER_TENCENT_HY3_PREVIEW_MODEL: &str = "tencent/hy3-preview";
62const OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL: &str = "xiaomi/mimo-v2.5-pro";
63const OPENROUTER_XIAOMI_MIMO_V2_5_MODEL: &str = "xiaomi/mimo-v2.5";
64const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro";
65const XIAOMI_MIMO_V2_5_OMNI_MODEL: &str = "mimo-v2.5";
66const XIAOMI_MIMO_ASR_MODEL: &str = "mimo-v2.5-asr";
67const XIAOMI_MIMO_TTS_MODEL: &str = "mimo-v2.5-tts";
68const XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL: &str = "mimo-v2.5-tts-voicedesign";
69const XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL: &str = "mimo-v2.5-tts-voiceclone";
70const XIAOMI_MIMO_V2_TTS_MODEL: &str = "mimo-v2-tts";
71const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro";
72const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
73const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro";
74const DEFAULT_SILICONFLOW_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
75const DEFAULT_SILICONFLOW_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
76const DEFAULT_ARCEE_MODEL: &str = "trinity-large-thinking";
77const ARCEE_TRINITY_LARGE_PREVIEW_MODEL: &str = "trinity-large-preview";
78const ARCEE_TRINITY_MINI_MODEL: &str = "trinity-mini";
79const DEFAULT_MOONSHOT_MODEL: &str = "kimi-k2.7-code";
80const MOONSHOT_KIMI_K2_6_MODEL: &str = "kimi-k2.6";
81const DEFAULT_MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1";
82const DEFAULT_KIMI_CODE_MODEL: &str = "kimi-for-coding";
83const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding/v1";
84const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
85const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
86const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
87const XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL: &str = "https://api.xiaomimimo.com/v1";
88const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://token-plan-sgp.xiaomimimo.com/v1";
89const XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL: &str = "https://token-plan-cn.xiaomimimo.com/v1";
90const XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL: &str = DEFAULT_XIAOMI_MIMO_BASE_URL;
91const XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL: &str = "https://token-plan-ams.xiaomimimo.com/v1";
92const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/openai/v1";
93const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
94const DEFAULT_SILICONFLOW_BASE_URL: &str = "https://api.siliconflow.com/v1";
95const DEFAULT_SILICONFLOW_CN_BASE_URL: &str = "https://api.siliconflow.cn/v1";
96const DEFAULT_ARCEE_BASE_URL: &str = "https://api.arcee.ai/api/v1";
97const DEFAULT_HUGGINGFACE_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
98const DEFAULT_HUGGINGFACE_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
99const DEFAULT_HUGGINGFACE_BASE_URL: &str = "https://router.huggingface.co/v1";
100const DEFAULT_TOGETHER_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
101const DEFAULT_TOGETHER_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
102const DEFAULT_TOGETHER_BASE_URL: &str = "https://api.together.xyz/v1";
103const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1";
104const DEFAULT_VLLM_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
105const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
106const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1";
107const DEFAULT_OLLAMA_MODEL: &str = "deepseek-coder:1.3b";
108const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
109
110const DEFAULT_ZAI_MODEL: &str = "GLM-5.2";
112const ZAI_GLM_5_1_MODEL: &str = "GLM-5.1";
113#[allow(dead_code)]
117const ZAI_GLM_5_2_MODEL: &str = "GLM-5.2";
118const ZAI_GLM_5_TURBO_MODEL: &str = "GLM-5-Turbo";
119const DEFAULT_ZAI_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
120const DEFAULT_STEPFUN_MODEL: &str = "step-3.7-flash";
122const DEFAULT_STEPFUN_BASE_URL: &str = "https://api.stepfun.ai/v1";
123const DEFAULT_MINIMAX_MODEL: &str = "MiniMax-M3";
125const MINIMAX_M2_7_MODEL: &str = "MiniMax-M2.7";
126const MINIMAX_M2_7_HIGHSPEED_MODEL: &str = "MiniMax-M2.7-highspeed";
127const MINIMAX_M2_5_MODEL: &str = "MiniMax-M2.5";
128const MINIMAX_M2_5_HIGHSPEED_MODEL: &str = "MiniMax-M2.5-highspeed";
129const MINIMAX_M2_1_MODEL: &str = "MiniMax-M2.1";
130const MINIMAX_M2_1_HIGHSPEED_MODEL: &str = "MiniMax-M2.1-highspeed";
131const MINIMAX_M2_MODEL: &str = "MiniMax-M2";
132const DEFAULT_MINIMAX_BASE_URL: &str = "https://api.minimax.io/v1";
133const DEFAULT_DEEPINFRA_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
134const DEFAULT_DEEPINFRA_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
135const DEFAULT_DEEPINFRA_BASE_URL: &str = "https://api.deepinfra.com/v1/openai";
136
137#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
138#[serde(rename_all = "kebab-case")]
139pub enum ProviderKind {
140 #[default]
141 #[serde(
142 alias = "deepseek-cn",
143 alias = "deepseek_china",
144 alias = "deepseekcn",
145 alias = "deepseek-china"
146 )]
147 Deepseek,
148 NvidiaNim,
149 #[serde(alias = "open-ai")]
150 Openai,
151 Atlascloud,
152 #[serde(
153 alias = "wanjie",
154 alias = "wanjie_ark",
155 alias = "ark-wanjie",
156 alias = "ark_wanjie",
157 alias = "wanjie-maas",
158 alias = "wanjie_maas"
159 )]
160 WanjieArk,
161 #[serde(alias = "volcengine-ark", alias = "volcengine_ark", alias = "ark")]
162 Volcengine,
163 Openrouter,
164 #[serde(alias = "mimo", alias = "xiaomi", alias = "xiaomi_mimo")]
165 XiaomiMimo,
166 Novita,
167 Fireworks,
168 #[serde(alias = "silicon-flow", alias = "silicon_flow")]
169 Siliconflow,
170 #[serde(alias = "arcee-ai", alias = "arcee_ai")]
171 Arcee,
172 #[serde(alias = "siliconflow-cn", alias = "siliconflow-CN")]
173 SiliconflowCN,
174 Moonshot,
175 Sglang,
176 Vllm,
177 Ollama,
178 #[serde(alias = "hugging-face", alias = "hugging_face", alias = "hf")]
179 Huggingface,
180 #[serde(alias = "together-ai", alias = "together_ai")]
181 Together,
182 #[serde(
183 alias = "openai-codex",
184 alias = "openai_codex",
185 alias = "codex",
186 alias = "chatgpt",
187 alias = "chatgpt-codex",
188 alias = "chatgpt_codex"
189 )]
190 OpenaiCodex,
191 #[serde(alias = "claude")]
192 Anthropic,
193 #[serde(alias = "z-ai", alias = "z_ai", alias = "z.ai")]
194 Zai,
195 #[serde(
196 alias = "step-fun",
197 alias = "step_fun",
198 alias = "stepfun",
199 alias = "stepflash",
200 alias = "step-flash",
201 alias = "step_flash"
202 )]
203 Stepfun,
204 #[serde(alias = "mini-max", alias = "mini_max", alias = "minimax")]
205 Minimax,
206 #[serde(alias = "deep-infra", alias = "deep_infra")]
207 Deepinfra,
208}
209
210impl ProviderKind {
211 pub const ALL: [Self; 25] = [
212 Self::Deepseek,
213 Self::NvidiaNim,
214 Self::Openai,
215 Self::Atlascloud,
216 Self::WanjieArk,
217 Self::Volcengine,
218 Self::Openrouter,
219 Self::XiaomiMimo,
220 Self::Novita,
221 Self::Fireworks,
222 Self::Siliconflow,
223 Self::Arcee,
224 Self::SiliconflowCN,
225 Self::Moonshot,
226 Self::Sglang,
227 Self::Vllm,
228 Self::Ollama,
229 Self::Huggingface,
230 Self::Together,
231 Self::OpenaiCodex,
232 Self::Anthropic,
233 Self::Zai,
234 Self::Stepfun,
235 Self::Minimax,
236 Self::Deepinfra,
237 ];
238
239 #[must_use]
240 pub fn all() -> &'static [Self] {
241 &Self::ALL
242 }
243
244 #[must_use]
245 pub fn names_hint() -> String {
246 Self::all()
247 .iter()
248 .map(|provider| provider.as_str())
249 .collect::<Vec<_>>()
250 .join(", ")
251 }
252
253 #[must_use]
254 pub fn as_str(self) -> &'static str {
255 self.provider().id()
256 }
257
258 #[must_use]
259 pub fn parse(value: &str) -> Option<Self> {
260 let trimmed = value.trim();
261 provider::all_providers()
262 .iter()
263 .find(|p| {
264 trimmed.eq_ignore_ascii_case(p.id())
265 || p.aliases().iter().any(|a| trimmed.eq_ignore_ascii_case(a))
266 })
267 .map(|p| p.kind())
268 }
269
270 #[must_use]
271 pub fn is_siliconflow(self) -> bool {
272 matches!(self, Self::Siliconflow | Self::SiliconflowCN)
273 }
274
275 #[must_use]
280 pub fn provider(self) -> &'static dyn provider::Provider {
281 provider::provider_for_kind(self)
282 }
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize, Default)]
286pub struct ProviderConfigToml {
287 pub api_key: Option<String>,
288 pub base_url: Option<String>,
289 pub model: Option<String>,
290 pub mode: Option<String>,
291 pub auth_mode: Option<String>,
292 pub insecure_skip_tls_verify: Option<bool>,
293 #[serde(default)]
294 pub http_headers: BTreeMap<String, String>,
295 pub path_suffix: Option<String>,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize, Default)]
299pub struct ProvidersToml {
300 #[serde(default)]
301 pub deepseek: ProviderConfigToml,
302 #[serde(default)]
303 pub nvidia_nim: ProviderConfigToml,
304 #[serde(default)]
305 pub openai: ProviderConfigToml,
306 #[serde(default)]
307 pub atlascloud: ProviderConfigToml,
308 #[serde(default)]
309 pub wanjie_ark: ProviderConfigToml,
310 #[serde(default)]
311 pub volcengine: ProviderConfigToml,
312 #[serde(default)]
313 pub openrouter: ProviderConfigToml,
314 #[serde(default, alias = "xiaomi", alias = "mimo", alias = "xiaomimimo")]
315 pub xiaomi_mimo: ProviderConfigToml,
316 #[serde(default)]
317 pub novita: ProviderConfigToml,
318 #[serde(default)]
319 pub fireworks: ProviderConfigToml,
320 #[serde(default)]
321 pub siliconflow: ProviderConfigToml,
322 #[serde(default, alias = "siliconflow-CN", alias = "siliconflow-cn")]
323 pub siliconflow_cn: ProviderConfigToml,
324 #[serde(default)]
325 pub arcee: ProviderConfigToml,
326 #[serde(default)]
327 pub moonshot: ProviderConfigToml,
328 #[serde(default)]
329 pub sglang: ProviderConfigToml,
330 #[serde(default)]
331 pub vllm: ProviderConfigToml,
332 #[serde(default)]
333 pub ollama: ProviderConfigToml,
334 #[serde(default)]
335 pub huggingface: ProviderConfigToml,
336 #[serde(default)]
337 pub together: ProviderConfigToml,
338 #[serde(
339 default,
340 alias = "openai-codex",
341 alias = "openai_codex",
342 alias = "codex",
343 alias = "chatgpt",
344 alias = "chatgpt-codex"
345 )]
346 pub openai_codex: ProviderConfigToml,
347 #[serde(default)]
348 pub anthropic: ProviderConfigToml,
349 #[serde(default, alias = "z-ai", alias = "z_ai", alias = "z.ai")]
350 pub zai: ProviderConfigToml,
351 #[serde(
352 default,
353 alias = "step-fun",
354 alias = "step_fun",
355 alias = "stepfun",
356 alias = "stepflash",
357 alias = "step-flash",
358 alias = "step_flash"
359 )]
360 pub stepfun: ProviderConfigToml,
361 #[serde(default, alias = "mini-max", alias = "mini_max", alias = "minimax")]
362 pub minimax: ProviderConfigToml,
363 #[serde(default, alias = "deep-infra", alias = "deep_infra")]
364 pub deepinfra: ProviderConfigToml,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
373#[serde(deny_unknown_fields)]
374pub struct PermissionsToml {
375 #[serde(default, skip_serializing_if = "Vec::is_empty")]
376 pub rules: Vec<ToolAskRule>,
377}
378
379impl PermissionsToml {
380 #[must_use]
381 pub fn is_empty(&self) -> bool {
382 self.rules.is_empty()
383 }
384
385 #[must_use]
386 pub fn ruleset(&self) -> Ruleset {
387 Ruleset::user(Vec::new(), Vec::new()).with_ask_rules(self.rules.clone())
388 }
389}
390
391impl ProvidersToml {
392 #[must_use]
393 pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
394 match provider {
395 ProviderKind::Deepseek => &self.deepseek,
396 ProviderKind::NvidiaNim => &self.nvidia_nim,
397 ProviderKind::Openai => &self.openai,
398 ProviderKind::Atlascloud => &self.atlascloud,
399 ProviderKind::WanjieArk => &self.wanjie_ark,
400 ProviderKind::Volcengine => &self.volcengine,
401 ProviderKind::Openrouter => &self.openrouter,
402 ProviderKind::XiaomiMimo => &self.xiaomi_mimo,
403 ProviderKind::Novita => &self.novita,
404 ProviderKind::Fireworks => &self.fireworks,
405 ProviderKind::Siliconflow => &self.siliconflow,
406 ProviderKind::SiliconflowCN => &self.siliconflow_cn,
407 ProviderKind::Arcee => &self.arcee,
408 ProviderKind::Moonshot => &self.moonshot,
409 ProviderKind::Sglang => &self.sglang,
410 ProviderKind::Vllm => &self.vllm,
411 ProviderKind::Ollama => &self.ollama,
412 ProviderKind::Huggingface => &self.huggingface,
413 ProviderKind::Together => &self.together,
414 ProviderKind::OpenaiCodex => &self.openai_codex,
415 ProviderKind::Anthropic => &self.anthropic,
416 ProviderKind::Zai => &self.zai,
417 ProviderKind::Stepfun => &self.stepfun,
418 ProviderKind::Minimax => &self.minimax,
419 ProviderKind::Deepinfra => &self.deepinfra,
420 }
421 }
422
423 pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
424 match provider {
425 ProviderKind::Deepseek => &mut self.deepseek,
426 ProviderKind::NvidiaNim => &mut self.nvidia_nim,
427 ProviderKind::Openai => &mut self.openai,
428 ProviderKind::Atlascloud => &mut self.atlascloud,
429 ProviderKind::WanjieArk => &mut self.wanjie_ark,
430 ProviderKind::Volcengine => &mut self.volcengine,
431 ProviderKind::Openrouter => &mut self.openrouter,
432 ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo,
433 ProviderKind::Novita => &mut self.novita,
434 ProviderKind::Fireworks => &mut self.fireworks,
435 ProviderKind::Siliconflow => &mut self.siliconflow,
436 ProviderKind::SiliconflowCN => &mut self.siliconflow_cn,
437 ProviderKind::Arcee => &mut self.arcee,
438 ProviderKind::Moonshot => &mut self.moonshot,
439 ProviderKind::Sglang => &mut self.sglang,
440 ProviderKind::Vllm => &mut self.vllm,
441 ProviderKind::Ollama => &mut self.ollama,
442 ProviderKind::Huggingface => &mut self.huggingface,
443 ProviderKind::Together => &mut self.together,
444 ProviderKind::OpenaiCodex => &mut self.openai_codex,
445 ProviderKind::Anthropic => &mut self.anthropic,
446 ProviderKind::Zai => &mut self.zai,
447 ProviderKind::Stepfun => &mut self.stepfun,
448 ProviderKind::Minimax => &mut self.minimax,
449 ProviderKind::Deepinfra => &mut self.deepinfra,
450 }
451 }
452}
453
454#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
462#[serde(rename_all = "kebab-case")]
463pub enum HarnessPostureKind {
464 #[default]
467 Standard,
468 CacheHeavy,
470 Lean,
473 Custom,
475}
476
477#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
479#[serde(rename_all = "kebab-case")]
480pub enum HarnessCompactionStrategy {
481 #[default]
482 Default,
483 PrefixCache,
484 Aggressive,
485}
486
487#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
489#[serde(rename_all = "kebab-case")]
490pub enum HarnessToolSurface {
491 #[default]
492 Full,
493 ReadOnly,
494 Auto,
495}
496
497#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
499#[serde(rename_all = "kebab-case")]
500pub enum HarnessSafetyPosture {
501 #[default]
502 Standard,
503 Strict,
504 Permissive,
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
509#[serde(deny_unknown_fields)]
510pub struct HarnessPosture {
511 #[serde(default)]
513 pub kind: HarnessPostureKind,
514 #[serde(default)]
516 pub max_subagents: usize,
517 #[serde(default)]
519 pub prefer_codebase_search: bool,
520 #[serde(default)]
522 pub compaction_strategy: HarnessCompactionStrategy,
523 #[serde(default)]
525 pub tool_surface: HarnessToolSurface,
526 #[serde(default)]
528 pub safety_posture: HarnessSafetyPosture,
529}
530
531impl Default for HarnessPosture {
532 fn default() -> Self {
533 Self {
534 kind: HarnessPostureKind::Standard,
535 max_subagents: 0,
536 prefer_codebase_search: false,
537 compaction_strategy: HarnessCompactionStrategy::default(),
538 tool_surface: HarnessToolSurface::default(),
539 safety_posture: HarnessSafetyPosture::default(),
540 }
541 }
542}
543
544impl HarnessPosture {
545 #[must_use]
547 pub fn cache_heavy() -> Self {
548 Self {
549 kind: HarnessPostureKind::CacheHeavy,
550 max_subagents: 10,
551 prefer_codebase_search: false,
552 compaction_strategy: HarnessCompactionStrategy::PrefixCache,
553 tool_surface: HarnessToolSurface::Full,
554 safety_posture: HarnessSafetyPosture::Standard,
555 }
556 }
557
558 #[must_use]
560 pub fn lean() -> Self {
561 Self {
562 kind: HarnessPostureKind::Lean,
563 max_subagents: 20,
564 prefer_codebase_search: true,
565 compaction_strategy: HarnessCompactionStrategy::Aggressive,
566 tool_surface: HarnessToolSurface::Full,
567 safety_posture: HarnessSafetyPosture::Standard,
568 }
569 }
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
574#[serde(deny_unknown_fields)]
575pub struct HarnessProfile {
576 pub provider_route: String,
579 pub model_pattern: String,
581 #[serde(default)]
583 pub posture: HarnessPosture,
584}
585
586impl HarnessProfile {
587 #[must_use]
592 pub fn matches_route(&self, provider_route: &str, model: &str) -> bool {
593 provider_routes_equal(&self.provider_route, provider_route)
594 && wildcard_pattern_matches(&self.model_pattern, model)
595 }
596}
597
598#[derive(Debug, Clone, Serialize, Deserialize, Default)]
599pub struct ConfigToml {
600 pub api_key: Option<String>,
603 pub base_url: Option<String>,
605 #[serde(default)]
607 pub http_headers: BTreeMap<String, String>,
608 pub default_text_model: Option<String>,
610 #[serde(default)]
611 pub provider: ProviderKind,
612 pub model: Option<String>,
613 pub auth_mode: Option<String>,
614 pub output_mode: Option<String>,
615 pub verbosity: Option<String>,
616 pub log_level: Option<String>,
617 pub telemetry: Option<bool>,
618 pub approval_policy: Option<String>,
619 pub sandbox_mode: Option<String>,
620 #[serde(default)]
622 pub tools: Option<ToolsToml>,
623 #[serde(default)]
624 pub providers: ProvidersToml,
625 #[serde(default, skip_serializing_if = "Vec::is_empty")]
629 pub fallback_providers: Vec<ProviderKind>,
630 #[serde(default)]
633 pub network: Option<NetworkPolicyToml>,
634 #[serde(default)]
638 pub skills: Option<SkillsToml>,
639 #[serde(default)]
642 pub snapshots: Option<SnapshotsToml>,
643 #[serde(default)]
646 pub lsp: Option<LspConfigToml>,
647 #[serde(default)]
650 pub harness_profiles: Vec<HarnessProfile>,
651 #[serde(default, skip_serializing_if = "Option::is_none")]
654 pub hotbar: Option<Vec<HotbarBindingToml>>,
655 #[serde(default)]
658 pub hook_sinks: Option<HookSinksToml>,
659 #[serde(default)]
662 pub fleet: Option<FleetConfigToml>,
663 #[serde(flatten)]
664 pub extras: BTreeMap<String, toml::Value>,
665}
666
667#[derive(Debug, Clone, Copy, PartialEq, Eq)]
668enum ProviderConfigField {
669 ApiKey,
670 BaseUrl,
671 Model,
672 Mode,
673 AuthMode,
674 InsecureSkipTlsVerify,
675 HttpHeaders,
676 PathSuffix,
677}
678
679impl ProviderConfigField {
680 fn parse(key: &str) -> Option<Self> {
681 Some(match key {
682 "api_key" => Self::ApiKey,
683 "base_url" => Self::BaseUrl,
684 "model" => Self::Model,
685 "mode" => Self::Mode,
686 "auth_mode" => Self::AuthMode,
687 "insecure_skip_tls_verify" => Self::InsecureSkipTlsVerify,
688 "http_headers" => Self::HttpHeaders,
689 "path_suffix" => Self::PathSuffix,
690 _ => return None,
691 })
692 }
693
694 fn key(self) -> &'static str {
695 match self {
696 Self::ApiKey => "api_key",
697 Self::BaseUrl => "base_url",
698 Self::Model => "model",
699 Self::Mode => "mode",
700 Self::AuthMode => "auth_mode",
701 Self::InsecureSkipTlsVerify => "insecure_skip_tls_verify",
702 Self::HttpHeaders => "http_headers",
703 Self::PathSuffix => "path_suffix",
704 }
705 }
706}
707
708fn parse_provider_config_key(key: &str) -> Option<(ProviderKind, ProviderConfigField)> {
709 let suffix = key.strip_prefix("providers.")?;
710 let (provider_key, field_key) = suffix.split_once('.')?;
711 let field = ProviderConfigField::parse(field_key)?;
712 let provider = ProviderKind::ALL
713 .iter()
714 .copied()
715 .find(|kind| kind.provider().provider_config_key() == provider_key)?;
716 Some((provider, field))
717}
718
719fn provider_config_key(provider: ProviderKind, field: ProviderConfigField) -> String {
720 format!(
721 "providers.{}.{}",
722 provider.provider().provider_config_key(),
723 field.key()
724 )
725}
726
727fn get_provider_config_value(
728 config: &ProviderConfigToml,
729 field: ProviderConfigField,
730) -> Option<String> {
731 match field {
732 ProviderConfigField::ApiKey => config.api_key.clone(),
733 ProviderConfigField::BaseUrl => config.base_url.clone(),
734 ProviderConfigField::Model => config.model.clone(),
735 ProviderConfigField::Mode => config.mode.clone(),
736 ProviderConfigField::AuthMode => config.auth_mode.clone(),
737 ProviderConfigField::InsecureSkipTlsVerify => config
738 .insecure_skip_tls_verify
739 .map(|value| value.to_string()),
740 ProviderConfigField::HttpHeaders => serialize_http_headers(&config.http_headers),
741 ProviderConfigField::PathSuffix => config.path_suffix.clone(),
742 }
743}
744
745fn get_provider_config_display_value(
746 config: &ProviderConfigToml,
747 field: ProviderConfigField,
748) -> Option<String> {
749 match field {
750 ProviderConfigField::ApiKey => config.api_key.as_deref().map(redact_secret),
751 ProviderConfigField::HttpHeaders => {
752 serialize_http_headers_for_display(&config.http_headers)
753 }
754 _ => get_provider_config_value(config, field),
755 }
756}
757
758fn set_provider_config_value(
759 config: &mut ConfigToml,
760 provider: ProviderKind,
761 field: ProviderConfigField,
762 value: &str,
763) -> Result<()> {
764 match field {
765 ProviderConfigField::ApiKey => {
766 let value = value.to_string();
767 config.providers.for_provider_mut(provider).api_key = Some(value.clone());
768 if provider == ProviderKind::Deepseek {
769 config.api_key = Some(value);
770 }
771 }
772 ProviderConfigField::BaseUrl => {
773 let value = value.to_string();
774 config.providers.for_provider_mut(provider).base_url = Some(value.clone());
775 if provider == ProviderKind::Deepseek {
776 config.base_url = Some(value);
777 }
778 }
779 ProviderConfigField::Model => {
780 let value = value.to_string();
781 config.providers.for_provider_mut(provider).model = Some(value.clone());
782 if provider == ProviderKind::Deepseek {
783 config.default_text_model = Some(value);
784 }
785 }
786 ProviderConfigField::Mode => {
787 config.providers.for_provider_mut(provider).mode = Some(value.to_string());
788 }
789 ProviderConfigField::AuthMode => {
790 config.providers.for_provider_mut(provider).auth_mode = Some(value.to_string());
791 }
792 ProviderConfigField::InsecureSkipTlsVerify => {
793 config
794 .providers
795 .for_provider_mut(provider)
796 .insecure_skip_tls_verify = Some(parse_bool(value)?);
797 }
798 ProviderConfigField::HttpHeaders => {
799 let headers = parse_http_headers(value)?;
800 config.providers.for_provider_mut(provider).http_headers = headers.clone();
801 if provider == ProviderKind::Deepseek {
802 config.http_headers = headers;
803 }
804 }
805 ProviderConfigField::PathSuffix => {
806 config.providers.for_provider_mut(provider).path_suffix = Some(value.to_string());
807 }
808 }
809 Ok(())
810}
811
812fn unset_provider_config_value(
813 config: &mut ConfigToml,
814 provider: ProviderKind,
815 field: ProviderConfigField,
816) {
817 match field {
818 ProviderConfigField::ApiKey => {
819 config.providers.for_provider_mut(provider).api_key = None;
820 if provider == ProviderKind::Deepseek {
821 config.api_key = None;
822 }
823 }
824 ProviderConfigField::BaseUrl => {
825 config.providers.for_provider_mut(provider).base_url = None;
826 if provider == ProviderKind::Deepseek {
827 config.base_url = None;
828 }
829 }
830 ProviderConfigField::Model => {
831 config.providers.for_provider_mut(provider).model = None;
832 if provider == ProviderKind::Deepseek {
833 config.default_text_model = None;
834 }
835 }
836 ProviderConfigField::Mode => {
837 config.providers.for_provider_mut(provider).mode = None;
838 }
839 ProviderConfigField::AuthMode => {
840 config.providers.for_provider_mut(provider).auth_mode = None;
841 }
842 ProviderConfigField::InsecureSkipTlsVerify => {
843 config
844 .providers
845 .for_provider_mut(provider)
846 .insecure_skip_tls_verify = None;
847 }
848 ProviderConfigField::HttpHeaders => {
849 config
850 .providers
851 .for_provider_mut(provider)
852 .http_headers
853 .clear();
854 if provider == ProviderKind::Deepseek {
855 config.http_headers.clear();
856 }
857 }
858 ProviderConfigField::PathSuffix => {
859 config.providers.for_provider_mut(provider).path_suffix = None;
860 }
861 }
862}
863
864fn insert_provider_config_values(
865 out: &mut BTreeMap<String, String>,
866 provider: ProviderKind,
867 config: &ProviderConfigToml,
868) {
869 if let Some(v) = config.api_key.as_ref() {
870 out.insert(
871 provider_config_key(provider, ProviderConfigField::ApiKey),
872 redact_secret(v),
873 );
874 }
875 if let Some(v) = config.base_url.as_ref() {
876 out.insert(
877 provider_config_key(provider, ProviderConfigField::BaseUrl),
878 v.clone(),
879 );
880 }
881 if let Some(v) = config.model.as_ref() {
882 out.insert(
883 provider_config_key(provider, ProviderConfigField::Model),
884 v.clone(),
885 );
886 }
887 if let Some(v) = config.mode.as_ref() {
888 out.insert(
889 provider_config_key(provider, ProviderConfigField::Mode),
890 v.clone(),
891 );
892 }
893 if let Some(v) = config.auth_mode.as_ref() {
894 out.insert(
895 provider_config_key(provider, ProviderConfigField::AuthMode),
896 v.clone(),
897 );
898 }
899 if let Some(v) = config.insecure_skip_tls_verify {
900 out.insert(
901 provider_config_key(provider, ProviderConfigField::InsecureSkipTlsVerify),
902 v.to_string(),
903 );
904 }
905 if let Some(v) = serialize_http_headers_for_display(&config.http_headers) {
906 out.insert(
907 provider_config_key(provider, ProviderConfigField::HttpHeaders),
908 v,
909 );
910 }
911 if let Some(v) = config.path_suffix.as_ref() {
912 out.insert(
913 provider_config_key(provider, ProviderConfigField::PathSuffix),
914 v.clone(),
915 );
916 }
917}
918
919impl ConfigToml {
920 #[must_use]
926 pub fn resolve_harness_profile(
927 &self,
928 provider_route: &str,
929 model: &str,
930 ) -> Option<&HarnessProfile> {
931 self.harness_profiles
932 .iter()
933 .chain(built_in_harness_profiles().iter())
934 .find(|profile| profile.matches_route(provider_route, model))
935 }
936
937 #[must_use]
943 pub fn resolve_hotbar_bindings(&self, known_action_ids: &[&str]) -> HotbarConfigResolution {
944 resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids)
945 }
946}
947
948#[must_use]
953pub fn built_in_harness_profiles() -> &'static [HarnessProfile] {
954 static PROFILES: OnceLock<Vec<HarnessProfile>> = OnceLock::new();
955 PROFILES.get_or_init(|| {
956 vec![
957 HarnessProfile {
958 provider_route: "deepseek".to_string(),
959 model_pattern: "deepseek-v4*".to_string(),
960 posture: HarnessPosture::cache_heavy(),
961 },
962 HarnessProfile {
963 provider_route: "xiaomi-mimo".to_string(),
964 model_pattern: "mimo-v2.5*".to_string(),
965 posture: HarnessPosture::cache_heavy(),
966 },
967 HarnessProfile {
968 provider_route: "arcee".to_string(),
969 model_pattern: "trinity-large-thinking".to_string(),
970 posture: HarnessPosture::cache_heavy(),
971 },
972 HarnessProfile {
973 provider_route: "huggingface".to_string(),
974 model_pattern: "*".to_string(),
975 posture: HarnessPosture::lean(),
976 },
977 HarnessProfile {
978 provider_route: "sglang".to_string(),
979 model_pattern: "*".to_string(),
980 posture: HarnessPosture::lean(),
981 },
982 HarnessProfile {
983 provider_route: "vllm".to_string(),
984 model_pattern: "*".to_string(),
985 posture: HarnessPosture::lean(),
986 },
987 HarnessProfile {
988 provider_route: "ollama".to_string(),
989 model_pattern: "*".to_string(),
990 posture: HarnessPosture::lean(),
991 },
992 ]
993 })
994}
995
996fn provider_routes_equal(expected: &str, actual: &str) -> bool {
997 match (ProviderKind::parse(expected), ProviderKind::parse(actual)) {
998 (Some(expected), Some(actual)) => expected == actual,
999 _ => expected.trim().eq_ignore_ascii_case(actual.trim()),
1000 }
1001}
1002
1003fn wildcard_pattern_matches(pattern: &str, value: &str) -> bool {
1004 wildcard_chars_match(
1005 &pattern.chars().collect::<Vec<_>>(),
1006 &value.chars().collect::<Vec<_>>(),
1007 )
1008}
1009
1010fn wildcard_chars_match(pattern: &[char], value: &[char]) -> bool {
1011 let (mut pattern_idx, mut value_idx) = (0, 0);
1012 let mut star_idx: Option<usize> = None;
1013 let mut star_value_idx = 0;
1014
1015 while value_idx < value.len() {
1016 if pattern_idx < pattern.len()
1017 && (pattern[pattern_idx] == '?' || pattern[pattern_idx] == value[value_idx])
1018 {
1019 pattern_idx += 1;
1020 value_idx += 1;
1021 } else if pattern_idx < pattern.len() && pattern[pattern_idx] == '*' {
1022 star_idx = Some(pattern_idx);
1023 pattern_idx += 1;
1024 star_value_idx = value_idx;
1025 } else if let Some(star) = star_idx {
1026 pattern_idx = star + 1;
1027 star_value_idx += 1;
1028 value_idx = star_value_idx;
1029 } else {
1030 return false;
1031 }
1032 }
1033
1034 pattern[pattern_idx..].iter().all(|ch| *ch == '*')
1035}
1036
1037#[derive(Debug, Clone, PartialEq, Eq)]
1042pub struct ProviderChain {
1043 providers: Vec<ProviderKind>,
1044 position: usize,
1045}
1046
1047pub const HOTBAR_SLOT_COUNT: u8 = 8;
1048
1049pub const DEFAULT_HOTBAR_ACTIONS: [&str; HOTBAR_SLOT_COUNT as usize] = [
1050 "voice.toggle",
1051 "session.compact",
1052 "mode.plan",
1053 "mode.agent",
1054 "mode.yolo",
1055 "palette.open",
1056 "sidebar.toggle",
1057 "trust.toggle",
1058];
1059
1060#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1062#[serde(deny_unknown_fields)]
1063pub struct HotbarBindingToml {
1064 pub slot: u8,
1065 pub action: String,
1066 #[serde(default)]
1067 pub label: Option<String>,
1068}
1069
1070#[derive(Debug, Clone, PartialEq, Eq)]
1072pub struct HotbarBinding {
1073 pub slot: u8,
1074 pub action: String,
1075 pub label: Option<String>,
1076}
1077
1078#[derive(Debug, Clone, PartialEq, Eq)]
1081pub enum HotbarConfigWarning {
1082 SlotOutOfRange {
1083 slot: u8,
1084 action: String,
1085 },
1086 DuplicateSlot {
1087 slot: u8,
1088 previous_action: String,
1089 replacement_action: String,
1090 },
1091 UnknownAction {
1092 slot: u8,
1093 action: String,
1094 },
1095}
1096
1097impl fmt::Display for HotbarConfigWarning {
1098 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1099 match self {
1100 Self::SlotOutOfRange { slot, action } => write!(
1101 f,
1102 "hotbar slot {slot} for action '{action}' is outside 1-{HOTBAR_SLOT_COUNT}; skipped"
1103 ),
1104 Self::DuplicateSlot {
1105 slot,
1106 previous_action,
1107 replacement_action,
1108 } => write!(
1109 f,
1110 "hotbar slot {slot} was bound to '{previous_action}' more than once; using '{replacement_action}'"
1111 ),
1112 Self::UnknownAction { slot, action } => write!(
1113 f,
1114 "hotbar slot {slot} references unknown action '{action}'; keeping binding"
1115 ),
1116 }
1117 }
1118}
1119
1120#[derive(Debug, Clone, PartialEq, Eq)]
1121pub struct HotbarConfigResolution {
1122 pub bindings: Vec<HotbarBinding>,
1123 pub warnings: Vec<HotbarConfigWarning>,
1124}
1125
1126#[must_use]
1127pub fn default_hotbar_bindings() -> Vec<HotbarBinding> {
1128 DEFAULT_HOTBAR_ACTIONS
1129 .iter()
1130 .enumerate()
1131 .map(|(idx, action)| HotbarBinding {
1132 slot: u8::try_from(idx + 1).expect("default hotbar slot fits in u8"),
1133 action: (*action).to_string(),
1134 label: None,
1135 })
1136 .collect()
1137}
1138
1139#[must_use]
1140pub fn resolve_hotbar_bindings(
1141 configured: Option<&[HotbarBindingToml]>,
1142 known_action_ids: &[&str],
1143) -> HotbarConfigResolution {
1144 let known = known_action_ids.iter().copied().collect::<BTreeSet<&str>>();
1145 let mut warnings = Vec::new();
1146
1147 let source = match configured {
1148 Some(bindings) => bindings
1149 .iter()
1150 .map(|binding| HotbarBinding {
1151 slot: binding.slot,
1152 action: binding.action.clone(),
1153 label: binding.label.clone(),
1154 })
1155 .collect::<Vec<_>>(),
1156 None => default_hotbar_bindings(),
1157 };
1158
1159 let mut by_slot: BTreeMap<u8, HotbarBinding> = BTreeMap::new();
1160 for binding in source {
1161 if !(1..=HOTBAR_SLOT_COUNT).contains(&binding.slot) {
1162 warnings.push(HotbarConfigWarning::SlotOutOfRange {
1163 slot: binding.slot,
1164 action: binding.action,
1165 });
1166 continue;
1167 }
1168 if !known.is_empty() && !known.contains(binding.action.as_str()) {
1169 warnings.push(HotbarConfigWarning::UnknownAction {
1170 slot: binding.slot,
1171 action: binding.action.clone(),
1172 });
1173 }
1174 if let Some(previous) = by_slot.insert(binding.slot, binding.clone()) {
1175 warnings.push(HotbarConfigWarning::DuplicateSlot {
1176 slot: binding.slot,
1177 previous_action: previous.action,
1178 replacement_action: binding.action,
1179 });
1180 }
1181 }
1182
1183 HotbarConfigResolution {
1184 bindings: by_slot.into_values().collect(),
1185 warnings,
1186 }
1187}
1188
1189impl ProviderChain {
1190 #[must_use]
1191 pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self {
1192 let mut providers = vec![active];
1193 for fallback in fallbacks {
1194 if *fallback != active && !providers.contains(fallback) {
1195 providers.push(*fallback);
1196 }
1197 }
1198 Self {
1199 providers,
1200 position: 0,
1201 }
1202 }
1203
1204 #[must_use]
1205 pub fn providers(&self) -> &[ProviderKind] {
1206 &self.providers
1207 }
1208
1209 #[must_use]
1210 pub fn position(&self) -> usize {
1211 self.position
1212 }
1213
1214 #[must_use]
1215 pub fn current(&self) -> ProviderKind {
1216 self.providers
1217 .get(self.position)
1218 .copied()
1219 .unwrap_or(self.providers[0])
1220 }
1221
1222 #[must_use]
1223 pub fn has_next(&self) -> bool {
1224 self.position + 1 < self.providers.len()
1225 }
1226
1227 pub fn advance(&mut self) -> Option<ProviderKind> {
1228 if !self.has_next() {
1229 return None;
1230 }
1231 self.position += 1;
1232 Some(self.current())
1233 }
1234
1235 pub fn reset(&mut self) {
1236 self.position = 0;
1237 }
1238
1239 #[must_use]
1240 pub fn is_fallback_active(&self) -> bool {
1241 self.position > 0
1242 }
1243
1244 #[must_use]
1246 pub fn remaining(&self) -> usize {
1247 self.providers.len() - self.position
1248 }
1249}
1250
1251#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1253pub struct HookSinksToml {
1254 #[serde(default)]
1259 pub unix_socket_path: Option<PathBuf>,
1260}
1261
1262#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1265pub struct SkillsToml {
1266 #[serde(default)]
1269 pub registry_url: Option<String>,
1270 #[serde(default)]
1273 pub max_install_size_bytes: Option<u64>,
1274}
1275
1276#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1278pub struct ToolsToml {
1279 #[serde(default)]
1281 pub always_load: Vec<String>,
1282}
1283
1284#[derive(Debug, Clone, Serialize, Deserialize)]
1287pub struct SnapshotsToml {
1288 #[serde(default = "default_snapshots_enabled")]
1289 pub enabled: bool,
1290 #[serde(default = "default_snapshot_max_age_days")]
1291 pub max_age_days: u64,
1292}
1293
1294fn default_snapshots_enabled() -> bool {
1295 true
1296}
1297
1298fn default_snapshot_max_age_days() -> u64 {
1299 7
1300}
1301
1302impl Default for SnapshotsToml {
1303 fn default() -> Self {
1304 Self {
1305 enabled: default_snapshots_enabled(),
1306 max_age_days: default_snapshot_max_age_days(),
1307 }
1308 }
1309}
1310
1311#[derive(Debug, Clone, Serialize, Deserialize)]
1314pub struct FleetConfigToml {
1315 #[serde(default = "default_fleet_trust_level_str")]
1318 pub default_trust_level: String,
1319 #[serde(default = "default_fleet_require_identity")]
1322 pub require_identity_verification: bool,
1323 #[serde(default = "default_fleet_max_trust_level_str")]
1326 pub max_trust_level: String,
1327 #[serde(default)]
1334 pub roles: BTreeMap<String, FleetRolePreset>,
1335 #[serde(default)]
1337 pub exec: FleetExecConfig,
1338}
1339
1340pub const DEFAULT_SPAWN_DEPTH: u32 = 3;
1355
1356pub const MAX_SPAWN_DEPTH_CEILING: u32 = 8;
1362
1363#[derive(Debug, Clone, Serialize, Deserialize)]
1368pub struct FleetExecConfig {
1369 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1371 pub allowed_tools: Vec<String>,
1372 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1374 pub disallowed_tools: Vec<String>,
1375 #[serde(default = "default_fleet_max_turns")]
1378 pub max_turns: u32,
1379 #[serde(default = "default_fleet_max_spawn_depth")]
1385 pub max_spawn_depth: u32,
1386 #[serde(default, skip_serializing_if = "String::is_empty")]
1389 pub append_system_prompt: String,
1390 #[serde(default = "default_fleet_output_format")]
1393 pub output_format: String,
1394}
1395
1396fn default_fleet_max_turns() -> u32 {
1397 u32::MAX
1398}
1399
1400fn default_fleet_max_spawn_depth() -> u32 {
1401 DEFAULT_SPAWN_DEPTH
1402}
1403
1404fn default_fleet_output_format() -> String {
1405 "text".to_string()
1406}
1407
1408impl Default for FleetExecConfig {
1409 fn default() -> Self {
1410 Self {
1411 allowed_tools: Vec::new(),
1412 disallowed_tools: Vec::new(),
1413 max_turns: default_fleet_max_turns(),
1414 max_spawn_depth: default_fleet_max_spawn_depth(),
1415 append_system_prompt: String::new(),
1416 output_format: default_fleet_output_format(),
1417 }
1418 }
1419}
1420
1421#[derive(Debug, Clone, Serialize, Deserialize)]
1430pub struct FleetRolePreset {
1431 #[serde(skip_serializing_if = "Option::is_none")]
1433 pub description: Option<String>,
1434 #[serde(skip_serializing_if = "Option::is_none")]
1436 pub tool_profile: Option<String>,
1437 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1439 pub tools: Vec<String>,
1440 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1442 pub capabilities: Vec<String>,
1443 #[serde(skip_serializing_if = "Option::is_none")]
1445 pub timeout_seconds: Option<u64>,
1446 #[serde(skip_serializing_if = "Option::is_none")]
1448 pub trust_level: Option<String>,
1449}
1450
1451fn default_fleet_trust_level_str() -> String {
1452 "sandbox".to_string()
1453}
1454
1455fn default_fleet_require_identity() -> bool {
1456 true
1457}
1458
1459fn default_fleet_max_trust_level_str() -> String {
1460 "operator".to_string()
1461}
1462
1463impl Default for FleetConfigToml {
1464 fn default() -> Self {
1465 Self {
1466 default_trust_level: default_fleet_trust_level_str(),
1467 require_identity_verification: default_fleet_require_identity(),
1468 max_trust_level: default_fleet_max_trust_level_str(),
1469 roles: BTreeMap::new(),
1470 exec: FleetExecConfig::default(),
1471 }
1472 }
1473}
1474
1475impl FleetConfigToml {
1476 #[must_use]
1479 pub fn resolve_role(&self, name: &str) -> Option<FleetRolePreset> {
1480 self.roles
1481 .get(name)
1482 .cloned()
1483 .or_else(|| built_in_role_presets().get(name).cloned())
1484 }
1485}
1486
1487#[must_use]
1489pub fn built_in_role_presets() -> BTreeMap<String, FleetRolePreset> {
1490 [
1491 (
1492 "smoke-runner".to_string(),
1493 FleetRolePreset {
1494 description: Some("Lightweight read-only smoke check worker".to_string()),
1495 tool_profile: Some("read-only".to_string()),
1496 tools: vec![],
1497 capabilities: vec![],
1498 timeout_seconds: Some(300),
1499 trust_level: Some("local".to_string()),
1500 },
1501 ),
1502 (
1503 "reviewer".to_string(),
1504 FleetRolePreset {
1505 description: Some("Read-only code and documentation review".to_string()),
1506 tool_profile: Some("read-only".to_string()),
1507 tools: vec![],
1508 capabilities: vec![],
1509 timeout_seconds: Some(600),
1510 trust_level: None,
1511 },
1512 ),
1513 (
1514 "builder".to_string(),
1515 FleetRolePreset {
1516 description: Some(
1517 "Read-write builder with compilation and test access".to_string(),
1518 ),
1519 tool_profile: Some("read-write".to_string()),
1520 tools: vec![],
1521 capabilities: vec![],
1522 timeout_seconds: Some(1800),
1523 trust_level: Some("local".to_string()),
1524 },
1525 ),
1526 (
1527 "read-only".to_string(),
1528 FleetRolePreset {
1529 description: Some(
1530 "Minimal read-only observer with no writes or secrets".to_string(),
1531 ),
1532 tool_profile: Some("read-only".to_string()),
1533 tools: vec![],
1534 capabilities: vec![],
1535 timeout_seconds: Some(300),
1536 trust_level: Some("sandbox".to_string()),
1537 },
1538 ),
1539 ]
1540 .into()
1541}
1542
1543#[derive(Debug, Clone, Serialize, Deserialize)]
1546pub struct NetworkPolicyToml {
1547 #[serde(default = "default_network_decision")]
1550 pub default: String,
1551 #[serde(default)]
1554 pub allow: Vec<String>,
1555 #[serde(default)]
1557 pub deny: Vec<String>,
1558 #[serde(default)]
1561 pub proxy: Vec<String>,
1562 #[serde(default = "default_network_audit")]
1564 pub audit: bool,
1565}
1566
1567fn default_network_decision() -> String {
1568 "prompt".to_string()
1569}
1570
1571fn default_network_audit() -> bool {
1572 true
1573}
1574
1575impl Default for NetworkPolicyToml {
1576 fn default() -> Self {
1577 Self {
1578 default: default_network_decision(),
1579 allow: Vec::new(),
1580 deny: Vec::new(),
1581 proxy: Vec::new(),
1582 audit: default_network_audit(),
1583 }
1584 }
1585}
1586
1587#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1591pub struct LspConfigToml {
1592 pub enabled: Option<bool>,
1594 pub poll_after_edit_ms: Option<u64>,
1596 pub max_diagnostics_per_file: Option<usize>,
1598 pub include_warnings: Option<bool>,
1600 pub servers: Option<BTreeMap<String, Vec<String>>>,
1602}
1603
1604impl ConfigToml {
1605 pub fn merge_project_overrides(&mut self, project: ConfigToml) {
1614 if project.default_text_model.is_some() {
1615 self.default_text_model = project.default_text_model;
1616 }
1617 if project.model.is_some() {
1618 self.model = project.model;
1619 }
1620 if project.output_mode.is_some() {
1621 self.output_mode = project.output_mode;
1622 }
1623 if project.verbosity.is_some() {
1624 self.verbosity = project.verbosity;
1625 }
1626 if project.log_level.is_some() {
1627 self.log_level = project.log_level;
1628 }
1629 if let Some(policy) = project.approval_policy
1630 && project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy)
1631 {
1632 self.approval_policy = Some(policy);
1633 }
1634 if let Some(mode) = project.sandbox_mode
1635 && project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode)
1636 {
1637 self.sandbox_mode = Some(mode);
1638 }
1639 if project.tools.is_some() {
1640 self.tools = project.tools;
1641 }
1642 for provider in ProviderKind::ALL {
1643 merge_project_provider_config(
1644 self.providers.for_provider_mut(provider),
1645 project.providers.for_provider(provider),
1646 );
1647 }
1648 }
1649
1650 #[must_use]
1651 pub fn get_value(&self, key: &str) -> Option<String> {
1652 if let Some((provider, field)) = parse_provider_config_key(key) {
1653 return get_provider_config_value(self.providers.for_provider(provider), field);
1654 }
1655
1656 match key {
1657 "provider" => Some(self.provider.as_str().to_string()),
1658 "api_key" => self.api_key.clone(),
1659 "base_url" => self.base_url.clone(),
1660 "http_headers" => serialize_http_headers(&self.http_headers),
1661 "default_text_model" => self.default_text_model.clone(),
1662 "model" => self.model.clone(),
1663 "auth.mode" => self.auth_mode.clone(),
1664 "output_mode" => self.output_mode.clone(),
1665 "verbosity" => self.verbosity.clone(),
1666 "log_level" => self.log_level.clone(),
1667 "telemetry" => self.telemetry.map(|v| v.to_string()),
1668 "approval_policy" => self.approval_policy.clone(),
1669 "sandbox_mode" => self.sandbox_mode.clone(),
1670 "tools.always_load" => self.tools.as_ref().map(|tools| tools.always_load.join(",")),
1671 "hook_sinks.unix_socket_path" => self
1672 .hook_sinks
1673 .as_ref()
1674 .and_then(|sinks| sinks.unix_socket_path.as_ref())
1675 .map(|path| path.display().to_string()),
1676 _ => self.extras.get(key).map(toml::Value::to_string),
1677 }
1678 }
1679
1680 #[must_use]
1681 pub fn get_display_value(&self, key: &str) -> Option<String> {
1682 if let Some((provider, field)) = parse_provider_config_key(key) {
1683 return get_provider_config_display_value(self.providers.for_provider(provider), field);
1684 }
1685
1686 if key == "http_headers" {
1687 return serialize_http_headers_for_display(&self.http_headers);
1688 }
1689
1690 if let Some(value) = self.extras.get(key) {
1691 return Some(redact_toml_value_for_display(key, value));
1692 }
1693
1694 self.get_value(key).map(|value| {
1695 if is_sensitive_config_key(key) {
1696 redact_secret(&value)
1697 } else {
1698 value
1699 }
1700 })
1701 }
1702
1703 pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
1704 if let Some((provider, field)) = parse_provider_config_key(key) {
1705 return set_provider_config_value(self, provider, field, value);
1706 }
1707
1708 match key {
1709 "provider" => {
1710 self.provider = ProviderKind::parse(value).with_context(|| {
1711 format!(
1712 "unknown provider '{value}': expected {}",
1713 ProviderKind::names_hint()
1714 )
1715 })?;
1716 }
1717 "api_key" => self.api_key = Some(value.to_string()),
1718 "base_url" => self.base_url = Some(value.to_string()),
1719 "http_headers" => self.http_headers = parse_http_headers(value)?,
1720 "default_text_model" => self.default_text_model = Some(value.to_string()),
1721 "model" => self.model = Some(value.to_string()),
1722 "auth.mode" => self.auth_mode = Some(value.to_string()),
1723 "output_mode" => self.output_mode = Some(value.to_string()),
1724 "verbosity" => self.verbosity = Some(value.to_string()),
1725 "log_level" => self.log_level = Some(value.to_string()),
1726 "telemetry" => {
1727 self.telemetry = Some(parse_bool(value)?);
1728 }
1729 "approval_policy" => self.approval_policy = Some(value.to_string()),
1730 "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
1731 "hook_sinks.unix_socket_path" => {
1732 self.hook_sinks
1733 .get_or_insert_with(HookSinksToml::default)
1734 .unix_socket_path = Some(PathBuf::from(value));
1735 }
1736 _ => {
1737 self.extras
1738 .insert(key.to_string(), toml::Value::String(value.to_string()));
1739 }
1740 }
1741 Ok(())
1742 }
1743
1744 pub fn unset_value(&mut self, key: &str) -> Result<()> {
1745 if let Some((provider, field)) = parse_provider_config_key(key) {
1746 unset_provider_config_value(self, provider, field);
1747 return Ok(());
1748 }
1749
1750 match key {
1751 "provider" => self.provider = ProviderKind::Deepseek,
1752 "api_key" => self.api_key = None,
1753 "base_url" => self.base_url = None,
1754 "http_headers" => self.http_headers.clear(),
1755 "default_text_model" => self.default_text_model = None,
1756 "model" => self.model = None,
1757 "auth.mode" => self.auth_mode = None,
1758 "output_mode" => self.output_mode = None,
1759 "verbosity" => self.verbosity = None,
1760 "log_level" => self.log_level = None,
1761 "telemetry" => self.telemetry = None,
1762 "approval_policy" => self.approval_policy = None,
1763 "sandbox_mode" => self.sandbox_mode = None,
1764 "hook_sinks.unix_socket_path" => {
1765 if let Some(sinks) = self.hook_sinks.as_mut() {
1766 sinks.unix_socket_path = None;
1767 }
1768 }
1769 _ => {
1770 self.extras.remove(key);
1771 }
1772 }
1773 Ok(())
1774 }
1775
1776 #[must_use]
1777 pub fn list_values(&self) -> BTreeMap<String, String> {
1778 let mut out = BTreeMap::new();
1779 out.insert("provider".to_string(), self.provider.as_str().to_string());
1780
1781 if let Some(v) = self.api_key.as_ref() {
1782 out.insert("api_key".to_string(), redact_secret(v));
1783 }
1784 if let Some(v) = self.base_url.as_ref() {
1785 out.insert("base_url".to_string(), v.clone());
1786 }
1787 if let Some(v) = serialize_http_headers_for_display(&self.http_headers) {
1788 out.insert("http_headers".to_string(), v);
1789 }
1790 if let Some(v) = self.default_text_model.as_ref() {
1791 out.insert("default_text_model".to_string(), v.clone());
1792 }
1793 if let Some(v) = self.model.as_ref() {
1794 out.insert("model".to_string(), v.clone());
1795 }
1796 if let Some(v) = self.auth_mode.as_ref() {
1797 out.insert("auth.mode".to_string(), v.clone());
1798 }
1799 if let Some(v) = self.output_mode.as_ref() {
1800 out.insert("output_mode".to_string(), v.clone());
1801 }
1802 if let Some(v) = self.verbosity.as_ref() {
1803 out.insert("verbosity".to_string(), v.clone());
1804 }
1805 if let Some(v) = self.log_level.as_ref() {
1806 out.insert("log_level".to_string(), v.clone());
1807 }
1808 if let Some(v) = self.telemetry {
1809 out.insert("telemetry".to_string(), v.to_string());
1810 }
1811 if let Some(v) = self.approval_policy.as_ref() {
1812 out.insert("approval_policy".to_string(), v.clone());
1813 }
1814 if let Some(v) = self.sandbox_mode.as_ref() {
1815 out.insert("sandbox_mode".to_string(), v.clone());
1816 }
1817 if let Some(v) = self
1818 .hook_sinks
1819 .as_ref()
1820 .and_then(|sinks| sinks.unix_socket_path.as_ref())
1821 {
1822 out.insert(
1823 "hook_sinks.unix_socket_path".to_string(),
1824 v.display().to_string(),
1825 );
1826 }
1827
1828 for provider in ProviderKind::ALL {
1829 insert_provider_config_values(
1830 &mut out,
1831 provider,
1832 self.providers.for_provider(provider),
1833 );
1834 }
1835
1836 for (k, v) in &self.extras {
1837 out.insert(k.clone(), redact_toml_value_for_display(k, v));
1838 }
1839 out
1840 }
1841
1842 #[must_use]
1849 pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
1850 let no_keyring = Secrets::new(std::sync::Arc::new(
1851 codewhale_secrets::InMemoryKeyringStore::new(),
1852 ));
1853 self.resolve_runtime_options_with_secrets(cli, &no_keyring)
1854 }
1855
1856 #[must_use]
1860 pub fn resolve_runtime_options_with_secrets(
1861 &self,
1862 cli: &CliRuntimeOverrides,
1863 secrets: &Secrets,
1864 ) -> ResolvedRuntimeOptions {
1865 let env = EnvRuntimeOverrides::load();
1866 let (provider, provider_source) = if let Some(provider) = cli.provider {
1867 (provider, ProviderSource::Cli)
1868 } else if let Some(provider) = env.provider {
1869 (
1870 provider,
1871 ProviderSource::Env(env.provider_source.unwrap_or("CODEWHALE_PROVIDER")),
1872 )
1873 } else {
1874 (self.provider, ProviderSource::Config)
1875 };
1876
1877 let mut provider_cfg = self.providers.for_provider(provider).clone();
1878 if provider == ProviderKind::SiliconflowCN {
1879 let fb = &self.providers.siliconflow;
1880 if provider_cfg.api_key.is_none() {
1881 provider_cfg.api_key = fb.api_key.clone();
1882 }
1883 if provider_cfg.base_url.is_none() {
1884 provider_cfg.base_url = fb.base_url.clone();
1885 }
1886 if provider_cfg.model.is_none() {
1887 provider_cfg.model = fb.model.clone();
1888 }
1889 }
1890 let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
1891 .then(|| self.api_key.clone())
1892 .flatten();
1893 let root_deepseek_base_url = (provider == ProviderKind::Deepseek)
1894 .then(|| self.base_url.clone())
1895 .flatten();
1896 let root_deepseek_model = (provider == ProviderKind::Deepseek)
1897 .then(|| self.default_text_model.clone())
1898 .flatten();
1899 let auth_mode = cli
1900 .auth_mode
1901 .clone()
1902 .or_else(|| env.auth_mode.clone())
1903 .or_else(|| provider_cfg.auth_mode.clone())
1904 .or_else(|| self.auth_mode.clone());
1905 let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
1906 let configured_base_url = cli
1907 .base_url
1908 .clone()
1909 .or_else(|| env.base_url_for(provider))
1910 .or_else(|| provider_cfg.base_url.clone())
1911 .or(root_deepseek_base_url);
1912 let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo {
1913 env.xiaomi_mimo_mode
1914 .clone()
1915 .or_else(|| provider_cfg.mode.clone())
1916 } else {
1917 None
1918 };
1919 let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo {
1920 xiaomi_mimo_env_api_key_for_runtime(
1921 xiaomi_mimo_mode.as_deref(),
1922 configured_base_url.as_deref(),
1923 )
1924 } else {
1925 None
1926 };
1927 let explicit_api_key_for_endpoint = cli
1928 .api_key
1929 .as_deref()
1930 .or(from_file.as_deref())
1931 .or(xiaomi_mimo_env_api_key.as_deref());
1932 let base_url = if provider == ProviderKind::XiaomiMimo {
1933 resolve_xiaomi_mimo_base_url(
1934 configured_base_url,
1935 explicit_api_key_for_endpoint,
1936 xiaomi_mimo_mode.as_deref(),
1937 )
1938 } else {
1939 configured_base_url.unwrap_or_else(|| match provider {
1940 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
1941 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
1942 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
1943 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
1944 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
1945 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
1946 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
1947 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
1948 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
1949 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
1950 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
1951 ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL.to_string(),
1952 ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL.to_string(),
1953 ProviderKind::Moonshot => {
1954 if auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) {
1955 DEFAULT_KIMI_CODE_BASE_URL.to_string()
1956 } else {
1957 DEFAULT_MOONSHOT_BASE_URL.to_string()
1958 }
1959 }
1960 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
1961 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
1962 ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
1963 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(),
1964 ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL.to_string(),
1965 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
1966 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(),
1967 ProviderKind::Zai => DEFAULT_ZAI_BASE_URL.to_string(),
1968 ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL.to_string(),
1969 ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL.to_string(),
1970 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL.to_string(),
1971 })
1972 };
1973 let uses_kimi_oauth = provider == ProviderKind::Moonshot
1979 && auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth);
1980 let (api_key, api_key_source) = if let Some(value) = cli.api_key.clone() {
1981 (Some(value), Some(RuntimeApiKeySource::Cli))
1982 } else if uses_kimi_oauth {
1983 (None, None)
1984 } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) {
1985 (Some(value), Some(RuntimeApiKeySource::ConfigFile))
1986 } else if let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty()) {
1987 (Some(value), Some(RuntimeApiKeySource::Env))
1988 } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
1989 match env_api_key_for_provider(provider) {
1990 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
1991 None => (None, None),
1992 }
1993 } else {
1994 match secrets.resolve_with_source(provider.as_str()) {
1995 Some((value, source)) => {
1996 let source = match source {
1997 SecretSource::Keyring => RuntimeApiKeySource::Keyring,
1998 SecretSource::Env => RuntimeApiKeySource::Env,
1999 };
2000 (Some(value), Some(source))
2001 }
2002 None => match env_api_key_for_provider(provider) {
2003 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
2004 None => (None, None),
2005 },
2006 }
2007 };
2008
2009 let env_provider_model = env.model_for(provider, &base_url);
2010 let explicit_model = cli.model.is_some()
2011 || env.model.is_some()
2012 || env_provider_model.is_some()
2013 || provider_cfg.model.is_some()
2014 || root_deepseek_model.is_some()
2015 || self.model.is_some();
2016 let model = cli
2017 .model
2018 .clone()
2019 .or_else(|| env.model.clone())
2020 .or(env_provider_model)
2021 .or_else(|| provider_cfg.model.clone())
2022 .or(root_deepseek_model)
2023 .or_else(|| self.model.clone())
2024 .unwrap_or_else(|| {
2025 if provider == ProviderKind::Moonshot
2026 && (auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth)
2027 || moonshot_base_url_uses_kimi_code(&base_url))
2028 {
2029 DEFAULT_KIMI_CODE_MODEL.to_string()
2030 } else {
2031 default_model_for_provider(provider).to_string()
2032 }
2033 });
2034 let model =
2035 if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
2036 model.trim().to_string()
2037 } else {
2038 normalize_model_for_provider(provider, &model)
2039 };
2040
2041 let mut http_headers = self.http_headers.clone();
2042 http_headers.extend(provider_cfg.http_headers.clone());
2043 if let Some(env_headers) = env.http_headers {
2044 http_headers.extend(env_headers);
2045 }
2046 http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
2047
2048 let output_mode = cli
2049 .output_mode
2050 .clone()
2051 .or_else(|| env.output_mode.clone())
2052 .or_else(|| self.output_mode.clone());
2053 let log_level = cli
2054 .log_level
2055 .clone()
2056 .or_else(|| env.log_level.clone())
2057 .or_else(|| self.log_level.clone());
2058 let telemetry = cli
2059 .telemetry
2060 .or(env.telemetry)
2061 .or(self.telemetry)
2062 .unwrap_or(false);
2063 let approval_policy = cli
2064 .approval_policy
2065 .clone()
2066 .or_else(|| env.approval_policy.clone())
2067 .or_else(|| self.approval_policy.clone());
2068 let sandbox_mode = cli
2069 .sandbox_mode
2070 .clone()
2071 .or_else(|| env.sandbox_mode.clone())
2072 .or_else(|| self.sandbox_mode.clone());
2073 let yolo = cli.yolo.or(env.yolo);
2074 let verbosity = cli
2075 .verbosity
2076 .clone()
2077 .or_else(|| env.verbosity.clone())
2078 .or_else(|| self.verbosity.clone());
2079
2080 ResolvedRuntimeOptions {
2081 provider,
2082 provider_source,
2083 model,
2084 api_key,
2085 api_key_source,
2086 base_url,
2087 auth_mode,
2088 insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false),
2089 output_mode,
2090 log_level,
2091 telemetry,
2092 approval_policy,
2093 sandbox_mode,
2094 yolo,
2095 verbosity,
2096 http_headers,
2097 }
2098 }
2099}
2100
2101fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
2102 if source.model.is_some() {
2103 target.model = source.model.clone();
2104 }
2105}
2106
2107#[must_use]
2108pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
2109 let Some(project_rank) = approval_policy_rank(project) else {
2110 return false;
2111 };
2112 match current.and_then(approval_policy_rank) {
2113 Some(current_rank) => project_rank >= current_rank,
2114 None => project_rank >= 2,
2115 }
2116}
2117
2118#[must_use]
2119pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
2120 let normalized_project = project.trim().to_ascii_lowercase();
2121 if normalized_project == "external-sandbox" {
2122 return current
2123 .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
2124 .unwrap_or(false);
2125 }
2126
2127 let Some(project_rank) = sandbox_mode_rank(project) else {
2128 return false;
2129 };
2130 match current.and_then(sandbox_mode_rank) {
2131 Some(current_rank) => project_rank >= current_rank,
2132 None => project_rank >= 2,
2133 }
2134}
2135
2136fn approval_policy_rank(value: &str) -> Option<u8> {
2137 match value.trim().to_ascii_lowercase().as_str() {
2138 "auto" => Some(0),
2139 "suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
2140 "never" | "deny" | "denied" => Some(2),
2141 _ => None,
2142 }
2143}
2144
2145fn sandbox_mode_rank(value: &str) -> Option<u8> {
2146 match value.trim().to_ascii_lowercase().as_str() {
2147 "danger-full-access" => Some(0),
2148 "external-sandbox" => Some(0),
2149 "workspace-write" => Some(1),
2150 "read-only" => Some(2),
2151 _ => None,
2152 }
2153}
2154
2155pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
2161 for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
2162 let path = workspace.join(dir).join(CONFIG_FILE_NAME);
2163 if !project_config_candidate_exists(&path) {
2164 continue;
2165 }
2166 let raw = match read_checked_config_file(&path) {
2167 Ok(raw) => raw,
2168 Err(e) => {
2169 tracing::warn!("Failed to read project config {}: {e:#}", path.display());
2170 return None;
2171 }
2172 };
2173 match toml::from_str(&raw) {
2174 Ok(config) => return Some(config),
2175 Err(e) => {
2176 tracing::warn!("Failed to parse project config {}: {e}", path.display());
2177 return None;
2178 }
2179 }
2180 }
2181 None
2182}
2183
2184fn project_config_candidate_exists(path: &Path) -> bool {
2185 fs::symlink_metadata(path).is_ok_and(|metadata| {
2186 let file_type = metadata.file_type();
2187 file_type.is_file() || file_type.is_symlink()
2188 })
2189}
2190
2191fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
2192 if matches!(provider, ProviderKind::XiaomiMimo)
2193 && let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
2194 {
2195 return canonical.to_string();
2196 }
2197 if matches!(provider, ProviderKind::Minimax)
2198 && let Some(canonical) = canonical_minimax_model_id(model)
2199 {
2200 return canonical.to_string();
2201 }
2202 if matches!(provider, ProviderKind::Zai)
2203 && let Some(canonical) = canonical_zai_model_id(model)
2204 {
2205 return canonical.to_string();
2206 }
2207
2208 if matches!(
2209 provider,
2210 ProviderKind::Atlascloud
2211 | ProviderKind::WanjieArk
2212 | ProviderKind::Volcengine
2213 | ProviderKind::XiaomiMimo
2214 | ProviderKind::Zai
2215 | ProviderKind::Stepfun
2216 | ProviderKind::Minimax
2217 | ProviderKind::Ollama
2218 ) {
2219 return model.to_string();
2220 }
2221
2222 let normalized = model.trim().to_ascii_lowercase();
2223 if provider == ProviderKind::Openrouter
2224 && let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
2225 {
2226 return canonical.to_string();
2227 }
2228 match (provider, normalized.as_str()) {
2229 (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
2230 DEFAULT_NVIDIA_NIM_MODEL.to_string()
2231 }
2232 (
2233 ProviderKind::NvidiaNim,
2234 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2235 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2236 ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
2237 (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
2238 DEFAULT_OPENROUTER_MODEL.to_string()
2239 }
2240 (
2241 ProviderKind::Openrouter,
2242 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2243 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2244 ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
2245 (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
2246 DEFAULT_NOVITA_MODEL.to_string()
2247 }
2248 (
2249 ProviderKind::Novita,
2250 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2251 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2252 ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
2253 (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
2254 DEFAULT_FIREWORKS_MODEL.to_string()
2255 }
2256 (
2257 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2258 "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
2259 ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
2260 (
2261 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2262 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
2263 ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
2264 (
2265 ProviderKind::Arcee,
2266 "trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking",
2267 ) => DEFAULT_ARCEE_MODEL.to_string(),
2268 (ProviderKind::Arcee, "trinity-mini" | "arcee-trinity-mini") => {
2269 ARCEE_TRINITY_MINI_MODEL.to_string()
2270 }
2271 (ProviderKind::Arcee, "arcee-trinity-large-preview") => {
2272 ARCEE_TRINITY_LARGE_PREVIEW_MODEL.to_string()
2273 }
2274 (
2275 ProviderKind::Moonshot,
2276 "kimi"
2277 | "kimi-k2"
2278 | "kimi-k2.7"
2279 | "kimi-k2-7"
2280 | "kimi-k2.7-code"
2281 | "kimi-k2-7-code"
2282 | "kimi-code"
2283 | "moonshot-kimi-k2.7-code",
2284 ) => DEFAULT_MOONSHOT_MODEL.to_string(),
2285 (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
2286 MOONSHOT_KIMI_K2_6_MODEL.to_string()
2287 }
2288 (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
2289 DEFAULT_SGLANG_MODEL.to_string()
2290 }
2291 (
2292 ProviderKind::Sglang,
2293 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2294 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2295 ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
2296 (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
2297 DEFAULT_VLLM_MODEL.to_string()
2298 }
2299 (
2300 ProviderKind::Vllm,
2301 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2302 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2303 ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
2304 (ProviderKind::Huggingface, "deepseek-v4-pro" | "deepseek-v4pro") => {
2305 DEFAULT_HUGGINGFACE_MODEL.to_string()
2306 }
2307 (
2308 ProviderKind::Huggingface,
2309 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2310 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2311 ) => DEFAULT_HUGGINGFACE_FLASH_MODEL.to_string(),
2312 (ProviderKind::Together, "deepseek-v4-pro" | "deepseek-v4pro") => {
2313 DEFAULT_TOGETHER_MODEL.to_string()
2314 }
2315 (
2316 ProviderKind::Together,
2317 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2318 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2319 ) => DEFAULT_TOGETHER_FLASH_MODEL.to_string(),
2320 (ProviderKind::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
2321 DEFAULT_DEEPINFRA_MODEL.to_string()
2322 }
2323 (
2324 ProviderKind::Deepinfra,
2325 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2326 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2327 ) => DEFAULT_DEEPINFRA_FLASH_MODEL.to_string(),
2328 _ => model.to_string(),
2329 }
2330}
2331
2332fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> {
2333 let normalized = model.trim().to_ascii_lowercase();
2334 let normalized = normalized.replace(['_', ' '], "-");
2335 match normalized.as_str() {
2336 "mimo"
2337 | DEFAULT_XIAOMI_MIMO_MODEL
2338 | "mimo-v2-5-pro"
2339 | "xiaomi-mimo-v2.5-pro"
2340 | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL),
2341 "omni"
2342 | "mimo-omni"
2343 | "v2.5-omni"
2344 | "v25-omni"
2345 | "mimo-v2.5"
2346 | "mimo-v25"
2347 | "mimo-v2-5"
2348 | "mimo-v2.5-omni"
2349 | "mimo-v25-omni"
2350 | "mimo-v2-5-omni"
2351 | "xiaomi-mimo-v2.5"
2352 | "xiaomi-mimo-v2-5"
2353 | "xiaomi-mimo-v2.5-omni"
2354 | "xiaomi-mimo-v2-5-omni" => Some(XIAOMI_MIMO_V2_5_OMNI_MODEL),
2355 "asr" | "mimo-asr" | "mimo-v2.5-asr" | "speech-to-text" | "transcribe" => {
2356 Some(XIAOMI_MIMO_ASR_MODEL)
2357 }
2358 "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => {
2359 Some(XIAOMI_MIMO_TTS_MODEL)
2360 }
2361 "mimo-tts-voicedesign"
2362 | "mimo-voice-design"
2363 | "mimo-v25-tts-voicedesign"
2364 | "mimo-v2.5-tts-voicedesign"
2365 | "voicedesign"
2366 | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL),
2367 "mimo-tts-voiceclone"
2368 | "mimo-voice-clone"
2369 | "mimo-v25-tts-voiceclone"
2370 | "mimo-v2.5-tts-voiceclone"
2371 | "voiceclone"
2372 | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL),
2373 "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL),
2374 _ => None,
2375 }
2376}
2377
2378fn canonical_minimax_model_id(model: &str) -> Option<&'static str> {
2379 let normalized = model.trim().to_ascii_lowercase();
2380 let normalized = normalized.replace(['_', ' '], "-");
2381 match normalized.as_str() {
2382 "minimax" | "minimax-m3" | "minimax-m-3" | "minimax-m-3-thinking" => {
2383 Some(DEFAULT_MINIMAX_MODEL)
2384 }
2385 "minimax-m2.7" | "minimax-m2-7" | "minimax-m-2.7" | "minimax-m-2-7" => {
2386 Some(MINIMAX_M2_7_MODEL)
2387 }
2388 "minimax-m2.7-highspeed"
2389 | "minimax-m2-7-highspeed"
2390 | "minimax-m-2.7-highspeed"
2391 | "minimax-m-2-7-highspeed" => Some(MINIMAX_M2_7_HIGHSPEED_MODEL),
2392 "minimax-m2.5" | "minimax-m2-5" | "minimax-m-2.5" | "minimax-m-2-5" => {
2393 Some(MINIMAX_M2_5_MODEL)
2394 }
2395 "minimax-m2.5-highspeed"
2396 | "minimax-m2-5-highspeed"
2397 | "minimax-m-2.5-highspeed"
2398 | "minimax-m-2-5-highspeed" => Some(MINIMAX_M2_5_HIGHSPEED_MODEL),
2399 "minimax-m2.1" | "minimax-m2-1" | "minimax-m-2.1" | "minimax-m-2-1" => {
2400 Some(MINIMAX_M2_1_MODEL)
2401 }
2402 "minimax-m2.1-highspeed"
2403 | "minimax-m2-1-highspeed"
2404 | "minimax-m-2.1-highspeed"
2405 | "minimax-m-2-1-highspeed" => Some(MINIMAX_M2_1_HIGHSPEED_MODEL),
2406 "minimax-m2" | "minimax-m-2" => Some(MINIMAX_M2_MODEL),
2407 _ => None,
2408 }
2409}
2410
2411fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
2412 let normalized = model.trim().to_ascii_lowercase();
2413 let normalized = normalized.replace(['_', ' '], "-");
2414 match normalized.as_str() {
2415 "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
2416 "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(DEFAULT_ZAI_MODEL),
2417 "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
2418 _ => None,
2419 }
2420}
2421
2422fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
2423 let normalized = model.trim().to_ascii_lowercase();
2424 let normalized = normalized.replace(['_', ' '], "-");
2425 match normalized.as_str() {
2426 OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
2427 | "trinity"
2428 | "trinity-large-thinking"
2429 | "arcee-trinity"
2430 | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
2431 OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
2432 Some(OPENROUTER_GEMMA_4_31B_MODEL)
2433 }
2434 OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
2435 Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
2436 }
2437 OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
2438 Some(OPENROUTER_GLM_5_1_MODEL)
2439 }
2440 OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
2441 Some(OPENROUTER_GLM_5_2_MODEL)
2442 }
2443 OPENROUTER_KIMI_K2_7_CODE_MODEL
2444 | "kimi"
2445 | "kimi-k2"
2446 | "kimi-k2.7"
2447 | "kimi-k2-7"
2448 | "kimi-k2.7-code"
2449 | "kimi-k2-7-code"
2450 | "kimi-code"
2451 | "moonshot-kimi-k2.7-code"
2452 | "openrouter-kimi-k2.7-code" => Some(OPENROUTER_KIMI_K2_7_CODE_MODEL),
2453 OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
2454 Some(OPENROUTER_KIMI_K2_6_MODEL)
2455 }
2456 OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => {
2457 Some(OPENROUTER_MINIMAX_M3_MODEL)
2458 }
2459 OPENROUTER_MINIMAX_2_7_MODEL
2460 | "minimax-2.7"
2461 | "minimax-2-7"
2462 | "minimax-m2.7"
2463 | "minimax-m2-7"
2464 | "minimax-m-2.7"
2465 | "minimax-m-2-7" => Some(OPENROUTER_MINIMAX_2_7_MODEL),
2466 OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
2467 | "nemotron-3-nano-omni"
2468 | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
2469 OPENROUTER_QWEN_3_6_35B_A3B_MODEL
2470 | "qwen3.6-35b-a3b"
2471 | "qwen-3.6-35b-a3b"
2472 | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
2473 OPENROUTER_QWEN_3_6_FLASH_MODEL | "qwen3.6-flash" | "qwen-3.6-flash" => {
2474 Some(OPENROUTER_QWEN_3_6_FLASH_MODEL)
2475 }
2476 OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL
2477 | "qwen3.6-max-preview"
2478 | "qwen-3.6-max-preview"
2479 | "qwen-max-preview" => Some(OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
2480 OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
2481 Some(OPENROUTER_QWEN_3_6_27B_MODEL)
2482 }
2483 OPENROUTER_QWEN_3_6_PLUS_MODEL | "qwen3.6-plus" | "qwen-3.6-plus" => {
2484 Some(OPENROUTER_QWEN_3_6_PLUS_MODEL)
2485 }
2486 OPENROUTER_QWEN_3_7_MAX_MODEL | "qwen3.7-max" | "qwen-3.7-max" => {
2487 Some(OPENROUTER_QWEN_3_7_MAX_MODEL)
2488 }
2489 OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
2490 Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
2491 }
2492 OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
2493 | "mimo-v2.5-pro"
2494 | "mimo-v2-5-pro"
2495 | "xiaomi-mimo-v2.5-pro"
2496 | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
2497 OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
2498 | "mimo-v2.5"
2499 | "mimo-v2-5"
2500 | "xiaomi-mimo-v2.5"
2501 | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
2502 _ => None,
2503 }
2504}
2505
2506fn default_model_for_provider(provider: ProviderKind) -> &'static str {
2507 match provider {
2508 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
2509 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
2510 ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
2511 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
2512 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
2513 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
2514 ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
2515 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
2516 ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
2517 ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
2518 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_MODEL,
2519 ProviderKind::Arcee => DEFAULT_ARCEE_MODEL,
2520 ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
2521 ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
2522 ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
2523 ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
2524 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
2525 ProviderKind::Together => DEFAULT_TOGETHER_MODEL,
2526 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL,
2527 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_MODEL,
2528 ProviderKind::Zai => DEFAULT_ZAI_MODEL,
2529 ProviderKind::Stepfun => DEFAULT_STEPFUN_MODEL,
2530 ProviderKind::Minimax => DEFAULT_MINIMAX_MODEL,
2531 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
2532 }
2533}
2534
2535fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
2536 match provider {
2537 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
2538 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
2539 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
2540 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
2541 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
2542 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
2543 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
2544 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
2545 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
2546 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
2547 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
2548 ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL,
2549 ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL,
2550 ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
2551 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
2552 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
2553 ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
2554 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
2555 ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL,
2556 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
2557 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
2558 ProviderKind::Zai => DEFAULT_ZAI_BASE_URL,
2559 ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL,
2560 ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL,
2561 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
2562 }
2563}
2564
2565fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
2566 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
2567 normalized == DEFAULT_KIMI_CODE_BASE_URL
2568 || normalized == "https://api.kimi.com/coding"
2569 || normalized.starts_with("https://api.kimi.com/coding/")
2570}
2571
2572fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
2573 let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
2574 if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
2575 return None;
2576 }
2577 Some(match normalized.as_str() {
2578 "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => {
2579 DEFAULT_XIAOMI_MIMO_BASE_URL
2580 }
2581 "token-plan-cn"
2582 | "token-plan-china"
2583 | "token-plan-mainland"
2584 | "token-plan-mainland-china"
2585 | "cn"
2586 | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
2587 "token-plan-sgp"
2588 | "token-plan-sg"
2589 | "token-plan-singapore"
2590 | "sgp"
2591 | "sg"
2592 | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
2593 "token-plan-ams"
2594 | "token-plan-eu"
2595 | "token-plan-europe"
2596 | "token-plan-amsterdam"
2597 | "ams"
2598 | "eu"
2599 | "europe"
2600 | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
2601 _ => DEFAULT_XIAOMI_MIMO_BASE_URL,
2602 })
2603}
2604
2605fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
2606 matches!(
2607 normalized_mode,
2608 "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
2609 )
2610}
2611
2612fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
2613 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
2614 normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
2615 || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL
2616 || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
2617}
2618
2619fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option<String> {
2620 candidates.iter().find_map(|name| {
2621 std::env::var(name)
2622 .ok()
2623 .filter(|value| !value.trim().is_empty())
2624 })
2625}
2626
2627fn xiaomi_mimo_env_api_key_for_runtime(
2628 mode: Option<&str>,
2629 base_url: Option<&str>,
2630) -> Option<String> {
2631 const TOKEN_PLAN_ENV_VARS: &[&str] =
2632 &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"];
2633 const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"];
2634
2635 let normalized_mode =
2636 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
2637 let standard_selected = normalized_mode
2638 .as_deref()
2639 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint)
2640 || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go);
2641 if standard_selected {
2642 return xiaomi_mimo_env_var(STANDARD_ENV_VARS);
2643 }
2644
2645 let token_plan_selected = normalized_mode
2646 .as_deref()
2647 .and_then(xiaomi_mimo_base_url_for_mode)
2648 .is_some()
2649 || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan);
2650 if token_plan_selected {
2651 return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS);
2652 }
2653
2654 xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS))
2655}
2656
2657fn resolve_xiaomi_mimo_base_url(
2658 configured: Option<String>,
2659 api_key: Option<&str>,
2660 mode: Option<&str>,
2661) -> String {
2662 let normalized_mode =
2663 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
2664 let uses_standard_mode = normalized_mode
2665 .as_deref()
2666 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint);
2667 let mode_base_url = normalized_mode
2668 .as_deref()
2669 .and_then(xiaomi_mimo_base_url_for_mode);
2670 let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key);
2671 match configured {
2672 Some(base_url) if uses_standard_mode => base_url,
2673 Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => {
2674 mode_base_url
2675 .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL)
2676 .to_string()
2677 }
2678 Some(base_url) => base_url,
2679 None => {
2680 if let Some(base_url) = mode_base_url {
2681 base_url.to_string()
2682 } else if uses_standard_mode {
2683 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
2684 } else if uses_token_plan || api_key.is_none() {
2685 DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()
2686 } else {
2687 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
2688 }
2689 }
2690 }
2691}
2692
2693fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
2694 api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
2695}
2696
2697fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool {
2698 matches!(
2699 base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
2700 "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
2701 )
2702}
2703
2704fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
2705 if provider.is_siliconflow() && siliconflow_base_url_is_official(base_url) {
2706 return false;
2707 }
2708 if provider == ProviderKind::XiaomiMimo
2709 && (xiaomi_mimo_base_url_uses_token_plan(base_url)
2710 || xiaomi_mimo_base_url_is_pay_as_you_go(base_url))
2711 {
2712 return false;
2713 }
2714 let actual = base_url.trim_end_matches('/');
2715 let default = default_base_url_for_provider(provider).trim_end_matches('/');
2716 actual != default
2717}
2718
2719fn siliconflow_base_url_is_official(base_url: &str) -> bool {
2720 matches!(
2721 base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
2722 "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
2723 )
2724}
2725
2726fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
2727 base_url_is_custom_for_provider(provider, base_url)
2728}
2729
2730fn should_skip_secret_store_for_provider(
2731 provider: ProviderKind,
2732 base_url: &str,
2733 auth_mode: Option<&str>,
2734) -> bool {
2735 if auth_mode_requires_api_key(auth_mode) {
2736 return false;
2737 }
2738 if auth_mode_disables_api_key(auth_mode) {
2739 return true;
2740 }
2741
2742 matches!(
2743 provider,
2744 ProviderKind::Sglang | ProviderKind::Vllm | ProviderKind::Ollama
2745 ) || base_url_uses_local_host(base_url)
2746}
2747
2748fn env_api_key_for_provider(provider: ProviderKind) -> Option<String> {
2749 if provider == ProviderKind::Huggingface {
2750 return std::env::var("HUGGINGFACE_API_KEY")
2751 .ok()
2752 .filter(|value| !value.trim().is_empty())
2753 .or_else(|| {
2754 std::env::var("HF_TOKEN")
2755 .ok()
2756 .filter(|value| !value.trim().is_empty())
2757 });
2758 }
2759
2760 codewhale_secrets::env_for(provider.as_str())
2761}
2762
2763fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
2764 matches!(
2765 auth_mode
2766 .map(str::trim)
2767 .filter(|value| !value.is_empty())
2768 .map(|value| value.to_ascii_lowercase()),
2769 Some(value)
2770 if matches!(
2771 value.as_str(),
2772 "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
2773 )
2774 )
2775}
2776
2777fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
2778 matches!(
2779 auth_mode
2780 .map(str::trim)
2781 .filter(|value| !value.is_empty())
2782 .map(|value| value.to_ascii_lowercase()),
2783 Some(value)
2784 if matches!(
2785 value.as_str(),
2786 "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
2787 )
2788 )
2789}
2790
2791fn auth_mode_uses_kimi_oauth(auth_mode: &str) -> bool {
2792 matches!(
2793 auth_mode
2794 .trim()
2795 .to_ascii_lowercase()
2796 .replace('-', "_")
2797 .as_str(),
2798 "kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
2799 )
2800}
2801
2802fn base_url_uses_local_host(base_url: &str) -> bool {
2803 let Some(host) = base_url_host(base_url) else {
2804 return false;
2805 };
2806 let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
2807 if matches!(host.as_str(), "localhost" | "0.0.0.0") {
2808 return true;
2809 }
2810 host.parse::<std::net::IpAddr>()
2811 .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
2812}
2813
2814fn base_url_host(base_url: &str) -> Option<&str> {
2815 let without_scheme = base_url
2816 .split_once("://")
2817 .map_or(base_url, |(_, rest)| rest);
2818 let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
2819 if let Some(rest) = authority.strip_prefix('[') {
2820 return rest.split_once(']').map(|(host, _)| host);
2821 }
2822 authority.split(':').next().filter(|host| !host.is_empty())
2823}
2824
2825#[derive(Debug, Clone, Default)]
2826pub struct CliRuntimeOverrides {
2827 pub provider: Option<ProviderKind>,
2828 pub model: Option<String>,
2829 pub api_key: Option<String>,
2830 pub base_url: Option<String>,
2831 pub auth_mode: Option<String>,
2832 pub output_mode: Option<String>,
2833 pub log_level: Option<String>,
2834 pub telemetry: Option<bool>,
2835 pub approval_policy: Option<String>,
2836 pub sandbox_mode: Option<String>,
2837 pub yolo: Option<bool>,
2838 pub verbosity: Option<String>,
2839}
2840
2841#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2842pub enum RuntimeApiKeySource {
2843 Cli,
2844 ConfigFile,
2845 Keyring,
2846 Env,
2847}
2848
2849impl RuntimeApiKeySource {
2850 #[must_use]
2851 pub fn as_env_value(self) -> &'static str {
2852 match self {
2853 Self::Cli => "cli",
2854 Self::ConfigFile => "config",
2855 Self::Keyring => "keyring",
2856 Self::Env => "env",
2857 }
2858 }
2859}
2860
2861#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2862pub enum ProviderSource {
2863 Cli,
2864 Env(&'static str),
2865 Config,
2866}
2867
2868#[derive(Debug, Clone)]
2869pub struct ResolvedRuntimeOptions {
2870 pub provider: ProviderKind,
2871 pub provider_source: ProviderSource,
2872 pub model: String,
2873 pub api_key: Option<String>,
2874 pub api_key_source: Option<RuntimeApiKeySource>,
2875 pub base_url: String,
2876 pub auth_mode: Option<String>,
2877 pub insecure_skip_tls_verify: bool,
2878 pub output_mode: Option<String>,
2879 pub log_level: Option<String>,
2880 pub telemetry: bool,
2881 pub approval_policy: Option<String>,
2882 pub sandbox_mode: Option<String>,
2883 pub yolo: Option<bool>,
2884 pub verbosity: Option<String>,
2885 pub http_headers: BTreeMap<String, String>,
2886}
2887
2888#[derive(Debug, Clone)]
2889pub struct ConfigStore {
2890 path: PathBuf,
2891 pub config: ConfigToml,
2892 permissions: PermissionsToml,
2893 original_raw: Option<String>,
2896}
2897
2898impl ConfigStore {
2899 pub fn load(path: Option<PathBuf>) -> Result<Self> {
2900 let path = resolve_config_path(path)?;
2901 let (config, original_raw) = if checked_path_exists(&path)? {
2902 let raw = read_checked_config_file(&path)?;
2903 let parsed: ConfigToml = toml::from_str(&raw)
2904 .with_context(|| format!("failed to parse config at {}", path.display()))?;
2905 (parsed, Some(raw))
2906 } else {
2907 (ConfigToml::default(), None)
2908 };
2909 let permissions = load_sibling_permissions(&path)?;
2910
2911 Ok(Self {
2912 path,
2913 config,
2914 permissions,
2915 original_raw,
2916 })
2917 }
2918
2919 pub fn save(&self) -> Result<()> {
2920 let path = normalize_config_file_path(self.path.clone())?;
2921 if let Some(parent) = path.parent() {
2922 fs::create_dir_all(parent).with_context(|| {
2923 format!("failed to create config directory {}", parent.display())
2924 })?;
2925 }
2926 let body = if let Some(ref original_raw) = self.original_raw {
2927 let serialized =
2928 toml::to_string_pretty(&self.config).context("failed to serialize config")?;
2929 merge_and_preserve_comments(&serialized, original_raw).unwrap_or_else(|e| {
2930 tracing::warn!("failed to merge config comments, saving without them: {e:#}");
2931 serialized
2932 })
2933 } else {
2934 toml::to_string_pretty(&self.config).context("failed to serialize config")?
2935 };
2936 if checked_path_exists(&path)? {
2937 let existing = read_checked_config_file(&path)?;
2938 if existing == body {
2939 return Ok(());
2940 }
2941 write_one_time_config_backup(&path)?;
2942 }
2943 #[cfg(unix)]
2944 {
2945 let mut file = fs::OpenOptions::new()
2946 .write(true)
2947 .create(true)
2948 .truncate(true)
2949 .mode(0o600)
2950 .open(&path)
2951 .with_context(|| format!("failed to write config at {}", path.display()))?;
2952 file.write_all(body.as_bytes())
2953 .with_context(|| format!("failed to write config at {}", path.display()))?;
2954 file.set_permissions(fs::Permissions::from_mode(0o600))
2955 .with_context(|| {
2956 format!("failed to set config permissions at {}", path.display())
2957 })?;
2958 }
2959 #[cfg(not(unix))]
2960 {
2961 fs::write(&path, body)
2962 .with_context(|| format!("failed to write config at {}", path.display()))?;
2963 }
2964 Ok(())
2965 }
2966
2967 #[must_use]
2968 pub fn path(&self) -> &Path {
2969 &self.path
2970 }
2971
2972 #[must_use]
2973 pub fn permissions(&self) -> &PermissionsToml {
2974 &self.permissions
2975 }
2976
2977 #[must_use]
2978 pub fn permissions_path(&self) -> PathBuf {
2979 checked_permissions_path_for_config_path(&self.path)
2980 .expect("ConfigStore path is validated before construction")
2981 }
2982
2983 #[must_use]
2984 pub fn exec_policy_engine(&self) -> ExecPolicyEngine {
2985 if self.permissions.is_empty() {
2986 ExecPolicyEngine::new(Vec::new(), Vec::new())
2987 } else {
2988 ExecPolicyEngine::with_rulesets(vec![self.permissions.ruleset()])
2989 }
2990 }
2991
2992 pub fn append_ask_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
2999 if rules.is_empty() {
3000 return Ok(0);
3001 }
3002
3003 let path = checked_permissions_path_for_config_path(&self.path)?;
3004 let raw = if checked_path_exists(&path)? {
3005 read_checked_permissions_file(&path)?
3006 } else {
3007 String::new()
3008 };
3009 let mut permissions = if raw.trim().is_empty() {
3010 PermissionsToml::default()
3011 } else {
3012 toml::from_str(&raw)
3013 .with_context(|| format!("failed to parse permissions at {}", path.display()))?
3014 };
3015 let mut document = if raw.trim().is_empty() {
3016 toml_edit::DocumentMut::new()
3017 } else {
3018 raw.parse::<toml_edit::DocumentMut>()
3019 .with_context(|| format!("failed to edit permissions at {}", path.display()))?
3020 };
3021
3022 if !document.contains_key("rules") {
3023 document["rules"] = toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
3024 }
3025 let rules_item = document
3026 .get_mut("rules")
3027 .expect("rules entry was inserted above");
3028
3029 let mut added = 0;
3030 for rule in rules {
3031 if permissions.rules.contains(rule) {
3032 continue;
3033 }
3034 append_ask_rule(rules_item, rule)?;
3035 permissions.rules.push(rule.clone());
3036 added += 1;
3037 }
3038 if added == 0 {
3039 self.permissions = permissions;
3040 return Ok(0);
3041 }
3042
3043 let body = document.to_string();
3044 let persisted: PermissionsToml = toml::from_str(&body).with_context(|| {
3045 format!(
3046 "generated invalid permissions document for {}",
3047 path.display()
3048 )
3049 })?;
3050 write_permissions_atomic(&path, body.as_bytes())?;
3051 self.permissions = persisted;
3052 Ok(added)
3053 }
3054}
3055
3056fn config_backup_file_name(path: &Path) -> OsString {
3057 let mut file_name = path
3058 .file_name()
3059 .map(OsString::from)
3060 .unwrap_or_else(|| OsString::from(CONFIG_FILE_NAME));
3061 file_name.push(".bak");
3062 file_name
3063}
3064
3065fn config_sibling_path_unchecked(config_path: &Path, file_name: &OsStr) -> PathBuf {
3066 config_path
3067 .parent()
3068 .unwrap_or_else(|| Path::new("."))
3069 .join(file_name)
3070}
3071
3072fn checked_config_sibling_path(config_path: &Path, file_name: &OsStr) -> Result<PathBuf> {
3073 let config_path = normalize_config_file_path(config_path.to_path_buf())?;
3074 let parent = config_path
3075 .parent()
3076 .context("config path must include a parent directory")?;
3077 let path = parent.join(file_name);
3078 reject_path_symlink(&path)?;
3079 Ok(path)
3080}
3081
3082#[cfg(test)]
3083fn config_backup_path(path: &Path) -> PathBuf {
3084 config_sibling_path_unchecked(path, &config_backup_file_name(path))
3085}
3086
3087fn checked_config_backup_path(path: &Path) -> Result<PathBuf> {
3088 checked_config_sibling_path(path, &config_backup_file_name(path))
3089}
3090
3091fn write_one_time_config_backup(path: &Path) -> Result<()> {
3092 let backup = checked_config_backup_path(path)?;
3093 if backup.exists() {
3094 return Ok(());
3095 }
3096 fs::copy(path, &backup).with_context(|| {
3097 format!(
3098 "failed to create config backup {} from {}",
3099 backup.display(),
3100 path.display()
3101 )
3102 })?;
3103 #[cfg(unix)]
3104 {
3105 fs::set_permissions(&backup, fs::Permissions::from_mode(0o600)).with_context(|| {
3106 format!(
3107 "failed to set config backup permissions at {}",
3108 backup.display()
3109 )
3110 })?;
3111 }
3112 Ok(())
3113}
3114
3115pub fn merge_and_preserve_comments(serialized: &str, original_raw: &str) -> Result<String> {
3123 let original = original_raw
3124 .parse::<toml_edit::DocumentMut>()
3125 .context("failed to parse original config for comment merge")?;
3126
3127 let mut new_doc = serialized
3128 .parse::<toml_edit::DocumentMut>()
3129 .context("failed to parse serialized config for comment merge")?;
3130
3131 new_doc.set_trailing(original.trailing().clone());
3134
3135 *new_doc.as_table_mut().decor_mut() = original.as_table().decor().clone();
3138
3139 merge_decor_table(new_doc.as_table_mut(), original.as_table());
3140
3141 Ok(new_doc.to_string())
3142}
3143
3144fn merge_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
3147 let keys: Vec<String> = source.iter().map(|(k, _)| k.to_owned()).collect();
3150 for key in &keys {
3151 let Some((source_key, source_item)) = source.get_key_value(key) else {
3152 continue;
3153 };
3154 let Some((mut target_key_mut, target_item)) = target.get_key_value_mut(key) else {
3155 continue;
3156 };
3157
3158 *target_key_mut.leaf_decor_mut() = source_key.leaf_decor().clone();
3160
3161 copy_item_decor(target_item, source_item);
3162
3163 if let (Some(tt), Some(st)) = (target_item.as_table_mut(), source_item.as_table()) {
3164 merge_decor_table(tt, st);
3165 }
3166
3167 if let (Some(ta), Some(sa)) = (
3168 target_item.as_array_of_tables_mut(),
3169 source_item.as_array_of_tables(),
3170 ) {
3171 for (i, source_table) in sa.iter().enumerate() {
3172 if let Some(target_table) = ta.get_mut(i) {
3173 copy_item_decor_table(target_table, source_table);
3174 merge_decor_table(target_table, source_table);
3175 }
3176 }
3177 }
3178 }
3179}
3180
3181fn copy_item_decor(target: &mut toml_edit::Item, source: &toml_edit::Item) {
3185 match (target, source) {
3186 (toml_edit::Item::Table(tt), toml_edit::Item::Table(st)) => {
3187 *tt.decor_mut() = st.decor().clone();
3188 }
3189 (toml_edit::Item::Value(tv), toml_edit::Item::Value(sv)) => {
3190 *tv.decor_mut() = sv.decor().clone();
3191 }
3192 _ => {}
3193 }
3194}
3195
3196fn copy_item_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
3197 *target.decor_mut() = source.decor().clone();
3198}
3199
3200pub fn default_secrets() -> &'static Secrets {
3205 static SECRETS: OnceLock<Secrets> = OnceLock::new();
3206 SECRETS.get_or_init(|| {
3207 #[cfg(test)]
3212 {
3213 Secrets::new(std::sync::Arc::new(
3214 codewhale_secrets::InMemoryKeyringStore::new(),
3215 ))
3216 }
3217 #[cfg(not(test))]
3218 {
3219 Secrets::auto_detect()
3220 }
3221 })
3222}
3223
3224pub const CODEWHALE_APP_DIR: &str = ".codewhale";
3233
3234pub const LEGACY_APP_DIR: &str = ".deepseek";
3236
3237pub fn codewhale_home() -> Result<PathBuf> {
3242 if let Ok(val) = std::env::var("CODEWHALE_HOME") {
3243 let trimmed = val.trim();
3244 if !trimmed.is_empty() {
3245 return Ok(PathBuf::from(trimmed));
3246 }
3247 }
3248 let home = effective_home_dir().context("failed to resolve home directory")?;
3249 Ok(home.join(CODEWHALE_APP_DIR))
3250}
3251
3252pub fn legacy_deepseek_home() -> Result<PathBuf> {
3256 let home = effective_home_dir().context("failed to resolve home directory")?;
3257 Ok(home.join(LEGACY_APP_DIR))
3258}
3259
3260fn effective_home_dir() -> Option<PathBuf> {
3261 std::env::var_os("HOME")
3262 .filter(|value| !value.is_empty())
3263 .map(PathBuf::from)
3264 .or_else(dirs::home_dir)
3265}
3266
3267fn ensure_safe_state_subdir(subdir: &str) -> Result<()> {
3275 if subdir.is_empty() {
3276 bail!("state subdir must not be empty");
3277 }
3278 let path = std::path::Path::new(subdir);
3279 if path.is_absolute() {
3280 bail!("state subdir must not be an absolute path: {subdir}");
3281 }
3282 if path.components().any(|c| {
3283 matches!(
3284 c,
3285 std::path::Component::RootDir | std::path::Component::Prefix(_)
3286 )
3287 }) {
3288 bail!("state subdir must not contain a root or prefix: {subdir}");
3289 }
3290 if path
3291 .components()
3292 .any(|c| matches!(c, std::path::Component::ParentDir))
3293 {
3294 bail!("state subdir must not contain parent-dir (..) components: {subdir}");
3295 }
3296 Ok(())
3297}
3298
3299pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
3306 ensure_safe_state_subdir(subdir)?;
3307 let primary = codewhale_home()?.join(subdir);
3308 if primary.exists() {
3309 return Ok(primary);
3310 }
3311 let legacy = legacy_deepseek_home()?.join(subdir);
3312 if legacy.exists() {
3313 return Ok(legacy);
3314 }
3315 Ok(primary)
3317}
3318
3319pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
3329 ensure_safe_state_subdir(subdir)?;
3330 let dir = codewhale_home()?.join(subdir);
3331 migrate_legacy_state_dir(&dir, subdir)?;
3332 std::fs::create_dir_all(&dir)
3333 .with_context(|| format!("failed to create {}/", dir.display()))?;
3334 Ok(dir)
3335}
3336
3337fn migrate_legacy_state_dir(primary: &Path, subdir: &str) -> Result<()> {
3342 if primary.exists() || subdir == "." || subdir.is_empty() {
3343 return Ok(());
3344 }
3345 let legacy = match legacy_deepseek_home() {
3346 Ok(home) => home.join(subdir),
3347 Err(_) => return Ok(()),
3348 };
3349 if !legacy.exists() {
3350 return Ok(());
3351 }
3352 if let Some(parent) = primary.parent() {
3354 if let Err(err) = std::fs::create_dir_all(parent) {
3355 tracing::warn!(
3356 target: "config::migration",
3357 "Could not create {} for state migration ({}); writing to primary anyway",
3358 parent.display(),
3359 err
3360 );
3361 }
3362 }
3363 match std::fs::rename(&legacy, primary) {
3364 Ok(()) => {
3365 tracing::info!(
3366 target: "config::migration",
3367 "Migrated legacy state directory {} -> {} (relocated). The .deepseek copy was removed.",
3368 legacy.display(),
3369 primary.display()
3370 );
3371 }
3372 Err(err) => {
3373 match copy_dir_recursive(&legacy, primary) {
3378 Ok(()) => {
3379 tracing::info!(
3380 target: "config::migration",
3381 "Migrated legacy state directory {} -> {} (copied; rename failed: {err}). \
3382 The legacy .deepseek copy was left in place.",
3383 legacy.display(),
3384 primary.display()
3385 );
3386 }
3387 Err(copy_err) => {
3388 tracing::warn!(
3389 target: "config::migration",
3390 "Could not migrate legacy state {} -> {} (rename: {err}; copy: {copy_err}). \
3391 New data is written to the primary path; the legacy tree remains untouched.",
3392 legacy.display(),
3393 primary.display()
3394 );
3395 }
3396 }
3397 }
3398 }
3399 Ok(())
3400}
3401
3402fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
3405 std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
3406 for entry in
3407 std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
3408 {
3409 let entry = entry.with_context(|| format!("failed to read entry in {}", src.display()))?;
3410 let path = entry.path();
3411 let target = dst.join(entry.file_name());
3412 let file_type = entry
3413 .file_type()
3414 .with_context(|| format!("failed to read file type for {}", path.display()))?;
3415 if file_type.is_dir() {
3416 copy_dir_recursive(&path, &target)?;
3417 } else if file_type.is_file() {
3418 std::fs::copy(&path, &target).with_context(|| {
3419 format!("failed to copy {} -> {}", path.display(), target.display())
3420 })?;
3421 }
3422 }
3423 Ok(())
3424}
3425
3426pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> Result<(bool, PathBuf)> {
3433 ensure_safe_state_subdir(subdir)?;
3434 let workspace = normalize_project_workspace(workspace)?;
3435 let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
3436 if primary.exists() {
3437 return Ok((true, primary));
3438 }
3439 let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
3440 Ok((false, legacy))
3441}
3442
3443pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
3446 ensure_safe_state_subdir(subdir)?;
3447 let workspace = normalize_project_workspace(workspace)?;
3448 let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
3449 std::fs::create_dir_all(&dir)
3450 .with_context(|| format!("failed to create {}/", dir.display()))?;
3451 Ok(dir)
3452}
3453
3454pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
3455 if let Some(path) = explicit {
3456 return normalize_config_file_path(path);
3457 }
3458 if let Ok(path) = std::env::var("CODEWHALE_CONFIG_PATH") {
3459 if let Some(path) = config_path_from_env_value(&path)? {
3460 return Ok(path);
3461 }
3462 return default_config_path();
3463 }
3464 if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
3465 if let Some(path) = config_path_from_env_value(&path)? {
3466 return Ok(path);
3467 }
3468 return default_config_path();
3469 }
3470 default_config_path()
3471}
3472
3473fn config_path_from_env_value(path: &str) -> Result<Option<PathBuf>> {
3474 let trimmed = path.trim();
3475 if trimmed.is_empty() {
3476 Ok(None)
3477 } else {
3478 normalize_config_file_path(PathBuf::from(trimmed)).map(Some)
3479 }
3480}
3481
3482#[must_use]
3483pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
3484 config_sibling_path_unchecked(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
3485}
3486
3487fn checked_permissions_path_for_config_path(config_path: &Path) -> Result<PathBuf> {
3488 checked_config_sibling_path(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
3489}
3490
3491pub fn resolve_permissions_path(config_path: Option<PathBuf>) -> Result<PathBuf> {
3492 checked_permissions_path_for_config_path(&resolve_config_path(config_path)?)
3493}
3494
3495fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
3496 let permissions_path = checked_permissions_path_for_config_path(config_path)?;
3497 if !checked_path_exists(&permissions_path)? {
3498 return Ok(PermissionsToml::default());
3499 }
3500
3501 let raw = read_checked_permissions_file(&permissions_path)?;
3502 toml::from_str(&raw).with_context(|| {
3503 format!(
3504 "failed to parse permissions at {}",
3505 permissions_path.display()
3506 )
3507 })
3508}
3509
3510fn append_ask_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
3511 match item {
3512 toml_edit::Item::ArrayOfTables(rules) => {
3513 rules.push(ask_rule_table(rule));
3514 Ok(())
3515 }
3516 toml_edit::Item::Value(value) => {
3517 let Some(rules) = value.as_array_mut() else {
3518 bail!("`rules` in permissions.toml must be an array");
3519 };
3520 rules.push(toml_edit::Value::InlineTable(ask_rule_inline_table(rule)));
3521 Ok(())
3522 }
3523 _ => bail!("`rules` in permissions.toml must be an array"),
3524 }
3525}
3526
3527fn ask_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
3528 let mut table = toml_edit::Table::new();
3529 table["tool"] = toml_edit::value(rule.tool.clone());
3530 if let Some(command) = rule.command.as_deref() {
3531 table["command"] = toml_edit::value(command);
3532 }
3533 if let Some(path) = rule.path.as_deref() {
3534 table["path"] = toml_edit::value(path);
3535 }
3536 table
3537}
3538
3539fn ask_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
3540 let mut table = toml_edit::InlineTable::new();
3541 table.insert("tool", toml_edit::Value::from(rule.tool.clone()));
3542 if let Some(command) = rule.command.as_deref() {
3543 table.insert("command", toml_edit::Value::from(command));
3544 }
3545 if let Some(path) = rule.path.as_deref() {
3546 table.insert("path", toml_edit::Value::from(path));
3547 }
3548 table
3549}
3550
3551fn write_permissions_atomic(path: &Path, body: &[u8]) -> Result<()> {
3552 let parent = path.parent().with_context(|| {
3553 format!(
3554 "permissions path has no parent directory: {}",
3555 path.display()
3556 )
3557 })?;
3558 fs::create_dir_all(parent).with_context(|| {
3559 format!(
3560 "failed to create permissions directory {}",
3561 parent.display()
3562 )
3563 })?;
3564
3565 let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
3566 format!(
3567 "failed to create temporary permissions file in {}",
3568 parent.display()
3569 )
3570 })?;
3571 #[cfg(unix)]
3572 temporary
3573 .as_file()
3574 .set_permissions(fs::Permissions::from_mode(0o600))
3575 .with_context(|| {
3576 format!(
3577 "failed to secure temporary permissions file for {}",
3578 path.display()
3579 )
3580 })?;
3581 temporary
3582 .write_all(body)
3583 .with_context(|| format!("failed to write permissions at {}", path.display()))?;
3584 temporary
3585 .as_file()
3586 .sync_all()
3587 .with_context(|| format!("failed to sync permissions at {}", path.display()))?;
3588 temporary
3589 .persist(path)
3590 .map_err(|error| error.error)
3591 .with_context(|| format!("failed to replace permissions at {}", path.display()))?;
3592 Ok(())
3593}
3594
3595pub fn default_config_path() -> Result<PathBuf> {
3596 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
3599 if primary.exists() {
3600 return Ok(primary);
3601 }
3602 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
3603 if legacy.exists() {
3604 return Ok(legacy);
3605 }
3606 Ok(primary)
3608}
3609
3610#[derive(Debug, Clone, PartialEq, Eq)]
3611pub struct ConfigMigration {
3612 pub legacy_path: PathBuf,
3613 pub primary_path: PathBuf,
3614}
3615
3616impl ConfigMigration {
3617 pub fn user_notice(&self) -> String {
3618 format!(
3619 "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.",
3620 self.legacy_path.display(),
3621 self.primary_path.display()
3622 )
3623 }
3624}
3625
3626pub fn migrate_config_if_needed() -> Result<Option<ConfigMigration>> {
3631 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
3632 if primary.exists() {
3633 return Ok(None);
3634 }
3635 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
3636 if !legacy.exists() {
3637 return Ok(None);
3638 }
3639 if let Some(parent) = primary.parent() {
3641 std::fs::create_dir_all(parent).context("failed to create codewhale config directory")?;
3642 }
3643 std::fs::copy(&legacy, &primary)
3644 .context("failed to migrate config from deepseek to codewhale home")?;
3645 tracing::info!(
3646 "Migrated config from {} to {}",
3647 legacy.display(),
3648 primary.display()
3649 );
3650 Ok(Some(ConfigMigration {
3651 legacy_path: legacy,
3652 primary_path: primary,
3653 }))
3654}
3655
3656fn parse_bool(raw: &str) -> Result<bool> {
3657 match raw.trim().to_ascii_lowercase().as_str() {
3658 "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
3659 "0" | "false" | "no" | "off" | "disabled" => Ok(false),
3660 _ => bail!("invalid boolean '{raw}'"),
3661 }
3662}
3663
3664fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
3665 let mut headers = BTreeMap::new();
3666 for pair in raw.trim().split(',') {
3667 let pair = pair.trim();
3668 if pair.is_empty() {
3669 continue;
3670 }
3671 let Some((name, value)) = pair.split_once('=') else {
3672 bail!("invalid header pair '{pair}', expected name=value");
3673 };
3674 let name = name.trim();
3675 let value = value.trim();
3676 if name.is_empty() {
3677 bail!("header name cannot be empty");
3678 }
3679 if value.is_empty() {
3680 continue;
3681 }
3682 headers.insert(name.to_string(), value.to_string());
3683 }
3684 Ok(headers)
3685}
3686
3687fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
3688 if headers.is_empty() {
3689 return None;
3690 }
3691 Some(
3692 headers
3693 .iter()
3694 .map(|(name, value)| format!("{name}={value}"))
3695 .collect::<Vec<_>>()
3696 .join(","),
3697 )
3698}
3699
3700fn serialize_http_headers_for_display(headers: &BTreeMap<String, String>) -> Option<String> {
3701 if headers.is_empty() {
3702 return None;
3703 }
3704 Some(
3705 headers
3706 .iter()
3707 .map(|(name, value)| {
3708 let display_value = if is_sensitive_config_key(name) {
3709 redact_secret(value)
3710 } else {
3711 value.clone()
3712 };
3713 format!("{name}={display_value}")
3714 })
3715 .collect::<Vec<_>>()
3716 .join(","),
3717 )
3718}
3719
3720fn redact_secret(secret: &str) -> String {
3721 let chars: Vec<char> = secret.chars().collect();
3722 if chars.len() <= 16 {
3723 return "********".to_string();
3724 }
3725 let prefix: String = chars.iter().take(4).collect();
3726 let suffix: String = chars
3727 .iter()
3728 .rev()
3729 .take(4)
3730 .collect::<Vec<_>>()
3731 .into_iter()
3732 .rev()
3733 .collect();
3734 format!("{prefix}***{suffix}")
3735}
3736
3737#[must_use]
3738pub fn is_sensitive_config_key(key: &str) -> bool {
3739 let Some(segment) = key.rsplit('.').next() else {
3740 return false;
3741 };
3742 let normalized = segment
3743 .trim()
3744 .trim_matches('"')
3745 .replace('-', "_")
3746 .to_ascii_lowercase();
3747
3748 matches!(
3749 normalized.as_str(),
3750 "api_key"
3751 | "apikey"
3752 | "api_keys"
3753 | "authorization"
3754 | "bearer"
3755 | "client_secret"
3756 | "credential"
3757 | "credentials"
3758 | "id_token"
3759 | "password"
3760 | "passwords"
3761 | "passwd"
3762 | "proxy_authorization"
3763 | "refresh_token"
3764 | "secret"
3765 | "secrets"
3766 | "token"
3767 | "tokens"
3768 ) || normalized.ends_with("_api_key")
3769 || normalized.ends_with("_authorization")
3770 || normalized.ends_with("_password")
3771 || normalized.ends_with("_secret")
3772 || normalized.ends_with("_token")
3773}
3774
3775fn redact_toml_value_for_display(key: &str, value: &toml::Value) -> String {
3776 redact_toml_value_for_display_inner(key, false, value).to_string()
3777}
3778
3779fn redact_toml_value_for_display_inner(
3780 key: &str,
3781 sensitive_ancestor: bool,
3782 value: &toml::Value,
3783) -> toml::Value {
3784 let sensitive = sensitive_ancestor || is_sensitive_config_key(key);
3785 match value {
3786 toml::Value::String(value) if sensitive => toml::Value::String(redact_secret(value)),
3787 toml::Value::Array(values) => toml::Value::Array(
3788 values
3789 .iter()
3790 .map(|value| redact_toml_value_for_display_inner(key, sensitive, value))
3791 .collect(),
3792 ),
3793 toml::Value::Table(table) => {
3794 let mut redacted = toml::map::Map::new();
3795 for (child_key, child_value) in table {
3796 let path = if key.is_empty() {
3797 child_key.clone()
3798 } else {
3799 format!("{key}.{child_key}")
3800 };
3801 redacted.insert(
3802 child_key.clone(),
3803 redact_toml_value_for_display_inner(&path, sensitive, child_value),
3804 );
3805 }
3806 toml::Value::Table(redacted)
3807 }
3808 _ if sensitive => toml::Value::String("********".to_string()),
3809 _ => value.clone(),
3810 }
3811}
3812
3813fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
3814 if path.as_os_str().is_empty() {
3815 bail!("config path cannot be empty");
3816 }
3817 if path
3818 .components()
3819 .any(|component| matches!(component, Component::ParentDir))
3820 {
3821 bail!("config path cannot contain '..' components");
3822 }
3823 if path.file_name().is_none() {
3824 bail!("config path must include a file name");
3825 }
3826 let absolute = if path.is_absolute() {
3827 path
3828 } else {
3829 std::env::current_dir()
3830 .context("failed to resolve current directory for config path")?
3831 .join(path)
3832 };
3833 let file_name = absolute
3834 .file_name()
3835 .map(OsString::from)
3836 .context("config path must include a file name")?;
3837 let parent = absolute
3838 .parent()
3839 .context("config path must include a parent directory")?;
3840 let parent = match parent.canonicalize() {
3841 Ok(parent) => parent,
3842 Err(err) if err.kind() == std::io::ErrorKind::NotFound => parent.to_path_buf(),
3843 Err(err) => {
3844 return Err(err).with_context(|| {
3845 format!("failed to resolve config directory {}", parent.display())
3846 });
3847 }
3848 };
3849 let normalized = parent.join(file_name);
3850 reject_path_symlink(&normalized)?;
3851 Ok(normalized)
3852}
3853
3854fn normalize_project_workspace(workspace: &Path) -> Result<PathBuf> {
3855 if workspace.as_os_str().is_empty() {
3856 bail!("project workspace path cannot be empty");
3857 }
3858 if workspace
3859 .components()
3860 .any(|component| matches!(component, Component::ParentDir))
3861 {
3862 bail!("project workspace path cannot contain '..' components");
3863 }
3864 let absolute = if workspace.is_absolute() {
3865 workspace.to_path_buf()
3866 } else {
3867 std::env::current_dir()
3868 .context("failed to resolve current directory for project workspace")?
3869 .join(workspace)
3870 };
3871 match absolute.canonicalize() {
3872 Ok(path) => Ok(path),
3873 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
3874 Ok(normalize_path_components(&absolute))
3875 }
3876 Err(err) => Err(err).with_context(|| {
3877 format!(
3878 "failed to resolve project workspace {}",
3879 workspace.display()
3880 )
3881 }),
3882 }
3883}
3884
3885fn normalize_path_components(path: &Path) -> PathBuf {
3886 let mut normalized = PathBuf::new();
3887 for component in path.components() {
3888 match component {
3889 Component::Prefix(_) | Component::RootDir => normalized.push(component.as_os_str()),
3890 Component::CurDir => {}
3891 Component::ParentDir => {
3892 normalized.pop();
3893 }
3894 Component::Normal(part) => normalized.push(part),
3895 }
3896 }
3897 if normalized.as_os_str().is_empty() {
3898 PathBuf::from(".")
3899 } else {
3900 normalized
3901 }
3902}
3903
3904fn checked_path_exists(path: &Path) -> Result<bool> {
3905 let path = normalize_config_file_path(path.to_path_buf())?;
3906 path.try_exists()
3907 .with_context(|| format!("failed to inspect config path {}", path.display()))
3908}
3909
3910fn read_checked_config_file(path: &Path) -> Result<String> {
3911 read_checked_toml_file(path, "config")
3912}
3913
3914fn read_checked_permissions_file(path: &Path) -> Result<String> {
3915 read_checked_toml_file(path, "permissions")
3916}
3917
3918fn read_checked_toml_file(path: &Path, label: &str) -> Result<String> {
3919 let path = normalize_config_file_path(path.to_path_buf())?;
3920 read_string_no_follow(&path)
3921 .with_context(|| format!("failed to read {label} at {}", path.display()))
3922}
3923
3924#[cfg(unix)]
3925fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
3926 let mut file = fs::OpenOptions::new()
3927 .read(true)
3928 .custom_flags(libc::O_NOFOLLOW)
3929 .open(path)?;
3930 let mut raw = String::new();
3931 file.read_to_string(&mut raw)?;
3932 Ok(raw)
3933}
3934
3935#[cfg(not(unix))]
3936fn read_string_no_follow(path: &Path) -> std::io::Result<String> {
3937 fs::read_to_string(path)
3938}
3939
3940fn reject_path_symlink(path: &Path) -> Result<()> {
3941 let Ok(metadata) = fs::symlink_metadata(path) else {
3942 return Ok(());
3943 };
3944 if metadata.file_type().is_symlink() {
3945 bail!("config path must not be a symlink: {}", path.display());
3946 }
3947 Ok(())
3948}
3949
3950#[derive(Debug, Clone, Default)]
3951struct EnvRuntimeOverrides {
3952 provider: Option<ProviderKind>,
3953 provider_source: Option<&'static str>,
3954 model: Option<String>,
3955 volcengine_model: Option<String>,
3956 wanjie_ark_model: Option<String>,
3957 openrouter_model: Option<String>,
3958 moonshot_model: Option<String>,
3959 xiaomi_mimo_model: Option<String>,
3960 xiaomi_mimo_mode: Option<String>,
3961 novita_model: Option<String>,
3962 fireworks_model: Option<String>,
3963 arcee_model: Option<String>,
3964 output_mode: Option<String>,
3965 auth_mode: Option<String>,
3966 log_level: Option<String>,
3967 telemetry: Option<bool>,
3968 approval_policy: Option<String>,
3969 sandbox_mode: Option<String>,
3970 yolo: Option<bool>,
3971 verbosity: Option<String>,
3972 http_headers: Option<BTreeMap<String, String>>,
3973 deepseek_base_url: Option<String>,
3974 nvidia_base_url: Option<String>,
3975 openai_base_url: Option<String>,
3976 atlascloud_base_url: Option<String>,
3977 volcengine_base_url: Option<String>,
3978 wanjie_ark_base_url: Option<String>,
3979 openrouter_base_url: Option<String>,
3980 xiaomi_mimo_base_url: Option<String>,
3981 novita_base_url: Option<String>,
3982 fireworks_base_url: Option<String>,
3983 siliconflow_base_url: Option<String>,
3984 siliconflow_model: Option<String>,
3985 arcee_base_url: Option<String>,
3986 moonshot_base_url: Option<String>,
3987 sglang_base_url: Option<String>,
3988 vllm_base_url: Option<String>,
3989 ollama_base_url: Option<String>,
3990 huggingface_base_url: Option<String>,
3991 huggingface_model: Option<String>,
3992 together_base_url: Option<String>,
3993 together_model: Option<String>,
3994 openai_codex_base_url: Option<String>,
3995 openai_codex_model: Option<String>,
3996 anthropic_base_url: Option<String>,
3997 anthropic_model: Option<String>,
3998 zai_base_url: Option<String>,
3999 zai_model: Option<String>,
4000 stepfun_base_url: Option<String>,
4001 stepfun_model: Option<String>,
4002 minimax_base_url: Option<String>,
4003 minimax_model: Option<String>,
4004 deepinfra_base_url: Option<String>,
4005 deepinfra_model: Option<String>,
4006}
4007
4008impl EnvRuntimeOverrides {
4009 fn load() -> Self {
4010 let (provider, provider_source) = Self::load_provider();
4011 Self {
4012 provider,
4013 provider_source,
4014 model: std::env::var("CODEWHALE_MODEL")
4015 .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
4016 .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
4017 .ok()
4018 .filter(|v| !v.trim().is_empty()),
4019 volcengine_model: std::env::var("VOLCENGINE_MODEL")
4020 .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
4021 .ok()
4022 .filter(|v| !v.trim().is_empty()),
4023 wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
4024 .or_else(|_| std::env::var("WANJIE_MODEL"))
4025 .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
4026 .ok()
4027 .filter(|v| !v.trim().is_empty()),
4028 openrouter_model: std::env::var("OPENROUTER_MODEL")
4029 .ok()
4030 .filter(|v| !v.trim().is_empty()),
4031 moonshot_model: std::env::var("MOONSHOT_MODEL")
4032 .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
4033 .or_else(|_| std::env::var("KIMI_MODEL"))
4034 .ok()
4035 .filter(|v| !v.trim().is_empty()),
4036 xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
4037 .or_else(|_| std::env::var("MIMO_MODEL"))
4038 .ok()
4039 .filter(|v| !v.trim().is_empty()),
4040 xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
4041 .or_else(|_| std::env::var("MIMO_MODE"))
4042 .ok()
4043 .filter(|v| !v.trim().is_empty()),
4044 novita_model: std::env::var("NOVITA_MODEL")
4045 .ok()
4046 .filter(|v| !v.trim().is_empty()),
4047 fireworks_model: std::env::var("FIREWORKS_MODEL")
4048 .ok()
4049 .filter(|v| !v.trim().is_empty()),
4050 arcee_model: std::env::var("ARCEE_MODEL")
4051 .ok()
4052 .filter(|v| !v.trim().is_empty()),
4053 verbosity: std::env::var("CODEWHALE_VERBOSITY")
4054 .or_else(|_| std::env::var("DEEPSEEK_VERBOSITY"))
4055 .ok(),
4056 output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(),
4057 auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(),
4058 log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(),
4059 telemetry: std::env::var("DEEPSEEK_TELEMETRY")
4060 .ok()
4061 .and_then(|v| match parse_bool(&v) {
4062 Ok(b) => Some(b),
4063 Err(_) => {
4064 tracing::warn!("Invalid DEEPSEEK_TELEMETRY value '{v}', expected true/false");
4065 None
4066 }
4067 }),
4068 approval_policy: std::env::var("DEEPSEEK_APPROVAL_POLICY").ok(),
4069 sandbox_mode: std::env::var("DEEPSEEK_SANDBOX_MODE").ok(),
4070 yolo: std::env::var("DEEPSEEK_YOLO")
4071 .ok()
4072 .and_then(|v| match parse_bool(&v) {
4073 Ok(b) => Some(b),
4074 Err(_) => {
4075 tracing::warn!("Invalid DEEPSEEK_YOLO value '{v}', expected true/false");
4076 None
4077 }
4078 }),
4079 http_headers: std::env::var("DEEPSEEK_HTTP_HEADERS")
4080 .ok()
4081 .and_then(|value| match parse_http_headers(&value) {
4082 Ok(h) => Some(h),
4083 Err(_) => {
4084 tracing::warn!("Invalid DEEPSEEK_HTTP_HEADERS value, expected format: header1=val1,header2=val2");
4085 None
4086 }
4087 })
4088 .filter(|headers| !headers.is_empty()),
4089 deepseek_base_url: std::env::var("CODEWHALE_BASE_URL")
4090 .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
4091 .ok()
4092 .filter(|v| !v.trim().is_empty()),
4093 nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
4094 .or_else(|_| std::env::var("NIM_BASE_URL"))
4095 .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
4096 .ok()
4097 .filter(|v| !v.trim().is_empty()),
4098 openai_base_url: std::env::var("OPENAI_BASE_URL")
4099 .ok()
4100 .filter(|v| !v.trim().is_empty()),
4101 atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
4102 .ok()
4103 .filter(|v| !v.trim().is_empty()),
4104 volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
4105 .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
4106 .or_else(|_| std::env::var("ARK_BASE_URL"))
4107 .ok()
4108 .filter(|v| !v.trim().is_empty()),
4109 wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
4110 .or_else(|_| std::env::var("WANJIE_BASE_URL"))
4111 .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
4112 .ok()
4113 .filter(|v| !v.trim().is_empty()),
4114 openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
4115 .ok()
4116 .filter(|v| !v.trim().is_empty()),
4117 xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
4118 .or_else(|_| std::env::var("MIMO_BASE_URL"))
4119 .ok()
4120 .filter(|v| !v.trim().is_empty()),
4121 novita_base_url: std::env::var("NOVITA_BASE_URL")
4122 .ok()
4123 .filter(|v| !v.trim().is_empty()),
4124 fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
4125 .ok()
4126 .filter(|v| !v.trim().is_empty()),
4127 siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
4128 .ok()
4129 .filter(|v| !v.trim().is_empty()),
4130 siliconflow_model: std::env::var("SILICONFLOW_MODEL")
4131 .ok()
4132 .filter(|v| !v.trim().is_empty()),
4133 arcee_base_url: std::env::var("ARCEE_BASE_URL")
4134 .ok()
4135 .filter(|v| !v.trim().is_empty()),
4136 moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
4137 .or_else(|_| std::env::var("KIMI_BASE_URL"))
4138 .ok()
4139 .filter(|v| !v.trim().is_empty()),
4140 sglang_base_url: std::env::var("SGLANG_BASE_URL")
4141 .ok()
4142 .filter(|v| !v.trim().is_empty()),
4143 vllm_base_url: std::env::var("VLLM_BASE_URL")
4144 .ok()
4145 .filter(|v| !v.trim().is_empty()),
4146 ollama_base_url: std::env::var("OLLAMA_BASE_URL")
4147 .ok()
4148 .filter(|v| !v.trim().is_empty()),
4149 huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
4150 .or_else(|_| std::env::var("HF_BASE_URL"))
4151 .ok()
4152 .filter(|v| !v.trim().is_empty()),
4153 huggingface_model: std::env::var("HUGGINGFACE_MODEL")
4154 .or_else(|_| std::env::var("HF_MODEL"))
4155 .ok()
4156 .filter(|v| !v.trim().is_empty()),
4157 together_base_url: std::env::var("TOGETHER_BASE_URL")
4158 .ok()
4159 .filter(|v| !v.trim().is_empty()),
4160 together_model: std::env::var("TOGETHER_MODEL")
4161 .ok()
4162 .filter(|v| !v.trim().is_empty()),
4163 openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
4164 .or_else(|_| std::env::var("CODEX_BASE_URL"))
4165 .ok()
4166 .filter(|v| !v.trim().is_empty()),
4167 openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
4168 .or_else(|_| std::env::var("CODEX_MODEL"))
4169 .ok()
4170 .filter(|v| !v.trim().is_empty()),
4171 anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
4172 .ok()
4173 .filter(|v| !v.trim().is_empty()),
4174 anthropic_model: std::env::var("ANTHROPIC_MODEL")
4175 .ok()
4176 .filter(|v| !v.trim().is_empty()),
4177 zai_base_url: std::env::var("ZAI_BASE_URL")
4178 .or_else(|_| std::env::var("Z_AI_BASE_URL"))
4179 .ok()
4180 .filter(|v| !v.trim().is_empty()),
4181 zai_model: std::env::var("ZAI_MODEL")
4182 .or_else(|_| std::env::var("Z_AI_MODEL"))
4183 .ok()
4184 .filter(|v| !v.trim().is_empty()),
4185 stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
4186 .or_else(|_| std::env::var("STEP_BASE_URL"))
4187 .ok()
4188 .filter(|v| !v.trim().is_empty()),
4189 stepfun_model: std::env::var("STEPFUN_MODEL")
4190 .or_else(|_| std::env::var("STEP_MODEL"))
4191 .ok()
4192 .filter(|v| !v.trim().is_empty()),
4193 minimax_base_url: std::env::var("MINIMAX_BASE_URL")
4194 .ok()
4195 .filter(|v| !v.trim().is_empty()),
4196 minimax_model: std::env::var("MINIMAX_MODEL")
4197 .ok()
4198 .filter(|v| !v.trim().is_empty()),
4199 deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
4200 .ok()
4201 .filter(|v| !v.trim().is_empty()),
4202 deepinfra_model: std::env::var("DEEPINFRA_MODEL")
4203 .ok()
4204 .filter(|v| !v.trim().is_empty()),
4205 }
4206 }
4207
4208 fn load_provider() -> (Option<ProviderKind>, Option<&'static str>) {
4209 if let Ok(value) = std::env::var("CODEWHALE_PROVIDER") {
4210 let parsed = ProviderKind::parse(&value);
4211 return (parsed, parsed.map(|_| "CODEWHALE_PROVIDER"));
4212 }
4213
4214 if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
4215 let parsed = ProviderKind::parse(&value);
4216 return (parsed, parsed.map(|_| "DEEPSEEK_PROVIDER"));
4217 }
4218
4219 (None, None)
4220 }
4221
4222 fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
4223 match provider {
4226 ProviderKind::Deepseek => self.deepseek_base_url.clone(),
4227 ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
4228 ProviderKind::Openai => self.openai_base_url.clone(),
4229 ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
4230 ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
4231 ProviderKind::Volcengine => self.volcengine_base_url.clone(),
4232 ProviderKind::Openrouter => self.openrouter_base_url.clone(),
4233 ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
4234 ProviderKind::Novita => self.novita_base_url.clone(),
4235 ProviderKind::Fireworks => self.fireworks_base_url.clone(),
4236 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
4237 self.siliconflow_base_url.clone()
4238 }
4239 ProviderKind::Arcee => self.arcee_base_url.clone(),
4240 ProviderKind::Moonshot => self.moonshot_base_url.clone(),
4241 ProviderKind::Sglang => self.sglang_base_url.clone(),
4242 ProviderKind::Vllm => self.vllm_base_url.clone(),
4243 ProviderKind::Ollama => self.ollama_base_url.clone(),
4244 ProviderKind::Huggingface => self.huggingface_base_url.clone(),
4245 ProviderKind::Together => self.together_base_url.clone(),
4246 ProviderKind::OpenaiCodex => self.openai_codex_base_url.clone(),
4247 ProviderKind::Anthropic => self.anthropic_base_url.clone(),
4248 ProviderKind::Zai => self.zai_base_url.clone(),
4249 ProviderKind::Stepfun => self.stepfun_base_url.clone(),
4250 ProviderKind::Minimax => self.minimax_base_url.clone(),
4251 ProviderKind::Deepinfra => self.deepinfra_base_url.clone(),
4252 }
4253 }
4254
4255 fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
4256 let model = match provider {
4257 ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
4258 ProviderKind::Volcengine => self.volcengine_model.clone(),
4259 ProviderKind::Openrouter => self.openrouter_model.clone(),
4260 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
4261 self.siliconflow_model.clone()
4262 }
4263 ProviderKind::Arcee => self.arcee_model.clone(),
4264 ProviderKind::Moonshot => self.moonshot_model.clone(),
4265 ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
4266 ProviderKind::Novita => self.novita_model.clone(),
4267 ProviderKind::Fireworks => self.fireworks_model.clone(),
4268 ProviderKind::Huggingface => self.huggingface_model.clone(),
4269 ProviderKind::Together => self.together_model.clone(),
4270 ProviderKind::OpenaiCodex => self.openai_codex_model.clone(),
4271 ProviderKind::Anthropic => self.anthropic_model.clone(),
4272 ProviderKind::Zai => self.zai_model.clone(),
4273 ProviderKind::Stepfun => self.stepfun_model.clone(),
4274 ProviderKind::Minimax => self.minimax_model.clone(),
4275 ProviderKind::Deepinfra => self.deepinfra_model.clone(),
4276 _ => None,
4277 }?;
4278
4279 if provider_preserves_custom_base_url_model(provider, base_url) {
4280 Some(model.trim().to_string())
4281 } else {
4282 Some(normalize_model_for_provider(provider, &model))
4283 }
4284 }
4285}
4286
4287#[cfg(test)]
4288mod tests;