1pub mod provider;
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5use std::fs;
6use std::io::Write;
7use std::path::{Component, Path, PathBuf};
8use std::sync::OnceLock;
9
10use anyhow::{Context, Result, bail};
11pub use codewhale_execpolicy::ToolAskRule;
12use codewhale_execpolicy::{ExecPolicyEngine, Ruleset};
13use codewhale_secrets::SecretSource;
14pub use codewhale_secrets::Secrets;
15use serde::{Deserialize, Serialize};
16
17#[cfg(unix)]
18use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
19
20pub const CONFIG_FILE_NAME: &str = "config.toml";
21pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml";
22const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro";
23const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
24const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
25const DEFAULT_OPENAI_MODEL: &str = "deepseek-v4-pro";
26const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta";
27const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
28const DEFAULT_OPENAI_CODEX_MODEL: &str = "gpt-5.5";
29const DEFAULT_ANTHROPIC_MODEL: &str = "claude-sonnet-4-6";
30const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com";
31const DEFAULT_OPENAI_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api";
32const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
33const DEFAULT_ATLASCLOUD_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
34const DEFAULT_ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1";
35const DEFAULT_WANJIE_ARK_MODEL: &str = "deepseek-reasoner";
36const DEFAULT_WANJIE_ARK_BASE_URL: &str = "https://maas-openapi.wanjiedata.com/api/v1";
37const DEFAULT_VOLCENGINE_MODEL: &str = "DeepSeek-V4-Pro";
38const DEFAULT_VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/coding/v3";
39const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro";
40const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
41const OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL: &str = "arcee-ai/trinity-large-thinking";
42const OPENROUTER_GEMMA_4_31B_MODEL: &str = "google/gemma-4-31b-it";
43const OPENROUTER_GEMMA_4_26B_A4B_MODEL: &str = "google/gemma-4-26b-a4b-it";
44const OPENROUTER_GLM_5_1_MODEL: &str = "z-ai/glm-5.1";
45const OPENROUTER_GLM_5_2_MODEL: &str = "z-ai/glm-5.2";
46const OPENROUTER_KIMI_K2_7_CODE_MODEL: &str = "moonshotai/kimi-k2.7-code";
47const OPENROUTER_KIMI_K2_6_MODEL: &str = "moonshotai/kimi-k2.6";
48const OPENROUTER_MINIMAX_M3_MODEL: &str = "minimax/minimax-m3";
49const OPENROUTER_MINIMAX_2_7_MODEL: &str = "minimax/minimax-2.7";
50const OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL: &str =
51 "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free";
52const OPENROUTER_QWEN_3_6_FLASH_MODEL: &str = "qwen/qwen3.6-flash";
53const OPENROUTER_QWEN_3_6_35B_A3B_MODEL: &str = "qwen/qwen3.6-35b-a3b";
54const OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL: &str = "qwen/qwen3.6-max-preview";
55const OPENROUTER_QWEN_3_6_27B_MODEL: &str = "qwen/qwen3.6-27b";
56const OPENROUTER_QWEN_3_6_PLUS_MODEL: &str = "qwen/qwen3.6-plus";
57const OPENROUTER_QWEN_3_7_MAX_MODEL: &str = "qwen/qwen3.7-max";
58const OPENROUTER_TENCENT_HY3_PREVIEW_MODEL: &str = "tencent/hy3-preview";
59const OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL: &str = "xiaomi/mimo-v2.5-pro";
60const OPENROUTER_XIAOMI_MIMO_V2_5_MODEL: &str = "xiaomi/mimo-v2.5";
61const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro";
62const XIAOMI_MIMO_V2_5_OMNI_MODEL: &str = "mimo-v2.5";
63const XIAOMI_MIMO_ASR_MODEL: &str = "mimo-v2.5-asr";
64const XIAOMI_MIMO_TTS_MODEL: &str = "mimo-v2.5-tts";
65const XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL: &str = "mimo-v2.5-tts-voicedesign";
66const XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL: &str = "mimo-v2.5-tts-voiceclone";
67const XIAOMI_MIMO_V2_TTS_MODEL: &str = "mimo-v2-tts";
68const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro";
69const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
70const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro";
71const DEFAULT_SILICONFLOW_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
72const DEFAULT_SILICONFLOW_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
73const DEFAULT_ARCEE_MODEL: &str = "trinity-large-thinking";
74const ARCEE_TRINITY_LARGE_PREVIEW_MODEL: &str = "trinity-large-preview";
75const ARCEE_TRINITY_MINI_MODEL: &str = "trinity-mini";
76const DEFAULT_MOONSHOT_MODEL: &str = "kimi-k2.7-code";
77const MOONSHOT_KIMI_K2_6_MODEL: &str = "kimi-k2.6";
78const DEFAULT_MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1";
79const DEFAULT_KIMI_CODE_MODEL: &str = "kimi-for-coding";
80const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding/v1";
81const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
82const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
83const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
84const XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL: &str = "https://api.xiaomimimo.com/v1";
85const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://token-plan-sgp.xiaomimimo.com/v1";
86const XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL: &str = "https://token-plan-cn.xiaomimimo.com/v1";
87const XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL: &str = DEFAULT_XIAOMI_MIMO_BASE_URL;
88const XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL: &str = "https://token-plan-ams.xiaomimimo.com/v1";
89const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/openai/v1";
90const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
91const DEFAULT_SILICONFLOW_BASE_URL: &str = "https://api.siliconflow.com/v1";
92const DEFAULT_SILICONFLOW_CN_BASE_URL: &str = "https://api.siliconflow.cn/v1";
93const DEFAULT_ARCEE_BASE_URL: &str = "https://api.arcee.ai/api/v1";
94const DEFAULT_HUGGINGFACE_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
95const DEFAULT_HUGGINGFACE_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
96const DEFAULT_HUGGINGFACE_BASE_URL: &str = "https://router.huggingface.co/v1";
97const DEFAULT_TOGETHER_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
98const DEFAULT_TOGETHER_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
99const DEFAULT_TOGETHER_BASE_URL: &str = "https://api.together.xyz/v1";
100const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1";
101const DEFAULT_VLLM_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
102const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
103const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1";
104const DEFAULT_OLLAMA_MODEL: &str = "deepseek-coder:1.3b";
105const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
106
107const DEFAULT_ZAI_MODEL: &str = "GLM-5.2";
109const ZAI_GLM_5_1_MODEL: &str = "GLM-5.1";
110#[allow(dead_code)]
114const ZAI_GLM_5_2_MODEL: &str = "GLM-5.2";
115const ZAI_GLM_5_TURBO_MODEL: &str = "GLM-5-Turbo";
116const DEFAULT_ZAI_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
117const DEFAULT_STEPFUN_MODEL: &str = "step-3.7-flash";
119const DEFAULT_STEPFUN_BASE_URL: &str = "https://api.stepfun.ai/v1";
120const DEFAULT_MINIMAX_MODEL: &str = "MiniMax-M3";
122const MINIMAX_M2_7_MODEL: &str = "MiniMax-M2.7";
123const MINIMAX_M2_7_HIGHSPEED_MODEL: &str = "MiniMax-M2.7-highspeed";
124const MINIMAX_M2_5_MODEL: &str = "MiniMax-M2.5";
125const MINIMAX_M2_5_HIGHSPEED_MODEL: &str = "MiniMax-M2.5-highspeed";
126const MINIMAX_M2_1_MODEL: &str = "MiniMax-M2.1";
127const MINIMAX_M2_1_HIGHSPEED_MODEL: &str = "MiniMax-M2.1-highspeed";
128const MINIMAX_M2_MODEL: &str = "MiniMax-M2";
129const DEFAULT_MINIMAX_BASE_URL: &str = "https://api.minimax.io/v1";
130const DEFAULT_DEEPINFRA_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
131const DEFAULT_DEEPINFRA_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
132const DEFAULT_DEEPINFRA_BASE_URL: &str = "https://api.deepinfra.com/v1/openai";
133
134#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
135#[serde(rename_all = "kebab-case")]
136pub enum ProviderKind {
137 #[default]
138 #[serde(
139 alias = "deepseek-cn",
140 alias = "deepseek_china",
141 alias = "deepseekcn",
142 alias = "deepseek-china"
143 )]
144 Deepseek,
145 NvidiaNim,
146 #[serde(alias = "open-ai")]
147 Openai,
148 Atlascloud,
149 #[serde(
150 alias = "wanjie",
151 alias = "wanjie_ark",
152 alias = "ark-wanjie",
153 alias = "ark_wanjie",
154 alias = "wanjie-maas",
155 alias = "wanjie_maas"
156 )]
157 WanjieArk,
158 #[serde(alias = "volcengine-ark", alias = "volcengine_ark", alias = "ark")]
159 Volcengine,
160 Openrouter,
161 #[serde(alias = "mimo", alias = "xiaomi", alias = "xiaomi_mimo")]
162 XiaomiMimo,
163 Novita,
164 Fireworks,
165 #[serde(alias = "silicon-flow", alias = "silicon_flow")]
166 Siliconflow,
167 #[serde(alias = "arcee-ai", alias = "arcee_ai")]
168 Arcee,
169 #[serde(alias = "siliconflow-cn", alias = "siliconflow-CN")]
170 SiliconflowCN,
171 Moonshot,
172 Sglang,
173 Vllm,
174 Ollama,
175 #[serde(alias = "hugging-face", alias = "hugging_face", alias = "hf")]
176 Huggingface,
177 #[serde(alias = "together-ai", alias = "together_ai")]
178 Together,
179 #[serde(
180 alias = "openai-codex",
181 alias = "openai_codex",
182 alias = "codex",
183 alias = "chatgpt",
184 alias = "chatgpt-codex",
185 alias = "chatgpt_codex"
186 )]
187 OpenaiCodex,
188 #[serde(alias = "claude")]
189 Anthropic,
190 #[serde(alias = "z-ai", alias = "z_ai", alias = "z.ai")]
191 Zai,
192 #[serde(
193 alias = "step-fun",
194 alias = "step_fun",
195 alias = "stepfun",
196 alias = "stepflash",
197 alias = "step-flash",
198 alias = "step_flash"
199 )]
200 Stepfun,
201 #[serde(alias = "mini-max", alias = "mini_max", alias = "minimax")]
202 Minimax,
203 #[serde(alias = "deep-infra", alias = "deep_infra")]
204 Deepinfra,
205}
206
207impl ProviderKind {
208 pub const ALL: [Self; 25] = [
209 Self::Deepseek,
210 Self::NvidiaNim,
211 Self::Openai,
212 Self::Atlascloud,
213 Self::WanjieArk,
214 Self::Volcengine,
215 Self::Openrouter,
216 Self::XiaomiMimo,
217 Self::Novita,
218 Self::Fireworks,
219 Self::Siliconflow,
220 Self::Arcee,
221 Self::SiliconflowCN,
222 Self::Moonshot,
223 Self::Sglang,
224 Self::Vllm,
225 Self::Ollama,
226 Self::Huggingface,
227 Self::Together,
228 Self::OpenaiCodex,
229 Self::Anthropic,
230 Self::Zai,
231 Self::Stepfun,
232 Self::Minimax,
233 Self::Deepinfra,
234 ];
235
236 #[must_use]
237 pub fn all() -> &'static [Self] {
238 &Self::ALL
239 }
240
241 #[must_use]
242 pub fn names_hint() -> String {
243 Self::all()
244 .iter()
245 .map(|provider| provider.as_str())
246 .collect::<Vec<_>>()
247 .join(", ")
248 }
249
250 #[must_use]
251 pub fn as_str(self) -> &'static str {
252 self.provider().id()
253 }
254
255 #[must_use]
256 pub fn parse(value: &str) -> Option<Self> {
257 let trimmed = value.trim();
258 provider::all_providers()
259 .iter()
260 .find(|p| {
261 trimmed.eq_ignore_ascii_case(p.id())
262 || p.aliases().iter().any(|a| trimmed.eq_ignore_ascii_case(a))
263 })
264 .map(|p| p.kind())
265 }
266
267 #[must_use]
268 pub fn is_siliconflow(self) -> bool {
269 matches!(self, Self::Siliconflow | Self::SiliconflowCN)
270 }
271
272 #[must_use]
277 pub fn provider(self) -> &'static dyn provider::Provider {
278 provider::provider_for_kind(self)
279 }
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, Default)]
283pub struct ProviderConfigToml {
284 pub api_key: Option<String>,
285 pub base_url: Option<String>,
286 pub model: Option<String>,
287 pub mode: Option<String>,
288 pub auth_mode: Option<String>,
289 pub insecure_skip_tls_verify: Option<bool>,
290 #[serde(default)]
291 pub http_headers: BTreeMap<String, String>,
292 pub path_suffix: Option<String>,
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize, Default)]
296pub struct ProvidersToml {
297 #[serde(default)]
298 pub deepseek: ProviderConfigToml,
299 #[serde(default)]
300 pub nvidia_nim: ProviderConfigToml,
301 #[serde(default)]
302 pub openai: ProviderConfigToml,
303 #[serde(default)]
304 pub atlascloud: ProviderConfigToml,
305 #[serde(default)]
306 pub wanjie_ark: ProviderConfigToml,
307 #[serde(default)]
308 pub volcengine: ProviderConfigToml,
309 #[serde(default)]
310 pub openrouter: ProviderConfigToml,
311 #[serde(default, alias = "xiaomi", alias = "mimo", alias = "xiaomimimo")]
312 pub xiaomi_mimo: ProviderConfigToml,
313 #[serde(default)]
314 pub novita: ProviderConfigToml,
315 #[serde(default)]
316 pub fireworks: ProviderConfigToml,
317 #[serde(default)]
318 pub siliconflow: ProviderConfigToml,
319 #[serde(default, alias = "siliconflow-CN", alias = "siliconflow-cn")]
320 pub siliconflow_cn: ProviderConfigToml,
321 #[serde(default)]
322 pub arcee: ProviderConfigToml,
323 #[serde(default)]
324 pub moonshot: ProviderConfigToml,
325 #[serde(default)]
326 pub sglang: ProviderConfigToml,
327 #[serde(default)]
328 pub vllm: ProviderConfigToml,
329 #[serde(default)]
330 pub ollama: ProviderConfigToml,
331 #[serde(default)]
332 pub huggingface: ProviderConfigToml,
333 #[serde(default)]
334 pub together: ProviderConfigToml,
335 #[serde(
336 default,
337 alias = "openai-codex",
338 alias = "openai_codex",
339 alias = "codex",
340 alias = "chatgpt",
341 alias = "chatgpt-codex"
342 )]
343 pub openai_codex: ProviderConfigToml,
344 #[serde(default)]
345 pub anthropic: ProviderConfigToml,
346 #[serde(default, alias = "z-ai", alias = "z_ai", alias = "z.ai")]
347 pub zai: ProviderConfigToml,
348 #[serde(
349 default,
350 alias = "step-fun",
351 alias = "step_fun",
352 alias = "stepfun",
353 alias = "stepflash",
354 alias = "step-flash",
355 alias = "step_flash"
356 )]
357 pub stepfun: ProviderConfigToml,
358 #[serde(default, alias = "mini-max", alias = "mini_max", alias = "minimax")]
359 pub minimax: ProviderConfigToml,
360 #[serde(default, alias = "deep-infra", alias = "deep_infra")]
361 pub deepinfra: ProviderConfigToml,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
370#[serde(deny_unknown_fields)]
371pub struct PermissionsToml {
372 #[serde(default, skip_serializing_if = "Vec::is_empty")]
373 pub rules: Vec<ToolAskRule>,
374}
375
376impl PermissionsToml {
377 #[must_use]
378 pub fn is_empty(&self) -> bool {
379 self.rules.is_empty()
380 }
381
382 #[must_use]
383 pub fn ruleset(&self) -> Ruleset {
384 Ruleset::user(Vec::new(), Vec::new()).with_ask_rules(self.rules.clone())
385 }
386}
387
388impl ProvidersToml {
389 #[must_use]
390 pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
391 match provider {
392 ProviderKind::Deepseek => &self.deepseek,
393 ProviderKind::NvidiaNim => &self.nvidia_nim,
394 ProviderKind::Openai => &self.openai,
395 ProviderKind::Atlascloud => &self.atlascloud,
396 ProviderKind::WanjieArk => &self.wanjie_ark,
397 ProviderKind::Volcengine => &self.volcengine,
398 ProviderKind::Openrouter => &self.openrouter,
399 ProviderKind::XiaomiMimo => &self.xiaomi_mimo,
400 ProviderKind::Novita => &self.novita,
401 ProviderKind::Fireworks => &self.fireworks,
402 ProviderKind::Siliconflow => &self.siliconflow,
403 ProviderKind::SiliconflowCN => &self.siliconflow_cn,
404 ProviderKind::Arcee => &self.arcee,
405 ProviderKind::Moonshot => &self.moonshot,
406 ProviderKind::Sglang => &self.sglang,
407 ProviderKind::Vllm => &self.vllm,
408 ProviderKind::Ollama => &self.ollama,
409 ProviderKind::Huggingface => &self.huggingface,
410 ProviderKind::Together => &self.together,
411 ProviderKind::OpenaiCodex => &self.openai_codex,
412 ProviderKind::Anthropic => &self.anthropic,
413 ProviderKind::Zai => &self.zai,
414 ProviderKind::Stepfun => &self.stepfun,
415 ProviderKind::Minimax => &self.minimax,
416 ProviderKind::Deepinfra => &self.deepinfra,
417 }
418 }
419
420 pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
421 match provider {
422 ProviderKind::Deepseek => &mut self.deepseek,
423 ProviderKind::NvidiaNim => &mut self.nvidia_nim,
424 ProviderKind::Openai => &mut self.openai,
425 ProviderKind::Atlascloud => &mut self.atlascloud,
426 ProviderKind::WanjieArk => &mut self.wanjie_ark,
427 ProviderKind::Volcengine => &mut self.volcengine,
428 ProviderKind::Openrouter => &mut self.openrouter,
429 ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo,
430 ProviderKind::Novita => &mut self.novita,
431 ProviderKind::Fireworks => &mut self.fireworks,
432 ProviderKind::Siliconflow => &mut self.siliconflow,
433 ProviderKind::SiliconflowCN => &mut self.siliconflow_cn,
434 ProviderKind::Arcee => &mut self.arcee,
435 ProviderKind::Moonshot => &mut self.moonshot,
436 ProviderKind::Sglang => &mut self.sglang,
437 ProviderKind::Vllm => &mut self.vllm,
438 ProviderKind::Ollama => &mut self.ollama,
439 ProviderKind::Huggingface => &mut self.huggingface,
440 ProviderKind::Together => &mut self.together,
441 ProviderKind::OpenaiCodex => &mut self.openai_codex,
442 ProviderKind::Anthropic => &mut self.anthropic,
443 ProviderKind::Zai => &mut self.zai,
444 ProviderKind::Stepfun => &mut self.stepfun,
445 ProviderKind::Minimax => &mut self.minimax,
446 ProviderKind::Deepinfra => &mut self.deepinfra,
447 }
448 }
449}
450
451#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
459#[serde(rename_all = "kebab-case")]
460pub enum HarnessPostureKind {
461 #[default]
464 Standard,
465 CacheHeavy,
467 Lean,
470 Custom,
472}
473
474#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
476#[serde(rename_all = "kebab-case")]
477pub enum HarnessCompactionStrategy {
478 #[default]
479 Default,
480 PrefixCache,
481 Aggressive,
482}
483
484#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
486#[serde(rename_all = "kebab-case")]
487pub enum HarnessToolSurface {
488 #[default]
489 Full,
490 ReadOnly,
491 Auto,
492}
493
494#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
496#[serde(rename_all = "kebab-case")]
497pub enum HarnessSafetyPosture {
498 #[default]
499 Standard,
500 Strict,
501 Permissive,
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
506#[serde(deny_unknown_fields)]
507pub struct HarnessPosture {
508 #[serde(default)]
510 pub kind: HarnessPostureKind,
511 #[serde(default)]
513 pub max_subagents: usize,
514 #[serde(default)]
516 pub prefer_codebase_search: bool,
517 #[serde(default)]
519 pub compaction_strategy: HarnessCompactionStrategy,
520 #[serde(default)]
522 pub tool_surface: HarnessToolSurface,
523 #[serde(default)]
525 pub safety_posture: HarnessSafetyPosture,
526}
527
528impl Default for HarnessPosture {
529 fn default() -> Self {
530 Self {
531 kind: HarnessPostureKind::Standard,
532 max_subagents: 0,
533 prefer_codebase_search: false,
534 compaction_strategy: HarnessCompactionStrategy::default(),
535 tool_surface: HarnessToolSurface::default(),
536 safety_posture: HarnessSafetyPosture::default(),
537 }
538 }
539}
540
541impl HarnessPosture {
542 #[must_use]
544 pub fn cache_heavy() -> Self {
545 Self {
546 kind: HarnessPostureKind::CacheHeavy,
547 max_subagents: 10,
548 prefer_codebase_search: false,
549 compaction_strategy: HarnessCompactionStrategy::PrefixCache,
550 tool_surface: HarnessToolSurface::Full,
551 safety_posture: HarnessSafetyPosture::Standard,
552 }
553 }
554
555 #[must_use]
557 pub fn lean() -> Self {
558 Self {
559 kind: HarnessPostureKind::Lean,
560 max_subagents: 20,
561 prefer_codebase_search: true,
562 compaction_strategy: HarnessCompactionStrategy::Aggressive,
563 tool_surface: HarnessToolSurface::Full,
564 safety_posture: HarnessSafetyPosture::Standard,
565 }
566 }
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
571#[serde(deny_unknown_fields)]
572pub struct HarnessProfile {
573 pub provider_route: String,
576 pub model_pattern: String,
578 #[serde(default)]
580 pub posture: HarnessPosture,
581}
582
583impl HarnessProfile {
584 #[must_use]
589 pub fn matches_route(&self, provider_route: &str, model: &str) -> bool {
590 provider_routes_equal(&self.provider_route, provider_route)
591 && wildcard_pattern_matches(&self.model_pattern, model)
592 }
593}
594
595#[derive(Debug, Clone, Serialize, Deserialize, Default)]
596pub struct ConfigToml {
597 pub api_key: Option<String>,
600 pub base_url: Option<String>,
602 #[serde(default)]
604 pub http_headers: BTreeMap<String, String>,
605 pub default_text_model: Option<String>,
607 #[serde(default)]
608 pub provider: ProviderKind,
609 pub model: Option<String>,
610 pub auth_mode: Option<String>,
611 pub output_mode: Option<String>,
612 pub verbosity: Option<String>,
613 pub log_level: Option<String>,
614 pub telemetry: Option<bool>,
615 pub approval_policy: Option<String>,
616 pub sandbox_mode: Option<String>,
617 #[serde(default)]
619 pub tools: Option<ToolsToml>,
620 #[serde(default)]
621 pub providers: ProvidersToml,
622 #[serde(default, skip_serializing_if = "Vec::is_empty")]
626 pub fallback_providers: Vec<ProviderKind>,
627 #[serde(default)]
630 pub network: Option<NetworkPolicyToml>,
631 #[serde(default)]
635 pub skills: Option<SkillsToml>,
636 #[serde(default)]
639 pub snapshots: Option<SnapshotsToml>,
640 #[serde(default)]
643 pub lsp: Option<LspConfigToml>,
644 #[serde(default)]
647 pub harness_profiles: Vec<HarnessProfile>,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
651 pub hotbar: Option<Vec<HotbarBindingToml>>,
652 #[serde(default)]
655 pub hook_sinks: Option<HookSinksToml>,
656 #[serde(default)]
659 pub fleet: Option<FleetConfigToml>,
660 #[serde(flatten)]
661 pub extras: BTreeMap<String, toml::Value>,
662}
663
664#[derive(Debug, Clone, Copy, PartialEq, Eq)]
665enum ProviderConfigField {
666 ApiKey,
667 BaseUrl,
668 Model,
669 Mode,
670 AuthMode,
671 InsecureSkipTlsVerify,
672 HttpHeaders,
673 PathSuffix,
674}
675
676impl ProviderConfigField {
677 fn parse(key: &str) -> Option<Self> {
678 Some(match key {
679 "api_key" => Self::ApiKey,
680 "base_url" => Self::BaseUrl,
681 "model" => Self::Model,
682 "mode" => Self::Mode,
683 "auth_mode" => Self::AuthMode,
684 "insecure_skip_tls_verify" => Self::InsecureSkipTlsVerify,
685 "http_headers" => Self::HttpHeaders,
686 "path_suffix" => Self::PathSuffix,
687 _ => return None,
688 })
689 }
690
691 fn key(self) -> &'static str {
692 match self {
693 Self::ApiKey => "api_key",
694 Self::BaseUrl => "base_url",
695 Self::Model => "model",
696 Self::Mode => "mode",
697 Self::AuthMode => "auth_mode",
698 Self::InsecureSkipTlsVerify => "insecure_skip_tls_verify",
699 Self::HttpHeaders => "http_headers",
700 Self::PathSuffix => "path_suffix",
701 }
702 }
703}
704
705fn parse_provider_config_key(key: &str) -> Option<(ProviderKind, ProviderConfigField)> {
706 let suffix = key.strip_prefix("providers.")?;
707 let (provider_key, field_key) = suffix.split_once('.')?;
708 let field = ProviderConfigField::parse(field_key)?;
709 let provider = ProviderKind::ALL
710 .iter()
711 .copied()
712 .find(|kind| kind.provider().provider_config_key() == provider_key)?;
713 Some((provider, field))
714}
715
716fn provider_config_key(provider: ProviderKind, field: ProviderConfigField) -> String {
717 format!(
718 "providers.{}.{}",
719 provider.provider().provider_config_key(),
720 field.key()
721 )
722}
723
724fn get_provider_config_value(
725 config: &ProviderConfigToml,
726 field: ProviderConfigField,
727) -> Option<String> {
728 match field {
729 ProviderConfigField::ApiKey => config.api_key.clone(),
730 ProviderConfigField::BaseUrl => config.base_url.clone(),
731 ProviderConfigField::Model => config.model.clone(),
732 ProviderConfigField::Mode => config.mode.clone(),
733 ProviderConfigField::AuthMode => config.auth_mode.clone(),
734 ProviderConfigField::InsecureSkipTlsVerify => config
735 .insecure_skip_tls_verify
736 .map(|value| value.to_string()),
737 ProviderConfigField::HttpHeaders => serialize_http_headers(&config.http_headers),
738 ProviderConfigField::PathSuffix => config.path_suffix.clone(),
739 }
740}
741
742fn get_provider_config_display_value(
743 config: &ProviderConfigToml,
744 field: ProviderConfigField,
745) -> Option<String> {
746 match field {
747 ProviderConfigField::ApiKey => config.api_key.as_deref().map(redact_secret),
748 ProviderConfigField::HttpHeaders => {
749 serialize_http_headers_for_display(&config.http_headers)
750 }
751 _ => get_provider_config_value(config, field),
752 }
753}
754
755fn set_provider_config_value(
756 config: &mut ConfigToml,
757 provider: ProviderKind,
758 field: ProviderConfigField,
759 value: &str,
760) -> Result<()> {
761 match field {
762 ProviderConfigField::ApiKey => {
763 let value = value.to_string();
764 config.providers.for_provider_mut(provider).api_key = Some(value.clone());
765 if provider == ProviderKind::Deepseek {
766 config.api_key = Some(value);
767 }
768 }
769 ProviderConfigField::BaseUrl => {
770 let value = value.to_string();
771 config.providers.for_provider_mut(provider).base_url = Some(value.clone());
772 if provider == ProviderKind::Deepseek {
773 config.base_url = Some(value);
774 }
775 }
776 ProviderConfigField::Model => {
777 let value = value.to_string();
778 config.providers.for_provider_mut(provider).model = Some(value.clone());
779 if provider == ProviderKind::Deepseek {
780 config.default_text_model = Some(value);
781 }
782 }
783 ProviderConfigField::Mode => {
784 config.providers.for_provider_mut(provider).mode = Some(value.to_string());
785 }
786 ProviderConfigField::AuthMode => {
787 config.providers.for_provider_mut(provider).auth_mode = Some(value.to_string());
788 }
789 ProviderConfigField::InsecureSkipTlsVerify => {
790 config
791 .providers
792 .for_provider_mut(provider)
793 .insecure_skip_tls_verify = Some(parse_bool(value)?);
794 }
795 ProviderConfigField::HttpHeaders => {
796 let headers = parse_http_headers(value)?;
797 config.providers.for_provider_mut(provider).http_headers = headers.clone();
798 if provider == ProviderKind::Deepseek {
799 config.http_headers = headers;
800 }
801 }
802 ProviderConfigField::PathSuffix => {
803 config.providers.for_provider_mut(provider).path_suffix = Some(value.to_string());
804 }
805 }
806 Ok(())
807}
808
809fn unset_provider_config_value(
810 config: &mut ConfigToml,
811 provider: ProviderKind,
812 field: ProviderConfigField,
813) {
814 match field {
815 ProviderConfigField::ApiKey => {
816 config.providers.for_provider_mut(provider).api_key = None;
817 if provider == ProviderKind::Deepseek {
818 config.api_key = None;
819 }
820 }
821 ProviderConfigField::BaseUrl => {
822 config.providers.for_provider_mut(provider).base_url = None;
823 if provider == ProviderKind::Deepseek {
824 config.base_url = None;
825 }
826 }
827 ProviderConfigField::Model => {
828 config.providers.for_provider_mut(provider).model = None;
829 if provider == ProviderKind::Deepseek {
830 config.default_text_model = None;
831 }
832 }
833 ProviderConfigField::Mode => {
834 config.providers.for_provider_mut(provider).mode = None;
835 }
836 ProviderConfigField::AuthMode => {
837 config.providers.for_provider_mut(provider).auth_mode = None;
838 }
839 ProviderConfigField::InsecureSkipTlsVerify => {
840 config
841 .providers
842 .for_provider_mut(provider)
843 .insecure_skip_tls_verify = None;
844 }
845 ProviderConfigField::HttpHeaders => {
846 config
847 .providers
848 .for_provider_mut(provider)
849 .http_headers
850 .clear();
851 if provider == ProviderKind::Deepseek {
852 config.http_headers.clear();
853 }
854 }
855 ProviderConfigField::PathSuffix => {
856 config.providers.for_provider_mut(provider).path_suffix = None;
857 }
858 }
859}
860
861fn insert_provider_config_values(
862 out: &mut BTreeMap<String, String>,
863 provider: ProviderKind,
864 config: &ProviderConfigToml,
865) {
866 if let Some(v) = config.api_key.as_ref() {
867 out.insert(
868 provider_config_key(provider, ProviderConfigField::ApiKey),
869 redact_secret(v),
870 );
871 }
872 if let Some(v) = config.base_url.as_ref() {
873 out.insert(
874 provider_config_key(provider, ProviderConfigField::BaseUrl),
875 v.clone(),
876 );
877 }
878 if let Some(v) = config.model.as_ref() {
879 out.insert(
880 provider_config_key(provider, ProviderConfigField::Model),
881 v.clone(),
882 );
883 }
884 if let Some(v) = config.mode.as_ref() {
885 out.insert(
886 provider_config_key(provider, ProviderConfigField::Mode),
887 v.clone(),
888 );
889 }
890 if let Some(v) = config.auth_mode.as_ref() {
891 out.insert(
892 provider_config_key(provider, ProviderConfigField::AuthMode),
893 v.clone(),
894 );
895 }
896 if let Some(v) = config.insecure_skip_tls_verify {
897 out.insert(
898 provider_config_key(provider, ProviderConfigField::InsecureSkipTlsVerify),
899 v.to_string(),
900 );
901 }
902 if let Some(v) = serialize_http_headers_for_display(&config.http_headers) {
903 out.insert(
904 provider_config_key(provider, ProviderConfigField::HttpHeaders),
905 v,
906 );
907 }
908 if let Some(v) = config.path_suffix.as_ref() {
909 out.insert(
910 provider_config_key(provider, ProviderConfigField::PathSuffix),
911 v.clone(),
912 );
913 }
914}
915
916impl ConfigToml {
917 #[must_use]
923 pub fn resolve_harness_profile(
924 &self,
925 provider_route: &str,
926 model: &str,
927 ) -> Option<&HarnessProfile> {
928 self.harness_profiles
929 .iter()
930 .chain(built_in_harness_profiles().iter())
931 .find(|profile| profile.matches_route(provider_route, model))
932 }
933
934 #[must_use]
940 pub fn resolve_hotbar_bindings(&self, known_action_ids: &[&str]) -> HotbarConfigResolution {
941 resolve_hotbar_bindings(self.hotbar.as_deref(), known_action_ids)
942 }
943}
944
945#[must_use]
950pub fn built_in_harness_profiles() -> &'static [HarnessProfile] {
951 static PROFILES: OnceLock<Vec<HarnessProfile>> = OnceLock::new();
952 PROFILES.get_or_init(|| {
953 vec![
954 HarnessProfile {
955 provider_route: "deepseek".to_string(),
956 model_pattern: "deepseek-v4*".to_string(),
957 posture: HarnessPosture::cache_heavy(),
958 },
959 HarnessProfile {
960 provider_route: "xiaomi-mimo".to_string(),
961 model_pattern: "mimo-v2.5*".to_string(),
962 posture: HarnessPosture::cache_heavy(),
963 },
964 HarnessProfile {
965 provider_route: "arcee".to_string(),
966 model_pattern: "trinity-large-thinking".to_string(),
967 posture: HarnessPosture::cache_heavy(),
968 },
969 HarnessProfile {
970 provider_route: "huggingface".to_string(),
971 model_pattern: "*".to_string(),
972 posture: HarnessPosture::lean(),
973 },
974 HarnessProfile {
975 provider_route: "sglang".to_string(),
976 model_pattern: "*".to_string(),
977 posture: HarnessPosture::lean(),
978 },
979 HarnessProfile {
980 provider_route: "vllm".to_string(),
981 model_pattern: "*".to_string(),
982 posture: HarnessPosture::lean(),
983 },
984 HarnessProfile {
985 provider_route: "ollama".to_string(),
986 model_pattern: "*".to_string(),
987 posture: HarnessPosture::lean(),
988 },
989 ]
990 })
991}
992
993fn provider_routes_equal(expected: &str, actual: &str) -> bool {
994 match (ProviderKind::parse(expected), ProviderKind::parse(actual)) {
995 (Some(expected), Some(actual)) => expected == actual,
996 _ => expected.trim().eq_ignore_ascii_case(actual.trim()),
997 }
998}
999
1000fn wildcard_pattern_matches(pattern: &str, value: &str) -> bool {
1001 wildcard_chars_match(
1002 &pattern.chars().collect::<Vec<_>>(),
1003 &value.chars().collect::<Vec<_>>(),
1004 )
1005}
1006
1007fn wildcard_chars_match(pattern: &[char], value: &[char]) -> bool {
1008 let (mut pattern_idx, mut value_idx) = (0, 0);
1009 let mut star_idx: Option<usize> = None;
1010 let mut star_value_idx = 0;
1011
1012 while value_idx < value.len() {
1013 if pattern_idx < pattern.len()
1014 && (pattern[pattern_idx] == '?' || pattern[pattern_idx] == value[value_idx])
1015 {
1016 pattern_idx += 1;
1017 value_idx += 1;
1018 } else if pattern_idx < pattern.len() && pattern[pattern_idx] == '*' {
1019 star_idx = Some(pattern_idx);
1020 pattern_idx += 1;
1021 star_value_idx = value_idx;
1022 } else if let Some(star) = star_idx {
1023 pattern_idx = star + 1;
1024 star_value_idx += 1;
1025 value_idx = star_value_idx;
1026 } else {
1027 return false;
1028 }
1029 }
1030
1031 pattern[pattern_idx..].iter().all(|ch| *ch == '*')
1032}
1033
1034#[derive(Debug, Clone, PartialEq, Eq)]
1039pub struct ProviderChain {
1040 providers: Vec<ProviderKind>,
1041 position: usize,
1042}
1043
1044pub const HOTBAR_SLOT_COUNT: u8 = 8;
1045
1046pub const DEFAULT_HOTBAR_ACTIONS: [&str; HOTBAR_SLOT_COUNT as usize] = [
1047 "voice.toggle",
1048 "session.compact",
1049 "mode.plan",
1050 "mode.agent",
1051 "mode.yolo",
1052 "palette.open",
1053 "sidebar.toggle",
1054 "trust.toggle",
1055];
1056
1057#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1059#[serde(deny_unknown_fields)]
1060pub struct HotbarBindingToml {
1061 pub slot: u8,
1062 pub action: String,
1063 #[serde(default)]
1064 pub label: Option<String>,
1065}
1066
1067#[derive(Debug, Clone, PartialEq, Eq)]
1069pub struct HotbarBinding {
1070 pub slot: u8,
1071 pub action: String,
1072 pub label: Option<String>,
1073}
1074
1075#[derive(Debug, Clone, PartialEq, Eq)]
1078pub enum HotbarConfigWarning {
1079 SlotOutOfRange {
1080 slot: u8,
1081 action: String,
1082 },
1083 DuplicateSlot {
1084 slot: u8,
1085 previous_action: String,
1086 replacement_action: String,
1087 },
1088 UnknownAction {
1089 slot: u8,
1090 action: String,
1091 },
1092}
1093
1094impl fmt::Display for HotbarConfigWarning {
1095 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1096 match self {
1097 Self::SlotOutOfRange { slot, action } => write!(
1098 f,
1099 "hotbar slot {slot} for action '{action}' is outside 1-{HOTBAR_SLOT_COUNT}; skipped"
1100 ),
1101 Self::DuplicateSlot {
1102 slot,
1103 previous_action,
1104 replacement_action,
1105 } => write!(
1106 f,
1107 "hotbar slot {slot} was bound to '{previous_action}' more than once; using '{replacement_action}'"
1108 ),
1109 Self::UnknownAction { slot, action } => write!(
1110 f,
1111 "hotbar slot {slot} references unknown action '{action}'; keeping binding"
1112 ),
1113 }
1114 }
1115}
1116
1117#[derive(Debug, Clone, PartialEq, Eq)]
1118pub struct HotbarConfigResolution {
1119 pub bindings: Vec<HotbarBinding>,
1120 pub warnings: Vec<HotbarConfigWarning>,
1121}
1122
1123#[must_use]
1124pub fn default_hotbar_bindings() -> Vec<HotbarBinding> {
1125 DEFAULT_HOTBAR_ACTIONS
1126 .iter()
1127 .enumerate()
1128 .map(|(idx, action)| HotbarBinding {
1129 slot: u8::try_from(idx + 1).expect("default hotbar slot fits in u8"),
1130 action: (*action).to_string(),
1131 label: None,
1132 })
1133 .collect()
1134}
1135
1136#[must_use]
1137pub fn resolve_hotbar_bindings(
1138 configured: Option<&[HotbarBindingToml]>,
1139 known_action_ids: &[&str],
1140) -> HotbarConfigResolution {
1141 let known = known_action_ids.iter().copied().collect::<BTreeSet<&str>>();
1142 let mut warnings = Vec::new();
1143
1144 let source = match configured {
1145 Some(bindings) => bindings
1146 .iter()
1147 .map(|binding| HotbarBinding {
1148 slot: binding.slot,
1149 action: binding.action.clone(),
1150 label: binding.label.clone(),
1151 })
1152 .collect::<Vec<_>>(),
1153 None => default_hotbar_bindings(),
1154 };
1155
1156 let mut by_slot: BTreeMap<u8, HotbarBinding> = BTreeMap::new();
1157 for binding in source {
1158 if !(1..=HOTBAR_SLOT_COUNT).contains(&binding.slot) {
1159 warnings.push(HotbarConfigWarning::SlotOutOfRange {
1160 slot: binding.slot,
1161 action: binding.action,
1162 });
1163 continue;
1164 }
1165 if !known.is_empty() && !known.contains(binding.action.as_str()) {
1166 warnings.push(HotbarConfigWarning::UnknownAction {
1167 slot: binding.slot,
1168 action: binding.action.clone(),
1169 });
1170 }
1171 if let Some(previous) = by_slot.insert(binding.slot, binding.clone()) {
1172 warnings.push(HotbarConfigWarning::DuplicateSlot {
1173 slot: binding.slot,
1174 previous_action: previous.action,
1175 replacement_action: binding.action,
1176 });
1177 }
1178 }
1179
1180 HotbarConfigResolution {
1181 bindings: by_slot.into_values().collect(),
1182 warnings,
1183 }
1184}
1185
1186impl ProviderChain {
1187 #[must_use]
1188 pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self {
1189 let mut providers = vec![active];
1190 for fallback in fallbacks {
1191 if *fallback != active && !providers.contains(fallback) {
1192 providers.push(*fallback);
1193 }
1194 }
1195 Self {
1196 providers,
1197 position: 0,
1198 }
1199 }
1200
1201 #[must_use]
1202 pub fn providers(&self) -> &[ProviderKind] {
1203 &self.providers
1204 }
1205
1206 #[must_use]
1207 pub fn position(&self) -> usize {
1208 self.position
1209 }
1210
1211 #[must_use]
1212 pub fn current(&self) -> ProviderKind {
1213 self.providers
1214 .get(self.position)
1215 .copied()
1216 .unwrap_or(self.providers[0])
1217 }
1218
1219 #[must_use]
1220 pub fn has_next(&self) -> bool {
1221 self.position + 1 < self.providers.len()
1222 }
1223
1224 pub fn advance(&mut self) -> Option<ProviderKind> {
1225 if !self.has_next() {
1226 return None;
1227 }
1228 self.position += 1;
1229 Some(self.current())
1230 }
1231
1232 pub fn reset(&mut self) {
1233 self.position = 0;
1234 }
1235
1236 #[must_use]
1237 pub fn is_fallback_active(&self) -> bool {
1238 self.position > 0
1239 }
1240
1241 #[must_use]
1243 pub fn remaining(&self) -> usize {
1244 self.providers.len() - self.position
1245 }
1246}
1247
1248#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1250pub struct HookSinksToml {
1251 #[serde(default)]
1256 pub unix_socket_path: Option<PathBuf>,
1257}
1258
1259#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1262pub struct SkillsToml {
1263 #[serde(default)]
1266 pub registry_url: Option<String>,
1267 #[serde(default)]
1270 pub max_install_size_bytes: Option<u64>,
1271}
1272
1273#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1275pub struct ToolsToml {
1276 #[serde(default)]
1278 pub always_load: Vec<String>,
1279}
1280
1281#[derive(Debug, Clone, Serialize, Deserialize)]
1284pub struct SnapshotsToml {
1285 #[serde(default = "default_snapshots_enabled")]
1286 pub enabled: bool,
1287 #[serde(default = "default_snapshot_max_age_days")]
1288 pub max_age_days: u64,
1289}
1290
1291fn default_snapshots_enabled() -> bool {
1292 true
1293}
1294
1295fn default_snapshot_max_age_days() -> u64 {
1296 7
1297}
1298
1299impl Default for SnapshotsToml {
1300 fn default() -> Self {
1301 Self {
1302 enabled: default_snapshots_enabled(),
1303 max_age_days: default_snapshot_max_age_days(),
1304 }
1305 }
1306}
1307
1308#[derive(Debug, Clone, Serialize, Deserialize)]
1311pub struct FleetConfigToml {
1312 #[serde(default = "default_fleet_trust_level_str")]
1315 pub default_trust_level: String,
1316 #[serde(default = "default_fleet_require_identity")]
1319 pub require_identity_verification: bool,
1320 #[serde(default = "default_fleet_max_trust_level_str")]
1323 pub max_trust_level: String,
1324 #[serde(default)]
1331 pub roles: BTreeMap<String, FleetRolePreset>,
1332 #[serde(default)]
1334 pub exec: FleetExecConfig,
1335}
1336
1337pub const DEFAULT_SPAWN_DEPTH: u32 = 3;
1352
1353pub const MAX_SPAWN_DEPTH_CEILING: u32 = 8;
1359
1360#[derive(Debug, Clone, Serialize, Deserialize)]
1365pub struct FleetExecConfig {
1366 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1368 pub allowed_tools: Vec<String>,
1369 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1371 pub disallowed_tools: Vec<String>,
1372 #[serde(default = "default_fleet_max_turns")]
1375 pub max_turns: u32,
1376 #[serde(default = "default_fleet_max_spawn_depth")]
1382 pub max_spawn_depth: u32,
1383 #[serde(default, skip_serializing_if = "String::is_empty")]
1386 pub append_system_prompt: String,
1387 #[serde(default = "default_fleet_output_format")]
1390 pub output_format: String,
1391}
1392
1393fn default_fleet_max_turns() -> u32 {
1394 u32::MAX
1395}
1396
1397fn default_fleet_max_spawn_depth() -> u32 {
1398 DEFAULT_SPAWN_DEPTH
1399}
1400
1401fn default_fleet_output_format() -> String {
1402 "text".to_string()
1403}
1404
1405impl Default for FleetExecConfig {
1406 fn default() -> Self {
1407 Self {
1408 allowed_tools: Vec::new(),
1409 disallowed_tools: Vec::new(),
1410 max_turns: default_fleet_max_turns(),
1411 max_spawn_depth: default_fleet_max_spawn_depth(),
1412 append_system_prompt: String::new(),
1413 output_format: default_fleet_output_format(),
1414 }
1415 }
1416}
1417
1418#[derive(Debug, Clone, Serialize, Deserialize)]
1427pub struct FleetRolePreset {
1428 #[serde(skip_serializing_if = "Option::is_none")]
1430 pub description: Option<String>,
1431 #[serde(skip_serializing_if = "Option::is_none")]
1433 pub tool_profile: Option<String>,
1434 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1436 pub tools: Vec<String>,
1437 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1439 pub capabilities: Vec<String>,
1440 #[serde(skip_serializing_if = "Option::is_none")]
1442 pub timeout_seconds: Option<u64>,
1443 #[serde(skip_serializing_if = "Option::is_none")]
1445 pub trust_level: Option<String>,
1446}
1447
1448fn default_fleet_trust_level_str() -> String {
1449 "sandbox".to_string()
1450}
1451
1452fn default_fleet_require_identity() -> bool {
1453 true
1454}
1455
1456fn default_fleet_max_trust_level_str() -> String {
1457 "operator".to_string()
1458}
1459
1460impl Default for FleetConfigToml {
1461 fn default() -> Self {
1462 Self {
1463 default_trust_level: default_fleet_trust_level_str(),
1464 require_identity_verification: default_fleet_require_identity(),
1465 max_trust_level: default_fleet_max_trust_level_str(),
1466 roles: BTreeMap::new(),
1467 exec: FleetExecConfig::default(),
1468 }
1469 }
1470}
1471
1472impl FleetConfigToml {
1473 #[must_use]
1476 pub fn resolve_role(&self, name: &str) -> Option<FleetRolePreset> {
1477 self.roles
1478 .get(name)
1479 .cloned()
1480 .or_else(|| built_in_role_presets().get(name).cloned())
1481 }
1482}
1483
1484#[must_use]
1486pub fn built_in_role_presets() -> BTreeMap<String, FleetRolePreset> {
1487 [
1488 (
1489 "smoke-runner".to_string(),
1490 FleetRolePreset {
1491 description: Some("Lightweight read-only smoke check worker".to_string()),
1492 tool_profile: Some("read-only".to_string()),
1493 tools: vec![],
1494 capabilities: vec![],
1495 timeout_seconds: Some(300),
1496 trust_level: Some("local".to_string()),
1497 },
1498 ),
1499 (
1500 "reviewer".to_string(),
1501 FleetRolePreset {
1502 description: Some("Read-only code and documentation review".to_string()),
1503 tool_profile: Some("read-only".to_string()),
1504 tools: vec![],
1505 capabilities: vec![],
1506 timeout_seconds: Some(600),
1507 trust_level: None,
1508 },
1509 ),
1510 (
1511 "builder".to_string(),
1512 FleetRolePreset {
1513 description: Some(
1514 "Read-write builder with compilation and test access".to_string(),
1515 ),
1516 tool_profile: Some("read-write".to_string()),
1517 tools: vec![],
1518 capabilities: vec![],
1519 timeout_seconds: Some(1800),
1520 trust_level: Some("local".to_string()),
1521 },
1522 ),
1523 (
1524 "read-only".to_string(),
1525 FleetRolePreset {
1526 description: Some(
1527 "Minimal read-only observer with no writes or secrets".to_string(),
1528 ),
1529 tool_profile: Some("read-only".to_string()),
1530 tools: vec![],
1531 capabilities: vec![],
1532 timeout_seconds: Some(300),
1533 trust_level: Some("sandbox".to_string()),
1534 },
1535 ),
1536 ]
1537 .into()
1538}
1539
1540#[derive(Debug, Clone, Serialize, Deserialize)]
1543pub struct NetworkPolicyToml {
1544 #[serde(default = "default_network_decision")]
1547 pub default: String,
1548 #[serde(default)]
1551 pub allow: Vec<String>,
1552 #[serde(default)]
1554 pub deny: Vec<String>,
1555 #[serde(default)]
1558 pub proxy: Vec<String>,
1559 #[serde(default = "default_network_audit")]
1561 pub audit: bool,
1562}
1563
1564fn default_network_decision() -> String {
1565 "prompt".to_string()
1566}
1567
1568fn default_network_audit() -> bool {
1569 true
1570}
1571
1572impl Default for NetworkPolicyToml {
1573 fn default() -> Self {
1574 Self {
1575 default: default_network_decision(),
1576 allow: Vec::new(),
1577 deny: Vec::new(),
1578 proxy: Vec::new(),
1579 audit: default_network_audit(),
1580 }
1581 }
1582}
1583
1584#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1588pub struct LspConfigToml {
1589 pub enabled: Option<bool>,
1591 pub poll_after_edit_ms: Option<u64>,
1593 pub max_diagnostics_per_file: Option<usize>,
1595 pub include_warnings: Option<bool>,
1597 pub servers: Option<BTreeMap<String, Vec<String>>>,
1599}
1600
1601impl ConfigToml {
1602 pub fn merge_project_overrides(&mut self, project: ConfigToml) {
1611 if project.default_text_model.is_some() {
1612 self.default_text_model = project.default_text_model;
1613 }
1614 if project.model.is_some() {
1615 self.model = project.model;
1616 }
1617 if project.output_mode.is_some() {
1618 self.output_mode = project.output_mode;
1619 }
1620 if project.verbosity.is_some() {
1621 self.verbosity = project.verbosity;
1622 }
1623 if project.log_level.is_some() {
1624 self.log_level = project.log_level;
1625 }
1626 if let Some(policy) = project.approval_policy
1627 && project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy)
1628 {
1629 self.approval_policy = Some(policy);
1630 }
1631 if let Some(mode) = project.sandbox_mode
1632 && project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode)
1633 {
1634 self.sandbox_mode = Some(mode);
1635 }
1636 if project.tools.is_some() {
1637 self.tools = project.tools;
1638 }
1639 for provider in ProviderKind::ALL {
1640 merge_project_provider_config(
1641 self.providers.for_provider_mut(provider),
1642 project.providers.for_provider(provider),
1643 );
1644 }
1645 }
1646
1647 #[must_use]
1648 pub fn get_value(&self, key: &str) -> Option<String> {
1649 if let Some((provider, field)) = parse_provider_config_key(key) {
1650 return get_provider_config_value(self.providers.for_provider(provider), field);
1651 }
1652
1653 match key {
1654 "provider" => Some(self.provider.as_str().to_string()),
1655 "api_key" => self.api_key.clone(),
1656 "base_url" => self.base_url.clone(),
1657 "http_headers" => serialize_http_headers(&self.http_headers),
1658 "default_text_model" => self.default_text_model.clone(),
1659 "model" => self.model.clone(),
1660 "auth.mode" => self.auth_mode.clone(),
1661 "output_mode" => self.output_mode.clone(),
1662 "verbosity" => self.verbosity.clone(),
1663 "log_level" => self.log_level.clone(),
1664 "telemetry" => self.telemetry.map(|v| v.to_string()),
1665 "approval_policy" => self.approval_policy.clone(),
1666 "sandbox_mode" => self.sandbox_mode.clone(),
1667 "tools.always_load" => self.tools.as_ref().map(|tools| tools.always_load.join(",")),
1668 "hook_sinks.unix_socket_path" => self
1669 .hook_sinks
1670 .as_ref()
1671 .and_then(|sinks| sinks.unix_socket_path.as_ref())
1672 .map(|path| path.display().to_string()),
1673 _ => self.extras.get(key).map(toml::Value::to_string),
1674 }
1675 }
1676
1677 #[must_use]
1678 pub fn get_display_value(&self, key: &str) -> Option<String> {
1679 if let Some((provider, field)) = parse_provider_config_key(key) {
1680 return get_provider_config_display_value(self.providers.for_provider(provider), field);
1681 }
1682
1683 if key == "http_headers" {
1684 return serialize_http_headers_for_display(&self.http_headers);
1685 }
1686
1687 if let Some(value) = self.extras.get(key) {
1688 return Some(redact_toml_value_for_display(key, value));
1689 }
1690
1691 self.get_value(key).map(|value| {
1692 if is_sensitive_config_key(key) {
1693 redact_secret(&value)
1694 } else {
1695 value
1696 }
1697 })
1698 }
1699
1700 pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
1701 if let Some((provider, field)) = parse_provider_config_key(key) {
1702 return set_provider_config_value(self, provider, field, value);
1703 }
1704
1705 match key {
1706 "provider" => {
1707 self.provider = ProviderKind::parse(value).with_context(|| {
1708 format!(
1709 "unknown provider '{value}': expected {}",
1710 ProviderKind::names_hint()
1711 )
1712 })?;
1713 }
1714 "api_key" => self.api_key = Some(value.to_string()),
1715 "base_url" => self.base_url = Some(value.to_string()),
1716 "http_headers" => self.http_headers = parse_http_headers(value)?,
1717 "default_text_model" => self.default_text_model = Some(value.to_string()),
1718 "model" => self.model = Some(value.to_string()),
1719 "auth.mode" => self.auth_mode = Some(value.to_string()),
1720 "output_mode" => self.output_mode = Some(value.to_string()),
1721 "verbosity" => self.verbosity = Some(value.to_string()),
1722 "log_level" => self.log_level = Some(value.to_string()),
1723 "telemetry" => {
1724 self.telemetry = Some(parse_bool(value)?);
1725 }
1726 "approval_policy" => self.approval_policy = Some(value.to_string()),
1727 "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
1728 "hook_sinks.unix_socket_path" => {
1729 self.hook_sinks
1730 .get_or_insert_with(HookSinksToml::default)
1731 .unix_socket_path = Some(PathBuf::from(value));
1732 }
1733 _ => {
1734 self.extras
1735 .insert(key.to_string(), toml::Value::String(value.to_string()));
1736 }
1737 }
1738 Ok(())
1739 }
1740
1741 pub fn unset_value(&mut self, key: &str) -> Result<()> {
1742 if let Some((provider, field)) = parse_provider_config_key(key) {
1743 unset_provider_config_value(self, provider, field);
1744 return Ok(());
1745 }
1746
1747 match key {
1748 "provider" => self.provider = ProviderKind::Deepseek,
1749 "api_key" => self.api_key = None,
1750 "base_url" => self.base_url = None,
1751 "http_headers" => self.http_headers.clear(),
1752 "default_text_model" => self.default_text_model = None,
1753 "model" => self.model = None,
1754 "auth.mode" => self.auth_mode = None,
1755 "output_mode" => self.output_mode = None,
1756 "verbosity" => self.verbosity = None,
1757 "log_level" => self.log_level = None,
1758 "telemetry" => self.telemetry = None,
1759 "approval_policy" => self.approval_policy = None,
1760 "sandbox_mode" => self.sandbox_mode = None,
1761 "hook_sinks.unix_socket_path" => {
1762 if let Some(sinks) = self.hook_sinks.as_mut() {
1763 sinks.unix_socket_path = None;
1764 }
1765 }
1766 _ => {
1767 self.extras.remove(key);
1768 }
1769 }
1770 Ok(())
1771 }
1772
1773 #[must_use]
1774 pub fn list_values(&self) -> BTreeMap<String, String> {
1775 let mut out = BTreeMap::new();
1776 out.insert("provider".to_string(), self.provider.as_str().to_string());
1777
1778 if let Some(v) = self.api_key.as_ref() {
1779 out.insert("api_key".to_string(), redact_secret(v));
1780 }
1781 if let Some(v) = self.base_url.as_ref() {
1782 out.insert("base_url".to_string(), v.clone());
1783 }
1784 if let Some(v) = serialize_http_headers_for_display(&self.http_headers) {
1785 out.insert("http_headers".to_string(), v);
1786 }
1787 if let Some(v) = self.default_text_model.as_ref() {
1788 out.insert("default_text_model".to_string(), v.clone());
1789 }
1790 if let Some(v) = self.model.as_ref() {
1791 out.insert("model".to_string(), v.clone());
1792 }
1793 if let Some(v) = self.auth_mode.as_ref() {
1794 out.insert("auth.mode".to_string(), v.clone());
1795 }
1796 if let Some(v) = self.output_mode.as_ref() {
1797 out.insert("output_mode".to_string(), v.clone());
1798 }
1799 if let Some(v) = self.verbosity.as_ref() {
1800 out.insert("verbosity".to_string(), v.clone());
1801 }
1802 if let Some(v) = self.log_level.as_ref() {
1803 out.insert("log_level".to_string(), v.clone());
1804 }
1805 if let Some(v) = self.telemetry {
1806 out.insert("telemetry".to_string(), v.to_string());
1807 }
1808 if let Some(v) = self.approval_policy.as_ref() {
1809 out.insert("approval_policy".to_string(), v.clone());
1810 }
1811 if let Some(v) = self.sandbox_mode.as_ref() {
1812 out.insert("sandbox_mode".to_string(), v.clone());
1813 }
1814 if let Some(v) = self
1815 .hook_sinks
1816 .as_ref()
1817 .and_then(|sinks| sinks.unix_socket_path.as_ref())
1818 {
1819 out.insert(
1820 "hook_sinks.unix_socket_path".to_string(),
1821 v.display().to_string(),
1822 );
1823 }
1824
1825 for provider in ProviderKind::ALL {
1826 insert_provider_config_values(
1827 &mut out,
1828 provider,
1829 self.providers.for_provider(provider),
1830 );
1831 }
1832
1833 for (k, v) in &self.extras {
1834 out.insert(k.clone(), redact_toml_value_for_display(k, v));
1835 }
1836 out
1837 }
1838
1839 #[must_use]
1846 pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
1847 let no_keyring = Secrets::new(std::sync::Arc::new(
1848 codewhale_secrets::InMemoryKeyringStore::new(),
1849 ));
1850 self.resolve_runtime_options_with_secrets(cli, &no_keyring)
1851 }
1852
1853 #[must_use]
1857 pub fn resolve_runtime_options_with_secrets(
1858 &self,
1859 cli: &CliRuntimeOverrides,
1860 secrets: &Secrets,
1861 ) -> ResolvedRuntimeOptions {
1862 let env = EnvRuntimeOverrides::load();
1863 let (provider, provider_source) = if let Some(provider) = cli.provider {
1864 (provider, ProviderSource::Cli)
1865 } else if let Some(provider) = env.provider {
1866 (
1867 provider,
1868 ProviderSource::Env(env.provider_source.unwrap_or("CODEWHALE_PROVIDER")),
1869 )
1870 } else {
1871 (self.provider, ProviderSource::Config)
1872 };
1873
1874 let mut provider_cfg = self.providers.for_provider(provider).clone();
1875 if provider == ProviderKind::SiliconflowCN {
1876 let fb = &self.providers.siliconflow;
1877 if provider_cfg.api_key.is_none() {
1878 provider_cfg.api_key = fb.api_key.clone();
1879 }
1880 if provider_cfg.base_url.is_none() {
1881 provider_cfg.base_url = fb.base_url.clone();
1882 }
1883 if provider_cfg.model.is_none() {
1884 provider_cfg.model = fb.model.clone();
1885 }
1886 }
1887 let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
1888 .then(|| self.api_key.clone())
1889 .flatten();
1890 let root_deepseek_base_url = (provider == ProviderKind::Deepseek)
1891 .then(|| self.base_url.clone())
1892 .flatten();
1893 let root_deepseek_model = (provider == ProviderKind::Deepseek)
1894 .then(|| self.default_text_model.clone())
1895 .flatten();
1896 let auth_mode = cli
1897 .auth_mode
1898 .clone()
1899 .or_else(|| env.auth_mode.clone())
1900 .or_else(|| provider_cfg.auth_mode.clone())
1901 .or_else(|| self.auth_mode.clone());
1902 let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
1903 let configured_base_url = cli
1904 .base_url
1905 .clone()
1906 .or_else(|| env.base_url_for(provider))
1907 .or_else(|| provider_cfg.base_url.clone())
1908 .or(root_deepseek_base_url);
1909 let xiaomi_mimo_mode = if provider == ProviderKind::XiaomiMimo {
1910 env.xiaomi_mimo_mode
1911 .clone()
1912 .or_else(|| provider_cfg.mode.clone())
1913 } else {
1914 None
1915 };
1916 let xiaomi_mimo_env_api_key = if provider == ProviderKind::XiaomiMimo {
1917 xiaomi_mimo_env_api_key_for_runtime(
1918 xiaomi_mimo_mode.as_deref(),
1919 configured_base_url.as_deref(),
1920 )
1921 } else {
1922 None
1923 };
1924 let explicit_api_key_for_endpoint = cli
1925 .api_key
1926 .as_deref()
1927 .or(from_file.as_deref())
1928 .or(xiaomi_mimo_env_api_key.as_deref());
1929 let base_url = if provider == ProviderKind::XiaomiMimo {
1930 resolve_xiaomi_mimo_base_url(
1931 configured_base_url,
1932 explicit_api_key_for_endpoint,
1933 xiaomi_mimo_mode.as_deref(),
1934 )
1935 } else {
1936 configured_base_url.unwrap_or_else(|| match provider {
1937 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
1938 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
1939 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
1940 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
1941 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
1942 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
1943 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
1944 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
1945 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
1946 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
1947 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
1948 ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL.to_string(),
1949 ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL.to_string(),
1950 ProviderKind::Moonshot => {
1951 if auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) {
1952 DEFAULT_KIMI_CODE_BASE_URL.to_string()
1953 } else {
1954 DEFAULT_MOONSHOT_BASE_URL.to_string()
1955 }
1956 }
1957 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
1958 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
1959 ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
1960 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(),
1961 ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL.to_string(),
1962 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
1963 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(),
1964 ProviderKind::Zai => DEFAULT_ZAI_BASE_URL.to_string(),
1965 ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL.to_string(),
1966 ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL.to_string(),
1967 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL.to_string(),
1968 })
1969 };
1970 let uses_kimi_oauth = provider == ProviderKind::Moonshot
1976 && auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth);
1977 let (api_key, api_key_source) = if let Some(value) = cli.api_key.clone() {
1978 (Some(value), Some(RuntimeApiKeySource::Cli))
1979 } else if uses_kimi_oauth {
1980 (None, None)
1981 } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) {
1982 (Some(value), Some(RuntimeApiKeySource::ConfigFile))
1983 } else if let Some(value) = xiaomi_mimo_env_api_key.filter(|v| !v.trim().is_empty()) {
1984 (Some(value), Some(RuntimeApiKeySource::Env))
1985 } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
1986 match env_api_key_for_provider(provider) {
1987 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
1988 None => (None, None),
1989 }
1990 } else {
1991 match secrets.resolve_with_source(provider.as_str()) {
1992 Some((value, source)) => {
1993 let source = match source {
1994 SecretSource::Keyring => RuntimeApiKeySource::Keyring,
1995 SecretSource::Env => RuntimeApiKeySource::Env,
1996 };
1997 (Some(value), Some(source))
1998 }
1999 None => match env_api_key_for_provider(provider) {
2000 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
2001 None => (None, None),
2002 },
2003 }
2004 };
2005
2006 let env_provider_model = env.model_for(provider, &base_url);
2007 let explicit_model = cli.model.is_some()
2008 || env.model.is_some()
2009 || env_provider_model.is_some()
2010 || provider_cfg.model.is_some()
2011 || root_deepseek_model.is_some()
2012 || self.model.is_some();
2013 let model = cli
2014 .model
2015 .clone()
2016 .or_else(|| env.model.clone())
2017 .or(env_provider_model)
2018 .or_else(|| provider_cfg.model.clone())
2019 .or(root_deepseek_model)
2020 .or_else(|| self.model.clone())
2021 .unwrap_or_else(|| {
2022 if provider == ProviderKind::Moonshot
2023 && (auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth)
2024 || moonshot_base_url_uses_kimi_code(&base_url))
2025 {
2026 DEFAULT_KIMI_CODE_MODEL.to_string()
2027 } else {
2028 default_model_for_provider(provider).to_string()
2029 }
2030 });
2031 let model =
2032 if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
2033 model.trim().to_string()
2034 } else {
2035 normalize_model_for_provider(provider, &model)
2036 };
2037
2038 let mut http_headers = self.http_headers.clone();
2039 http_headers.extend(provider_cfg.http_headers.clone());
2040 if let Some(env_headers) = env.http_headers {
2041 http_headers.extend(env_headers);
2042 }
2043 http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
2044
2045 let output_mode = cli
2046 .output_mode
2047 .clone()
2048 .or_else(|| env.output_mode.clone())
2049 .or_else(|| self.output_mode.clone());
2050 let log_level = cli
2051 .log_level
2052 .clone()
2053 .or_else(|| env.log_level.clone())
2054 .or_else(|| self.log_level.clone());
2055 let telemetry = cli
2056 .telemetry
2057 .or(env.telemetry)
2058 .or(self.telemetry)
2059 .unwrap_or(false);
2060 let approval_policy = cli
2061 .approval_policy
2062 .clone()
2063 .or_else(|| env.approval_policy.clone())
2064 .or_else(|| self.approval_policy.clone());
2065 let sandbox_mode = cli
2066 .sandbox_mode
2067 .clone()
2068 .or_else(|| env.sandbox_mode.clone())
2069 .or_else(|| self.sandbox_mode.clone());
2070 let yolo = cli.yolo.or(env.yolo);
2071 let verbosity = cli
2072 .verbosity
2073 .clone()
2074 .or_else(|| env.verbosity.clone())
2075 .or_else(|| self.verbosity.clone());
2076
2077 ResolvedRuntimeOptions {
2078 provider,
2079 provider_source,
2080 model,
2081 api_key,
2082 api_key_source,
2083 base_url,
2084 auth_mode,
2085 insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false),
2086 output_mode,
2087 log_level,
2088 telemetry,
2089 approval_policy,
2090 sandbox_mode,
2091 yolo,
2092 verbosity,
2093 http_headers,
2094 }
2095 }
2096}
2097
2098fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
2099 if source.model.is_some() {
2100 target.model = source.model.clone();
2101 }
2102}
2103
2104#[must_use]
2105pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
2106 let Some(project_rank) = approval_policy_rank(project) else {
2107 return false;
2108 };
2109 match current.and_then(approval_policy_rank) {
2110 Some(current_rank) => project_rank >= current_rank,
2111 None => project_rank >= 2,
2112 }
2113}
2114
2115#[must_use]
2116pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
2117 let normalized_project = project.trim().to_ascii_lowercase();
2118 if normalized_project == "external-sandbox" {
2119 return current
2120 .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
2121 .unwrap_or(false);
2122 }
2123
2124 let Some(project_rank) = sandbox_mode_rank(project) else {
2125 return false;
2126 };
2127 match current.and_then(sandbox_mode_rank) {
2128 Some(current_rank) => project_rank >= current_rank,
2129 None => project_rank >= 2,
2130 }
2131}
2132
2133fn approval_policy_rank(value: &str) -> Option<u8> {
2134 match value.trim().to_ascii_lowercase().as_str() {
2135 "auto" => Some(0),
2136 "suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
2137 "never" | "deny" | "denied" => Some(2),
2138 _ => None,
2139 }
2140}
2141
2142fn sandbox_mode_rank(value: &str) -> Option<u8> {
2143 match value.trim().to_ascii_lowercase().as_str() {
2144 "danger-full-access" => Some(0),
2145 "external-sandbox" => Some(0),
2146 "workspace-write" => Some(1),
2147 "read-only" => Some(2),
2148 _ => None,
2149 }
2150}
2151
2152pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
2158 for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
2159 let path = workspace.join(dir).join(CONFIG_FILE_NAME);
2160 if path.exists()
2161 && let Ok(raw) = fs::read_to_string(&path)
2162 {
2163 match toml::from_str(&raw) {
2164 Ok(config) => return Some(config),
2165 Err(e) => {
2166 tracing::warn!("Failed to parse project config {}: {e}", path.display());
2167 return None;
2168 }
2169 }
2170 }
2171 }
2172 None
2173}
2174
2175fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
2176 if matches!(provider, ProviderKind::XiaomiMimo)
2177 && let Some(canonical) = canonical_xiaomi_mimo_model_id(model)
2178 {
2179 return canonical.to_string();
2180 }
2181 if matches!(provider, ProviderKind::Minimax)
2182 && let Some(canonical) = canonical_minimax_model_id(model)
2183 {
2184 return canonical.to_string();
2185 }
2186 if matches!(provider, ProviderKind::Zai)
2187 && let Some(canonical) = canonical_zai_model_id(model)
2188 {
2189 return canonical.to_string();
2190 }
2191
2192 if matches!(
2193 provider,
2194 ProviderKind::Atlascloud
2195 | ProviderKind::WanjieArk
2196 | ProviderKind::Volcengine
2197 | ProviderKind::XiaomiMimo
2198 | ProviderKind::Zai
2199 | ProviderKind::Stepfun
2200 | ProviderKind::Minimax
2201 | ProviderKind::Ollama
2202 ) {
2203 return model.to_string();
2204 }
2205
2206 let normalized = model.trim().to_ascii_lowercase();
2207 if provider == ProviderKind::Openrouter
2208 && let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
2209 {
2210 return canonical.to_string();
2211 }
2212 match (provider, normalized.as_str()) {
2213 (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
2214 DEFAULT_NVIDIA_NIM_MODEL.to_string()
2215 }
2216 (
2217 ProviderKind::NvidiaNim,
2218 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2219 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2220 ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
2221 (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
2222 DEFAULT_OPENROUTER_MODEL.to_string()
2223 }
2224 (
2225 ProviderKind::Openrouter,
2226 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2227 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2228 ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
2229 (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
2230 DEFAULT_NOVITA_MODEL.to_string()
2231 }
2232 (
2233 ProviderKind::Novita,
2234 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2235 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2236 ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
2237 (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
2238 DEFAULT_FIREWORKS_MODEL.to_string()
2239 }
2240 (
2241 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2242 "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
2243 ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
2244 (
2245 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN,
2246 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
2247 ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
2248 (
2249 ProviderKind::Arcee,
2250 "trinity" | "arcee-trinity" | "trinity-large-thinking" | "arcee-trinity-large-thinking",
2251 ) => DEFAULT_ARCEE_MODEL.to_string(),
2252 (ProviderKind::Arcee, "trinity-mini" | "arcee-trinity-mini") => {
2253 ARCEE_TRINITY_MINI_MODEL.to_string()
2254 }
2255 (ProviderKind::Arcee, "arcee-trinity-large-preview") => {
2256 ARCEE_TRINITY_LARGE_PREVIEW_MODEL.to_string()
2257 }
2258 (
2259 ProviderKind::Moonshot,
2260 "kimi"
2261 | "kimi-k2"
2262 | "kimi-k2.7"
2263 | "kimi-k2-7"
2264 | "kimi-k2.7-code"
2265 | "kimi-k2-7-code"
2266 | "kimi-code"
2267 | "moonshot-kimi-k2.7-code",
2268 ) => DEFAULT_MOONSHOT_MODEL.to_string(),
2269 (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6") => {
2270 MOONSHOT_KIMI_K2_6_MODEL.to_string()
2271 }
2272 (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
2273 DEFAULT_SGLANG_MODEL.to_string()
2274 }
2275 (
2276 ProviderKind::Sglang,
2277 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2278 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2279 ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
2280 (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
2281 DEFAULT_VLLM_MODEL.to_string()
2282 }
2283 (
2284 ProviderKind::Vllm,
2285 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2286 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2287 ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
2288 (ProviderKind::Huggingface, "deepseek-v4-pro" | "deepseek-v4pro") => {
2289 DEFAULT_HUGGINGFACE_MODEL.to_string()
2290 }
2291 (
2292 ProviderKind::Huggingface,
2293 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2294 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2295 ) => DEFAULT_HUGGINGFACE_FLASH_MODEL.to_string(),
2296 (ProviderKind::Together, "deepseek-v4-pro" | "deepseek-v4pro") => {
2297 DEFAULT_TOGETHER_MODEL.to_string()
2298 }
2299 (
2300 ProviderKind::Together,
2301 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2302 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2303 ) => DEFAULT_TOGETHER_FLASH_MODEL.to_string(),
2304 (ProviderKind::Deepinfra, "deepseek-v4-pro" | "deepseek-v4pro") => {
2305 DEFAULT_DEEPINFRA_MODEL.to_string()
2306 }
2307 (
2308 ProviderKind::Deepinfra,
2309 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
2310 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
2311 ) => DEFAULT_DEEPINFRA_FLASH_MODEL.to_string(),
2312 _ => model.to_string(),
2313 }
2314}
2315
2316fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> {
2317 let normalized = model.trim().to_ascii_lowercase();
2318 let normalized = normalized.replace(['_', ' '], "-");
2319 match normalized.as_str() {
2320 "mimo"
2321 | DEFAULT_XIAOMI_MIMO_MODEL
2322 | "mimo-v2-5-pro"
2323 | "xiaomi-mimo-v2.5-pro"
2324 | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL),
2325 "omni"
2326 | "mimo-omni"
2327 | "v2.5-omni"
2328 | "v25-omni"
2329 | "mimo-v2.5"
2330 | "mimo-v25"
2331 | "mimo-v2-5"
2332 | "mimo-v2.5-omni"
2333 | "mimo-v25-omni"
2334 | "mimo-v2-5-omni"
2335 | "xiaomi-mimo-v2.5"
2336 | "xiaomi-mimo-v2-5"
2337 | "xiaomi-mimo-v2.5-omni"
2338 | "xiaomi-mimo-v2-5-omni" => Some(XIAOMI_MIMO_V2_5_OMNI_MODEL),
2339 "asr" | "mimo-asr" | "mimo-v2.5-asr" | "speech-to-text" | "transcribe" => {
2340 Some(XIAOMI_MIMO_ASR_MODEL)
2341 }
2342 "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => {
2343 Some(XIAOMI_MIMO_TTS_MODEL)
2344 }
2345 "mimo-tts-voicedesign"
2346 | "mimo-voice-design"
2347 | "mimo-v25-tts-voicedesign"
2348 | "mimo-v2.5-tts-voicedesign"
2349 | "voicedesign"
2350 | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL),
2351 "mimo-tts-voiceclone"
2352 | "mimo-voice-clone"
2353 | "mimo-v25-tts-voiceclone"
2354 | "mimo-v2.5-tts-voiceclone"
2355 | "voiceclone"
2356 | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL),
2357 "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL),
2358 _ => None,
2359 }
2360}
2361
2362fn canonical_minimax_model_id(model: &str) -> Option<&'static str> {
2363 let normalized = model.trim().to_ascii_lowercase();
2364 let normalized = normalized.replace(['_', ' '], "-");
2365 match normalized.as_str() {
2366 "minimax" | "minimax-m3" | "minimax-m-3" | "minimax-m-3-thinking" => {
2367 Some(DEFAULT_MINIMAX_MODEL)
2368 }
2369 "minimax-m2.7" | "minimax-m2-7" | "minimax-m-2.7" | "minimax-m-2-7" => {
2370 Some(MINIMAX_M2_7_MODEL)
2371 }
2372 "minimax-m2.7-highspeed"
2373 | "minimax-m2-7-highspeed"
2374 | "minimax-m-2.7-highspeed"
2375 | "minimax-m-2-7-highspeed" => Some(MINIMAX_M2_7_HIGHSPEED_MODEL),
2376 "minimax-m2.5" | "minimax-m2-5" | "minimax-m-2.5" | "minimax-m-2-5" => {
2377 Some(MINIMAX_M2_5_MODEL)
2378 }
2379 "minimax-m2.5-highspeed"
2380 | "minimax-m2-5-highspeed"
2381 | "minimax-m-2.5-highspeed"
2382 | "minimax-m-2-5-highspeed" => Some(MINIMAX_M2_5_HIGHSPEED_MODEL),
2383 "minimax-m2.1" | "minimax-m2-1" | "minimax-m-2.1" | "minimax-m-2-1" => {
2384 Some(MINIMAX_M2_1_MODEL)
2385 }
2386 "minimax-m2.1-highspeed"
2387 | "minimax-m2-1-highspeed"
2388 | "minimax-m-2.1-highspeed"
2389 | "minimax-m-2-1-highspeed" => Some(MINIMAX_M2_1_HIGHSPEED_MODEL),
2390 "minimax-m2" | "minimax-m-2" => Some(MINIMAX_M2_MODEL),
2391 _ => None,
2392 }
2393}
2394
2395fn canonical_zai_model_id(model: &str) -> Option<&'static str> {
2396 let normalized = model.trim().to_ascii_lowercase();
2397 let normalized = normalized.replace(['_', ' '], "-");
2398 match normalized.as_str() {
2399 "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => Some(ZAI_GLM_5_1_MODEL),
2400 "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(DEFAULT_ZAI_MODEL),
2401 "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL),
2402 _ => None,
2403 }
2404}
2405
2406fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
2407 let normalized = model.trim().to_ascii_lowercase();
2408 let normalized = normalized.replace(['_', ' '], "-");
2409 match normalized.as_str() {
2410 OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
2411 | "trinity"
2412 | "trinity-large-thinking"
2413 | "arcee-trinity"
2414 | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
2415 OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
2416 Some(OPENROUTER_GEMMA_4_31B_MODEL)
2417 }
2418 OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
2419 Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
2420 }
2421 OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
2422 Some(OPENROUTER_GLM_5_1_MODEL)
2423 }
2424 OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => {
2425 Some(OPENROUTER_GLM_5_2_MODEL)
2426 }
2427 OPENROUTER_KIMI_K2_7_CODE_MODEL
2428 | "kimi"
2429 | "kimi-k2"
2430 | "kimi-k2.7"
2431 | "kimi-k2-7"
2432 | "kimi-k2.7-code"
2433 | "kimi-k2-7-code"
2434 | "kimi-code"
2435 | "moonshot-kimi-k2.7-code"
2436 | "openrouter-kimi-k2.7-code" => Some(OPENROUTER_KIMI_K2_7_CODE_MODEL),
2437 OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
2438 Some(OPENROUTER_KIMI_K2_6_MODEL)
2439 }
2440 OPENROUTER_MINIMAX_M3_MODEL | "minimax-m3" | "minimax-m-3" => {
2441 Some(OPENROUTER_MINIMAX_M3_MODEL)
2442 }
2443 OPENROUTER_MINIMAX_2_7_MODEL
2444 | "minimax-2.7"
2445 | "minimax-2-7"
2446 | "minimax-m2.7"
2447 | "minimax-m2-7"
2448 | "minimax-m-2.7"
2449 | "minimax-m-2-7" => Some(OPENROUTER_MINIMAX_2_7_MODEL),
2450 OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
2451 | "nemotron-3-nano-omni"
2452 | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
2453 OPENROUTER_QWEN_3_6_35B_A3B_MODEL
2454 | "qwen3.6-35b-a3b"
2455 | "qwen-3.6-35b-a3b"
2456 | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
2457 OPENROUTER_QWEN_3_6_FLASH_MODEL | "qwen3.6-flash" | "qwen-3.6-flash" => {
2458 Some(OPENROUTER_QWEN_3_6_FLASH_MODEL)
2459 }
2460 OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL
2461 | "qwen3.6-max-preview"
2462 | "qwen-3.6-max-preview"
2463 | "qwen-max-preview" => Some(OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
2464 OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
2465 Some(OPENROUTER_QWEN_3_6_27B_MODEL)
2466 }
2467 OPENROUTER_QWEN_3_6_PLUS_MODEL | "qwen3.6-plus" | "qwen-3.6-plus" => {
2468 Some(OPENROUTER_QWEN_3_6_PLUS_MODEL)
2469 }
2470 OPENROUTER_QWEN_3_7_MAX_MODEL | "qwen3.7-max" | "qwen-3.7-max" => {
2471 Some(OPENROUTER_QWEN_3_7_MAX_MODEL)
2472 }
2473 OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
2474 Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
2475 }
2476 OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
2477 | "mimo-v2.5-pro"
2478 | "mimo-v2-5-pro"
2479 | "xiaomi-mimo-v2.5-pro"
2480 | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
2481 OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
2482 | "mimo-v2.5"
2483 | "mimo-v2-5"
2484 | "xiaomi-mimo-v2.5"
2485 | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
2486 _ => None,
2487 }
2488}
2489
2490fn default_model_for_provider(provider: ProviderKind) -> &'static str {
2491 match provider {
2492 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
2493 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
2494 ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
2495 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
2496 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
2497 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
2498 ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
2499 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
2500 ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
2501 ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
2502 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_MODEL,
2503 ProviderKind::Arcee => DEFAULT_ARCEE_MODEL,
2504 ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
2505 ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
2506 ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
2507 ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
2508 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
2509 ProviderKind::Together => DEFAULT_TOGETHER_MODEL,
2510 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL,
2511 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_MODEL,
2512 ProviderKind::Zai => DEFAULT_ZAI_MODEL,
2513 ProviderKind::Stepfun => DEFAULT_STEPFUN_MODEL,
2514 ProviderKind::Minimax => DEFAULT_MINIMAX_MODEL,
2515 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
2516 }
2517}
2518
2519fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
2520 match provider {
2521 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
2522 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
2523 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
2524 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
2525 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
2526 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
2527 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
2528 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
2529 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
2530 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
2531 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
2532 ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL,
2533 ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL,
2534 ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
2535 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
2536 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
2537 ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
2538 ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
2539 ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL,
2540 ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
2541 ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
2542 ProviderKind::Zai => DEFAULT_ZAI_BASE_URL,
2543 ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL,
2544 ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL,
2545 ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
2546 }
2547}
2548
2549fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
2550 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
2551 normalized == DEFAULT_KIMI_CODE_BASE_URL
2552 || normalized == "https://api.kimi.com/coding"
2553 || normalized.starts_with("https://api.kimi.com/coding/")
2554}
2555
2556fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
2557 let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
2558 if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
2559 return None;
2560 }
2561 Some(match normalized.as_str() {
2562 "token-plan" | "tokenplan" | "subscription" | "subscribed" | "plan" => {
2563 DEFAULT_XIAOMI_MIMO_BASE_URL
2564 }
2565 "token-plan-cn"
2566 | "token-plan-china"
2567 | "token-plan-mainland"
2568 | "token-plan-mainland-china"
2569 | "cn"
2570 | "china" => XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
2571 "token-plan-sgp"
2572 | "token-plan-sg"
2573 | "token-plan-singapore"
2574 | "sgp"
2575 | "sg"
2576 | "singapore" => XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
2577 "token-plan-ams"
2578 | "token-plan-eu"
2579 | "token-plan-europe"
2580 | "token-plan-amsterdam"
2581 | "ams"
2582 | "eu"
2583 | "europe"
2584 | "amsterdam" => XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
2585 _ => DEFAULT_XIAOMI_MIMO_BASE_URL,
2586 })
2587}
2588
2589fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
2590 matches!(
2591 normalized_mode,
2592 "standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
2593 )
2594}
2595
2596fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
2597 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
2598 normalized == XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL
2599 || normalized == XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL
2600 || normalized == XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL
2601}
2602
2603fn xiaomi_mimo_env_var(candidates: &[&str]) -> Option<String> {
2604 candidates.iter().find_map(|name| {
2605 std::env::var(name)
2606 .ok()
2607 .filter(|value| !value.trim().is_empty())
2608 })
2609}
2610
2611fn xiaomi_mimo_env_api_key_for_runtime(
2612 mode: Option<&str>,
2613 base_url: Option<&str>,
2614) -> Option<String> {
2615 const TOKEN_PLAN_ENV_VARS: &[&str] =
2616 &["XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "MIMO_TOKEN_PLAN_API_KEY"];
2617 const STANDARD_ENV_VARS: &[&str] = &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"];
2618
2619 let normalized_mode =
2620 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
2621 let standard_selected = normalized_mode
2622 .as_deref()
2623 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint)
2624 || base_url.is_some_and(xiaomi_mimo_base_url_is_pay_as_you_go);
2625 if standard_selected {
2626 return xiaomi_mimo_env_var(STANDARD_ENV_VARS);
2627 }
2628
2629 let token_plan_selected = normalized_mode
2630 .as_deref()
2631 .and_then(xiaomi_mimo_base_url_for_mode)
2632 .is_some()
2633 || base_url.is_some_and(xiaomi_mimo_base_url_uses_token_plan);
2634 if token_plan_selected {
2635 return xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS);
2636 }
2637
2638 xiaomi_mimo_env_var(TOKEN_PLAN_ENV_VARS).or_else(|| xiaomi_mimo_env_var(STANDARD_ENV_VARS))
2639}
2640
2641fn resolve_xiaomi_mimo_base_url(
2642 configured: Option<String>,
2643 api_key: Option<&str>,
2644 mode: Option<&str>,
2645) -> String {
2646 let normalized_mode =
2647 mode.map(|value| value.trim().to_ascii_lowercase().replace(['_', ' '], "-"));
2648 let uses_standard_mode = normalized_mode
2649 .as_deref()
2650 .is_some_and(xiaomi_mimo_mode_uses_standard_endpoint);
2651 let mode_base_url = normalized_mode
2652 .as_deref()
2653 .and_then(xiaomi_mimo_base_url_for_mode);
2654 let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key);
2655 match configured {
2656 Some(base_url) if uses_standard_mode => base_url,
2657 Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => {
2658 mode_base_url
2659 .unwrap_or(DEFAULT_XIAOMI_MIMO_BASE_URL)
2660 .to_string()
2661 }
2662 Some(base_url) => base_url,
2663 None => {
2664 if let Some(base_url) = mode_base_url {
2665 base_url.to_string()
2666 } else if uses_standard_mode {
2667 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
2668 } else if uses_token_plan || api_key.is_none() {
2669 DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()
2670 } else {
2671 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()
2672 }
2673 }
2674 }
2675}
2676
2677fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
2678 api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
2679}
2680
2681fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool {
2682 matches!(
2683 base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
2684 "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1"
2685 )
2686}
2687
2688fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
2689 if provider.is_siliconflow() && siliconflow_base_url_is_official(base_url) {
2690 return false;
2691 }
2692 if provider == ProviderKind::XiaomiMimo
2693 && (xiaomi_mimo_base_url_uses_token_plan(base_url)
2694 || xiaomi_mimo_base_url_is_pay_as_you_go(base_url))
2695 {
2696 return false;
2697 }
2698 let actual = base_url.trim_end_matches('/');
2699 let default = default_base_url_for_provider(provider).trim_end_matches('/');
2700 actual != default
2701}
2702
2703fn siliconflow_base_url_is_official(base_url: &str) -> bool {
2704 matches!(
2705 base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
2706 "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
2707 )
2708}
2709
2710fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
2711 base_url_is_custom_for_provider(provider, base_url)
2712}
2713
2714fn should_skip_secret_store_for_provider(
2715 provider: ProviderKind,
2716 base_url: &str,
2717 auth_mode: Option<&str>,
2718) -> bool {
2719 if auth_mode_requires_api_key(auth_mode) {
2720 return false;
2721 }
2722 if auth_mode_disables_api_key(auth_mode) {
2723 return true;
2724 }
2725
2726 matches!(
2727 provider,
2728 ProviderKind::Sglang | ProviderKind::Vllm | ProviderKind::Ollama
2729 ) || base_url_uses_local_host(base_url)
2730}
2731
2732fn env_api_key_for_provider(provider: ProviderKind) -> Option<String> {
2733 if provider == ProviderKind::Huggingface {
2734 return std::env::var("HUGGINGFACE_API_KEY")
2735 .ok()
2736 .filter(|value| !value.trim().is_empty())
2737 .or_else(|| {
2738 std::env::var("HF_TOKEN")
2739 .ok()
2740 .filter(|value| !value.trim().is_empty())
2741 });
2742 }
2743
2744 codewhale_secrets::env_for(provider.as_str())
2745}
2746
2747fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
2748 matches!(
2749 auth_mode
2750 .map(str::trim)
2751 .filter(|value| !value.is_empty())
2752 .map(|value| value.to_ascii_lowercase()),
2753 Some(value)
2754 if matches!(
2755 value.as_str(),
2756 "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
2757 )
2758 )
2759}
2760
2761fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
2762 matches!(
2763 auth_mode
2764 .map(str::trim)
2765 .filter(|value| !value.is_empty())
2766 .map(|value| value.to_ascii_lowercase()),
2767 Some(value)
2768 if matches!(
2769 value.as_str(),
2770 "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
2771 )
2772 )
2773}
2774
2775fn auth_mode_uses_kimi_oauth(auth_mode: &str) -> bool {
2776 matches!(
2777 auth_mode
2778 .trim()
2779 .to_ascii_lowercase()
2780 .replace('-', "_")
2781 .as_str(),
2782 "kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
2783 )
2784}
2785
2786fn base_url_uses_local_host(base_url: &str) -> bool {
2787 let Some(host) = base_url_host(base_url) else {
2788 return false;
2789 };
2790 let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
2791 if matches!(host.as_str(), "localhost" | "0.0.0.0") {
2792 return true;
2793 }
2794 host.parse::<std::net::IpAddr>()
2795 .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
2796}
2797
2798fn base_url_host(base_url: &str) -> Option<&str> {
2799 let without_scheme = base_url
2800 .split_once("://")
2801 .map_or(base_url, |(_, rest)| rest);
2802 let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
2803 if let Some(rest) = authority.strip_prefix('[') {
2804 return rest.split_once(']').map(|(host, _)| host);
2805 }
2806 authority.split(':').next().filter(|host| !host.is_empty())
2807}
2808
2809#[derive(Debug, Clone, Default)]
2810pub struct CliRuntimeOverrides {
2811 pub provider: Option<ProviderKind>,
2812 pub model: Option<String>,
2813 pub api_key: Option<String>,
2814 pub base_url: Option<String>,
2815 pub auth_mode: Option<String>,
2816 pub output_mode: Option<String>,
2817 pub log_level: Option<String>,
2818 pub telemetry: Option<bool>,
2819 pub approval_policy: Option<String>,
2820 pub sandbox_mode: Option<String>,
2821 pub yolo: Option<bool>,
2822 pub verbosity: Option<String>,
2823}
2824
2825#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2826pub enum RuntimeApiKeySource {
2827 Cli,
2828 ConfigFile,
2829 Keyring,
2830 Env,
2831}
2832
2833impl RuntimeApiKeySource {
2834 #[must_use]
2835 pub fn as_env_value(self) -> &'static str {
2836 match self {
2837 Self::Cli => "cli",
2838 Self::ConfigFile => "config",
2839 Self::Keyring => "keyring",
2840 Self::Env => "env",
2841 }
2842 }
2843}
2844
2845#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2846pub enum ProviderSource {
2847 Cli,
2848 Env(&'static str),
2849 Config,
2850}
2851
2852#[derive(Debug, Clone)]
2853pub struct ResolvedRuntimeOptions {
2854 pub provider: ProviderKind,
2855 pub provider_source: ProviderSource,
2856 pub model: String,
2857 pub api_key: Option<String>,
2858 pub api_key_source: Option<RuntimeApiKeySource>,
2859 pub base_url: String,
2860 pub auth_mode: Option<String>,
2861 pub insecure_skip_tls_verify: bool,
2862 pub output_mode: Option<String>,
2863 pub log_level: Option<String>,
2864 pub telemetry: bool,
2865 pub approval_policy: Option<String>,
2866 pub sandbox_mode: Option<String>,
2867 pub yolo: Option<bool>,
2868 pub verbosity: Option<String>,
2869 pub http_headers: BTreeMap<String, String>,
2870}
2871
2872#[derive(Debug, Clone)]
2873pub struct ConfigStore {
2874 path: PathBuf,
2875 pub config: ConfigToml,
2876 permissions: PermissionsToml,
2877 original_raw: Option<String>,
2880}
2881
2882impl ConfigStore {
2883 pub fn load(path: Option<PathBuf>) -> Result<Self> {
2884 let path = resolve_config_path(path)?;
2885 let (config, original_raw) = if path.exists() {
2886 let raw = fs::read_to_string(&path)
2887 .with_context(|| format!("failed to read config at {}", path.display()))?;
2888 let parsed: ConfigToml = toml::from_str(&raw)
2889 .with_context(|| format!("failed to parse config at {}", path.display()))?;
2890 (parsed, Some(raw))
2891 } else {
2892 (ConfigToml::default(), None)
2893 };
2894 let permissions = load_sibling_permissions(&path)?;
2895
2896 Ok(Self {
2897 path,
2898 config,
2899 permissions,
2900 original_raw,
2901 })
2902 }
2903
2904 pub fn save(&self) -> Result<()> {
2905 if let Some(parent) = self.path.parent() {
2906 fs::create_dir_all(parent).with_context(|| {
2907 format!("failed to create config directory {}", parent.display())
2908 })?;
2909 }
2910 let body = if let Some(ref original_raw) = self.original_raw {
2911 let serialized =
2912 toml::to_string_pretty(&self.config).context("failed to serialize config")?;
2913 merge_and_preserve_comments(&serialized, original_raw).unwrap_or_else(|e| {
2914 tracing::warn!("failed to merge config comments, saving without them: {e:#}");
2915 serialized
2916 })
2917 } else {
2918 toml::to_string_pretty(&self.config).context("failed to serialize config")?
2919 };
2920 match fs::read_to_string(&self.path) {
2921 Ok(existing) => {
2922 if existing == body {
2923 return Ok(());
2924 }
2925 write_one_time_config_backup(&self.path)?;
2926 }
2927 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
2928 Err(err) => {
2929 return Err(err)
2930 .with_context(|| format!("failed to read config at {}", self.path.display()));
2931 }
2932 }
2933 #[cfg(unix)]
2934 {
2935 let mut file = fs::OpenOptions::new()
2936 .write(true)
2937 .create(true)
2938 .truncate(true)
2939 .mode(0o600)
2940 .open(&self.path)
2941 .with_context(|| format!("failed to write config at {}", self.path.display()))?;
2942 file.write_all(body.as_bytes())
2943 .with_context(|| format!("failed to write config at {}", self.path.display()))?;
2944 file.set_permissions(fs::Permissions::from_mode(0o600))
2945 .with_context(|| {
2946 format!(
2947 "failed to set config permissions at {}",
2948 self.path.display()
2949 )
2950 })?;
2951 }
2952 #[cfg(not(unix))]
2953 {
2954 fs::write(&self.path, body)
2955 .with_context(|| format!("failed to write config at {}", self.path.display()))?;
2956 }
2957 Ok(())
2958 }
2959
2960 #[must_use]
2961 pub fn path(&self) -> &Path {
2962 &self.path
2963 }
2964
2965 #[must_use]
2966 pub fn permissions(&self) -> &PermissionsToml {
2967 &self.permissions
2968 }
2969
2970 #[must_use]
2971 pub fn permissions_path(&self) -> PathBuf {
2972 permissions_path_for_config_path(&self.path)
2973 }
2974
2975 #[must_use]
2976 pub fn exec_policy_engine(&self) -> ExecPolicyEngine {
2977 if self.permissions.is_empty() {
2978 ExecPolicyEngine::new(Vec::new(), Vec::new())
2979 } else {
2980 ExecPolicyEngine::with_rulesets(vec![self.permissions.ruleset()])
2981 }
2982 }
2983
2984 pub fn append_ask_rules(&mut self, rules: &[ToolAskRule]) -> Result<usize> {
2991 if rules.is_empty() {
2992 return Ok(0);
2993 }
2994
2995 let path = self.permissions_path();
2996 let raw = if path.exists() {
2997 fs::read_to_string(&path)
2998 .with_context(|| format!("failed to read permissions at {}", path.display()))?
2999 } else {
3000 String::new()
3001 };
3002 let mut permissions = if raw.trim().is_empty() {
3003 PermissionsToml::default()
3004 } else {
3005 toml::from_str(&raw)
3006 .with_context(|| format!("failed to parse permissions at {}", path.display()))?
3007 };
3008 let mut document = if raw.trim().is_empty() {
3009 toml_edit::DocumentMut::new()
3010 } else {
3011 raw.parse::<toml_edit::DocumentMut>()
3012 .with_context(|| format!("failed to edit permissions at {}", path.display()))?
3013 };
3014
3015 if !document.contains_key("rules") {
3016 document["rules"] = toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
3017 }
3018 let rules_item = document
3019 .get_mut("rules")
3020 .expect("rules entry was inserted above");
3021
3022 let mut added = 0;
3023 for rule in rules {
3024 if permissions.rules.contains(rule) {
3025 continue;
3026 }
3027 append_ask_rule(rules_item, rule)?;
3028 permissions.rules.push(rule.clone());
3029 added += 1;
3030 }
3031 if added == 0 {
3032 self.permissions = permissions;
3033 return Ok(0);
3034 }
3035
3036 let body = document.to_string();
3037 let persisted: PermissionsToml = toml::from_str(&body).with_context(|| {
3038 format!(
3039 "generated invalid permissions document for {}",
3040 path.display()
3041 )
3042 })?;
3043 write_permissions_atomic(&path, body.as_bytes())?;
3044 self.permissions = persisted;
3045 Ok(added)
3046 }
3047}
3048
3049fn config_backup_path(path: &Path) -> PathBuf {
3050 let mut file_name = path
3051 .file_name()
3052 .map(std::ffi::OsString::from)
3053 .unwrap_or_else(|| std::ffi::OsString::from(CONFIG_FILE_NAME));
3054 file_name.push(".bak");
3055 path.with_file_name(file_name)
3056}
3057
3058fn write_one_time_config_backup(path: &Path) -> Result<()> {
3059 let backup = config_backup_path(path);
3060 if backup.exists() {
3061 return Ok(());
3062 }
3063 fs::copy(path, &backup).with_context(|| {
3064 format!(
3065 "failed to create config backup {} from {}",
3066 backup.display(),
3067 path.display()
3068 )
3069 })?;
3070 #[cfg(unix)]
3071 {
3072 fs::set_permissions(&backup, fs::Permissions::from_mode(0o600)).with_context(|| {
3073 format!(
3074 "failed to set config backup permissions at {}",
3075 backup.display()
3076 )
3077 })?;
3078 }
3079 Ok(())
3080}
3081
3082pub fn merge_and_preserve_comments(serialized: &str, original_raw: &str) -> Result<String> {
3090 let original = original_raw
3091 .parse::<toml_edit::DocumentMut>()
3092 .context("failed to parse original config for comment merge")?;
3093
3094 let mut new_doc = serialized
3095 .parse::<toml_edit::DocumentMut>()
3096 .context("failed to parse serialized config for comment merge")?;
3097
3098 new_doc.set_trailing(original.trailing().clone());
3101
3102 *new_doc.as_table_mut().decor_mut() = original.as_table().decor().clone();
3105
3106 merge_decor_table(new_doc.as_table_mut(), original.as_table());
3107
3108 Ok(new_doc.to_string())
3109}
3110
3111fn merge_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
3114 let keys: Vec<String> = source.iter().map(|(k, _)| k.to_owned()).collect();
3117 for key in &keys {
3118 let Some((source_key, source_item)) = source.get_key_value(key) else {
3119 continue;
3120 };
3121 let Some((mut target_key_mut, target_item)) = target.get_key_value_mut(key) else {
3122 continue;
3123 };
3124
3125 *target_key_mut.leaf_decor_mut() = source_key.leaf_decor().clone();
3127
3128 copy_item_decor(target_item, source_item);
3129
3130 if let (Some(tt), Some(st)) = (target_item.as_table_mut(), source_item.as_table()) {
3131 merge_decor_table(tt, st);
3132 }
3133
3134 if let (Some(ta), Some(sa)) = (
3135 target_item.as_array_of_tables_mut(),
3136 source_item.as_array_of_tables(),
3137 ) {
3138 for (i, source_table) in sa.iter().enumerate() {
3139 if let Some(target_table) = ta.get_mut(i) {
3140 copy_item_decor_table(target_table, source_table);
3141 merge_decor_table(target_table, source_table);
3142 }
3143 }
3144 }
3145 }
3146}
3147
3148fn copy_item_decor(target: &mut toml_edit::Item, source: &toml_edit::Item) {
3152 match (target, source) {
3153 (toml_edit::Item::Table(tt), toml_edit::Item::Table(st)) => {
3154 *tt.decor_mut() = st.decor().clone();
3155 }
3156 (toml_edit::Item::Value(tv), toml_edit::Item::Value(sv)) => {
3157 *tv.decor_mut() = sv.decor().clone();
3158 }
3159 _ => {}
3160 }
3161}
3162
3163fn copy_item_decor_table(target: &mut toml_edit::Table, source: &toml_edit::Table) {
3164 *target.decor_mut() = source.decor().clone();
3165}
3166
3167pub fn default_secrets() -> &'static Secrets {
3172 static SECRETS: OnceLock<Secrets> = OnceLock::new();
3173 SECRETS.get_or_init(|| {
3174 #[cfg(test)]
3179 {
3180 Secrets::new(std::sync::Arc::new(
3181 codewhale_secrets::InMemoryKeyringStore::new(),
3182 ))
3183 }
3184 #[cfg(not(test))]
3185 {
3186 Secrets::auto_detect()
3187 }
3188 })
3189}
3190
3191pub const CODEWHALE_APP_DIR: &str = ".codewhale";
3200
3201pub const LEGACY_APP_DIR: &str = ".deepseek";
3203
3204pub fn codewhale_home() -> Result<PathBuf> {
3209 if let Ok(val) = std::env::var("CODEWHALE_HOME") {
3210 let trimmed = val.trim();
3211 if !trimmed.is_empty() {
3212 return Ok(PathBuf::from(trimmed));
3213 }
3214 }
3215 let home = effective_home_dir().context("failed to resolve home directory")?;
3216 Ok(home.join(CODEWHALE_APP_DIR))
3217}
3218
3219pub fn legacy_deepseek_home() -> Result<PathBuf> {
3223 let home = effective_home_dir().context("failed to resolve home directory")?;
3224 Ok(home.join(LEGACY_APP_DIR))
3225}
3226
3227fn effective_home_dir() -> Option<PathBuf> {
3228 std::env::var_os("HOME")
3229 .filter(|value| !value.is_empty())
3230 .map(PathBuf::from)
3231 .or_else(dirs::home_dir)
3232}
3233
3234fn ensure_safe_state_subdir(subdir: &str) -> Result<()> {
3242 if subdir.is_empty() {
3243 bail!("state subdir must not be empty");
3244 }
3245 let path = std::path::Path::new(subdir);
3246 if path.is_absolute() {
3247 bail!("state subdir must not be an absolute path: {subdir}");
3248 }
3249 if path.components().any(|c| {
3250 matches!(
3251 c,
3252 std::path::Component::RootDir | std::path::Component::Prefix(_)
3253 )
3254 }) {
3255 bail!("state subdir must not contain a root or prefix: {subdir}");
3256 }
3257 if path
3258 .components()
3259 .any(|c| matches!(c, std::path::Component::ParentDir))
3260 {
3261 bail!("state subdir must not contain parent-dir (..) components: {subdir}");
3262 }
3263 Ok(())
3264}
3265
3266pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
3273 ensure_safe_state_subdir(subdir)?;
3274 let primary = codewhale_home()?.join(subdir);
3275 if primary.exists() {
3276 return Ok(primary);
3277 }
3278 let legacy = legacy_deepseek_home()?.join(subdir);
3279 if legacy.exists() {
3280 return Ok(legacy);
3281 }
3282 Ok(primary)
3284}
3285
3286pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
3296 ensure_safe_state_subdir(subdir)?;
3297 let dir = codewhale_home()?.join(subdir);
3298 migrate_legacy_state_dir(&dir, subdir)?;
3299 std::fs::create_dir_all(&dir)
3300 .with_context(|| format!("failed to create {}/", dir.display()))?;
3301 Ok(dir)
3302}
3303
3304fn migrate_legacy_state_dir(primary: &Path, subdir: &str) -> Result<()> {
3309 if primary.exists() || subdir == "." || subdir.is_empty() {
3310 return Ok(());
3311 }
3312 let legacy = match legacy_deepseek_home() {
3313 Ok(home) => home.join(subdir),
3314 Err(_) => return Ok(()),
3315 };
3316 if !legacy.exists() {
3317 return Ok(());
3318 }
3319 if let Some(parent) = primary.parent() {
3321 if let Err(err) = std::fs::create_dir_all(parent) {
3322 tracing::warn!(
3323 target: "config::migration",
3324 "Could not create {} for state migration ({}); writing to primary anyway",
3325 parent.display(),
3326 err
3327 );
3328 }
3329 }
3330 match std::fs::rename(&legacy, primary) {
3331 Ok(()) => {
3332 tracing::info!(
3333 target: "config::migration",
3334 "Migrated legacy state directory {} -> {} (relocated). The .deepseek copy was removed.",
3335 legacy.display(),
3336 primary.display()
3337 );
3338 }
3339 Err(err) => {
3340 match copy_dir_recursive(&legacy, primary) {
3345 Ok(()) => {
3346 tracing::info!(
3347 target: "config::migration",
3348 "Migrated legacy state directory {} -> {} (copied; rename failed: {err}). \
3349 The legacy .deepseek copy was left in place.",
3350 legacy.display(),
3351 primary.display()
3352 );
3353 }
3354 Err(copy_err) => {
3355 tracing::warn!(
3356 target: "config::migration",
3357 "Could not migrate legacy state {} -> {} (rename: {err}; copy: {copy_err}). \
3358 New data is written to the primary path; the legacy tree remains untouched.",
3359 legacy.display(),
3360 primary.display()
3361 );
3362 }
3363 }
3364 }
3365 }
3366 Ok(())
3367}
3368
3369fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
3372 std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
3373 for entry in
3374 std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
3375 {
3376 let entry = entry.with_context(|| format!("failed to read entry in {}", src.display()))?;
3377 let path = entry.path();
3378 let target = dst.join(entry.file_name());
3379 let file_type = entry
3380 .file_type()
3381 .with_context(|| format!("failed to read file type for {}", path.display()))?;
3382 if file_type.is_dir() {
3383 copy_dir_recursive(&path, &target)?;
3384 } else if file_type.is_file() {
3385 std::fs::copy(&path, &target).with_context(|| {
3386 format!("failed to copy {} -> {}", path.display(), target.display())
3387 })?;
3388 }
3389 }
3390 Ok(())
3391}
3392
3393pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> (bool, PathBuf) {
3400 let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
3401 if primary.exists() {
3402 return (true, primary);
3403 }
3404 let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
3405 (false, legacy)
3406}
3407
3408pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
3411 let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
3412 std::fs::create_dir_all(&dir)
3413 .with_context(|| format!("failed to create {}/", dir.display()))?;
3414 Ok(dir)
3415}
3416
3417pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
3418 let path = if let Some(path) = explicit {
3419 path
3420 } else if let Ok(path) = std::env::var("CODEWHALE_CONFIG_PATH") {
3421 let trimmed = path.trim();
3422 if !trimmed.is_empty() {
3423 PathBuf::from(trimmed)
3424 } else {
3425 return default_config_path();
3426 }
3427 } else if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
3428 let trimmed = path.trim();
3429 if !trimmed.is_empty() {
3430 PathBuf::from(trimmed)
3431 } else {
3432 return default_config_path();
3433 }
3434 } else {
3435 return default_config_path();
3436 };
3437 normalize_config_file_path(path)
3438}
3439
3440#[must_use]
3441pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
3442 config_path.with_file_name(PERMISSIONS_FILE_NAME)
3443}
3444
3445pub fn resolve_permissions_path(config_path: Option<PathBuf>) -> Result<PathBuf> {
3446 Ok(permissions_path_for_config_path(&resolve_config_path(
3447 config_path,
3448 )?))
3449}
3450
3451fn load_sibling_permissions(config_path: &Path) -> Result<PermissionsToml> {
3452 let permissions_path = permissions_path_for_config_path(config_path);
3453 if !permissions_path.exists() {
3454 return Ok(PermissionsToml::default());
3455 }
3456
3457 let raw = fs::read_to_string(&permissions_path).with_context(|| {
3458 format!(
3459 "failed to read permissions at {}",
3460 permissions_path.display()
3461 )
3462 })?;
3463 toml::from_str(&raw).with_context(|| {
3464 format!(
3465 "failed to parse permissions at {}",
3466 permissions_path.display()
3467 )
3468 })
3469}
3470
3471fn append_ask_rule(item: &mut toml_edit::Item, rule: &ToolAskRule) -> Result<()> {
3472 match item {
3473 toml_edit::Item::ArrayOfTables(rules) => {
3474 rules.push(ask_rule_table(rule));
3475 Ok(())
3476 }
3477 toml_edit::Item::Value(value) => {
3478 let Some(rules) = value.as_array_mut() else {
3479 bail!("`rules` in permissions.toml must be an array");
3480 };
3481 rules.push(toml_edit::Value::InlineTable(ask_rule_inline_table(rule)));
3482 Ok(())
3483 }
3484 _ => bail!("`rules` in permissions.toml must be an array"),
3485 }
3486}
3487
3488fn ask_rule_table(rule: &ToolAskRule) -> toml_edit::Table {
3489 let mut table = toml_edit::Table::new();
3490 table["tool"] = toml_edit::value(rule.tool.clone());
3491 if let Some(command) = rule.command.as_deref() {
3492 table["command"] = toml_edit::value(command);
3493 }
3494 if let Some(path) = rule.path.as_deref() {
3495 table["path"] = toml_edit::value(path);
3496 }
3497 table
3498}
3499
3500fn ask_rule_inline_table(rule: &ToolAskRule) -> toml_edit::InlineTable {
3501 let mut table = toml_edit::InlineTable::new();
3502 table.insert("tool", toml_edit::Value::from(rule.tool.clone()));
3503 if let Some(command) = rule.command.as_deref() {
3504 table.insert("command", toml_edit::Value::from(command));
3505 }
3506 if let Some(path) = rule.path.as_deref() {
3507 table.insert("path", toml_edit::Value::from(path));
3508 }
3509 table
3510}
3511
3512fn write_permissions_atomic(path: &Path, body: &[u8]) -> Result<()> {
3513 let parent = path.parent().with_context(|| {
3514 format!(
3515 "permissions path has no parent directory: {}",
3516 path.display()
3517 )
3518 })?;
3519 fs::create_dir_all(parent).with_context(|| {
3520 format!(
3521 "failed to create permissions directory {}",
3522 parent.display()
3523 )
3524 })?;
3525
3526 let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
3527 format!(
3528 "failed to create temporary permissions file in {}",
3529 parent.display()
3530 )
3531 })?;
3532 #[cfg(unix)]
3533 temporary
3534 .as_file()
3535 .set_permissions(fs::Permissions::from_mode(0o600))
3536 .with_context(|| {
3537 format!(
3538 "failed to secure temporary permissions file for {}",
3539 path.display()
3540 )
3541 })?;
3542 temporary
3543 .write_all(body)
3544 .with_context(|| format!("failed to write permissions at {}", path.display()))?;
3545 temporary
3546 .as_file()
3547 .sync_all()
3548 .with_context(|| format!("failed to sync permissions at {}", path.display()))?;
3549 temporary
3550 .persist(path)
3551 .map_err(|error| error.error)
3552 .with_context(|| format!("failed to replace permissions at {}", path.display()))?;
3553 Ok(())
3554}
3555
3556pub fn default_config_path() -> Result<PathBuf> {
3557 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
3560 if primary.exists() {
3561 return Ok(primary);
3562 }
3563 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
3564 if legacy.exists() {
3565 return Ok(legacy);
3566 }
3567 Ok(primary)
3569}
3570
3571#[derive(Debug, Clone, PartialEq, Eq)]
3572pub struct ConfigMigration {
3573 pub legacy_path: PathBuf,
3574 pub primary_path: PathBuf,
3575}
3576
3577impl ConfigMigration {
3578 pub fn user_notice(&self) -> String {
3579 format!(
3580 "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.",
3581 self.legacy_path.display(),
3582 self.primary_path.display()
3583 )
3584 }
3585}
3586
3587pub fn migrate_config_if_needed() -> Result<Option<ConfigMigration>> {
3592 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
3593 if primary.exists() {
3594 return Ok(None);
3595 }
3596 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
3597 if !legacy.exists() {
3598 return Ok(None);
3599 }
3600 if let Some(parent) = primary.parent() {
3602 std::fs::create_dir_all(parent).context("failed to create codewhale config directory")?;
3603 }
3604 std::fs::copy(&legacy, &primary)
3605 .context("failed to migrate config from deepseek to codewhale home")?;
3606 tracing::info!(
3607 "Migrated config from {} to {}",
3608 legacy.display(),
3609 primary.display()
3610 );
3611 Ok(Some(ConfigMigration {
3612 legacy_path: legacy,
3613 primary_path: primary,
3614 }))
3615}
3616
3617fn parse_bool(raw: &str) -> Result<bool> {
3618 match raw.trim().to_ascii_lowercase().as_str() {
3619 "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
3620 "0" | "false" | "no" | "off" | "disabled" => Ok(false),
3621 _ => bail!("invalid boolean '{raw}'"),
3622 }
3623}
3624
3625fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
3626 let mut headers = BTreeMap::new();
3627 for pair in raw.trim().split(',') {
3628 let pair = pair.trim();
3629 if pair.is_empty() {
3630 continue;
3631 }
3632 let Some((name, value)) = pair.split_once('=') else {
3633 bail!("invalid header pair '{pair}', expected name=value");
3634 };
3635 let name = name.trim();
3636 let value = value.trim();
3637 if name.is_empty() {
3638 bail!("header name cannot be empty");
3639 }
3640 if value.is_empty() {
3641 continue;
3642 }
3643 headers.insert(name.to_string(), value.to_string());
3644 }
3645 Ok(headers)
3646}
3647
3648fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
3649 if headers.is_empty() {
3650 return None;
3651 }
3652 Some(
3653 headers
3654 .iter()
3655 .map(|(name, value)| format!("{name}={value}"))
3656 .collect::<Vec<_>>()
3657 .join(","),
3658 )
3659}
3660
3661fn serialize_http_headers_for_display(headers: &BTreeMap<String, String>) -> Option<String> {
3662 if headers.is_empty() {
3663 return None;
3664 }
3665 Some(
3666 headers
3667 .iter()
3668 .map(|(name, value)| {
3669 let display_value = if is_sensitive_config_key(name) {
3670 redact_secret(value)
3671 } else {
3672 value.clone()
3673 };
3674 format!("{name}={display_value}")
3675 })
3676 .collect::<Vec<_>>()
3677 .join(","),
3678 )
3679}
3680
3681fn redact_secret(secret: &str) -> String {
3682 let chars: Vec<char> = secret.chars().collect();
3683 if chars.len() <= 16 {
3684 return "********".to_string();
3685 }
3686 let prefix: String = chars.iter().take(4).collect();
3687 let suffix: String = chars
3688 .iter()
3689 .rev()
3690 .take(4)
3691 .collect::<Vec<_>>()
3692 .into_iter()
3693 .rev()
3694 .collect();
3695 format!("{prefix}***{suffix}")
3696}
3697
3698#[must_use]
3699pub fn is_sensitive_config_key(key: &str) -> bool {
3700 let Some(segment) = key.rsplit('.').next() else {
3701 return false;
3702 };
3703 let normalized = segment
3704 .trim()
3705 .trim_matches('"')
3706 .replace('-', "_")
3707 .to_ascii_lowercase();
3708
3709 matches!(
3710 normalized.as_str(),
3711 "api_key"
3712 | "apikey"
3713 | "api_keys"
3714 | "authorization"
3715 | "bearer"
3716 | "client_secret"
3717 | "credential"
3718 | "credentials"
3719 | "id_token"
3720 | "password"
3721 | "passwords"
3722 | "passwd"
3723 | "proxy_authorization"
3724 | "refresh_token"
3725 | "secret"
3726 | "secrets"
3727 | "token"
3728 | "tokens"
3729 ) || normalized.ends_with("_api_key")
3730 || normalized.ends_with("_authorization")
3731 || normalized.ends_with("_password")
3732 || normalized.ends_with("_secret")
3733 || normalized.ends_with("_token")
3734}
3735
3736fn redact_toml_value_for_display(key: &str, value: &toml::Value) -> String {
3737 redact_toml_value_for_display_inner(key, false, value).to_string()
3738}
3739
3740fn redact_toml_value_for_display_inner(
3741 key: &str,
3742 sensitive_ancestor: bool,
3743 value: &toml::Value,
3744) -> toml::Value {
3745 let sensitive = sensitive_ancestor || is_sensitive_config_key(key);
3746 match value {
3747 toml::Value::String(value) if sensitive => toml::Value::String(redact_secret(value)),
3748 toml::Value::Array(values) => toml::Value::Array(
3749 values
3750 .iter()
3751 .map(|value| redact_toml_value_for_display_inner(key, sensitive, value))
3752 .collect(),
3753 ),
3754 toml::Value::Table(table) => {
3755 let mut redacted = toml::map::Map::new();
3756 for (child_key, child_value) in table {
3757 let path = if key.is_empty() {
3758 child_key.clone()
3759 } else {
3760 format!("{key}.{child_key}")
3761 };
3762 redacted.insert(
3763 child_key.clone(),
3764 redact_toml_value_for_display_inner(&path, sensitive, child_value),
3765 );
3766 }
3767 toml::Value::Table(redacted)
3768 }
3769 _ if sensitive => toml::Value::String("********".to_string()),
3770 _ => value.clone(),
3771 }
3772}
3773
3774fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
3775 if path.as_os_str().is_empty() {
3776 bail!("config path cannot be empty");
3777 }
3778 if path
3779 .components()
3780 .any(|component| matches!(component, Component::ParentDir))
3781 {
3782 bail!("config path cannot contain '..' components");
3783 }
3784 if path.file_name().is_none() {
3785 bail!("config path must include a file name");
3786 }
3787 if path.is_absolute() {
3788 return Ok(path);
3789 }
3790 Ok(std::env::current_dir()
3791 .context("failed to resolve current directory for config path")?
3792 .join(path))
3793}
3794
3795#[derive(Debug, Clone, Default)]
3796struct EnvRuntimeOverrides {
3797 provider: Option<ProviderKind>,
3798 provider_source: Option<&'static str>,
3799 model: Option<String>,
3800 volcengine_model: Option<String>,
3801 wanjie_ark_model: Option<String>,
3802 openrouter_model: Option<String>,
3803 moonshot_model: Option<String>,
3804 xiaomi_mimo_model: Option<String>,
3805 xiaomi_mimo_mode: Option<String>,
3806 novita_model: Option<String>,
3807 fireworks_model: Option<String>,
3808 arcee_model: Option<String>,
3809 output_mode: Option<String>,
3810 auth_mode: Option<String>,
3811 log_level: Option<String>,
3812 telemetry: Option<bool>,
3813 approval_policy: Option<String>,
3814 sandbox_mode: Option<String>,
3815 yolo: Option<bool>,
3816 verbosity: Option<String>,
3817 http_headers: Option<BTreeMap<String, String>>,
3818 deepseek_base_url: Option<String>,
3819 nvidia_base_url: Option<String>,
3820 openai_base_url: Option<String>,
3821 atlascloud_base_url: Option<String>,
3822 volcengine_base_url: Option<String>,
3823 wanjie_ark_base_url: Option<String>,
3824 openrouter_base_url: Option<String>,
3825 xiaomi_mimo_base_url: Option<String>,
3826 novita_base_url: Option<String>,
3827 fireworks_base_url: Option<String>,
3828 siliconflow_base_url: Option<String>,
3829 siliconflow_model: Option<String>,
3830 arcee_base_url: Option<String>,
3831 moonshot_base_url: Option<String>,
3832 sglang_base_url: Option<String>,
3833 vllm_base_url: Option<String>,
3834 ollama_base_url: Option<String>,
3835 huggingface_base_url: Option<String>,
3836 huggingface_model: Option<String>,
3837 together_base_url: Option<String>,
3838 together_model: Option<String>,
3839 openai_codex_base_url: Option<String>,
3840 openai_codex_model: Option<String>,
3841 anthropic_base_url: Option<String>,
3842 anthropic_model: Option<String>,
3843 zai_base_url: Option<String>,
3844 zai_model: Option<String>,
3845 stepfun_base_url: Option<String>,
3846 stepfun_model: Option<String>,
3847 minimax_base_url: Option<String>,
3848 minimax_model: Option<String>,
3849 deepinfra_base_url: Option<String>,
3850 deepinfra_model: Option<String>,
3851}
3852
3853impl EnvRuntimeOverrides {
3854 fn load() -> Self {
3855 let (provider, provider_source) = Self::load_provider();
3856 Self {
3857 provider,
3858 provider_source,
3859 model: std::env::var("CODEWHALE_MODEL")
3860 .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
3861 .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
3862 .ok()
3863 .filter(|v| !v.trim().is_empty()),
3864 volcengine_model: std::env::var("VOLCENGINE_MODEL")
3865 .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
3866 .ok()
3867 .filter(|v| !v.trim().is_empty()),
3868 wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
3869 .or_else(|_| std::env::var("WANJIE_MODEL"))
3870 .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
3871 .ok()
3872 .filter(|v| !v.trim().is_empty()),
3873 openrouter_model: std::env::var("OPENROUTER_MODEL")
3874 .ok()
3875 .filter(|v| !v.trim().is_empty()),
3876 moonshot_model: std::env::var("MOONSHOT_MODEL")
3877 .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
3878 .or_else(|_| std::env::var("KIMI_MODEL"))
3879 .ok()
3880 .filter(|v| !v.trim().is_empty()),
3881 xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
3882 .or_else(|_| std::env::var("MIMO_MODEL"))
3883 .ok()
3884 .filter(|v| !v.trim().is_empty()),
3885 xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
3886 .or_else(|_| std::env::var("MIMO_MODE"))
3887 .ok()
3888 .filter(|v| !v.trim().is_empty()),
3889 novita_model: std::env::var("NOVITA_MODEL")
3890 .ok()
3891 .filter(|v| !v.trim().is_empty()),
3892 fireworks_model: std::env::var("FIREWORKS_MODEL")
3893 .ok()
3894 .filter(|v| !v.trim().is_empty()),
3895 arcee_model: std::env::var("ARCEE_MODEL")
3896 .ok()
3897 .filter(|v| !v.trim().is_empty()),
3898 verbosity: std::env::var("CODEWHALE_VERBOSITY")
3899 .or_else(|_| std::env::var("DEEPSEEK_VERBOSITY"))
3900 .ok(),
3901 output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(),
3902 auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(),
3903 log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(),
3904 telemetry: std::env::var("DEEPSEEK_TELEMETRY")
3905 .ok()
3906 .and_then(|v| match parse_bool(&v) {
3907 Ok(b) => Some(b),
3908 Err(_) => {
3909 tracing::warn!("Invalid DEEPSEEK_TELEMETRY value '{v}', expected true/false");
3910 None
3911 }
3912 }),
3913 approval_policy: std::env::var("DEEPSEEK_APPROVAL_POLICY").ok(),
3914 sandbox_mode: std::env::var("DEEPSEEK_SANDBOX_MODE").ok(),
3915 yolo: std::env::var("DEEPSEEK_YOLO")
3916 .ok()
3917 .and_then(|v| match parse_bool(&v) {
3918 Ok(b) => Some(b),
3919 Err(_) => {
3920 tracing::warn!("Invalid DEEPSEEK_YOLO value '{v}', expected true/false");
3921 None
3922 }
3923 }),
3924 http_headers: std::env::var("DEEPSEEK_HTTP_HEADERS")
3925 .ok()
3926 .and_then(|value| match parse_http_headers(&value) {
3927 Ok(h) => Some(h),
3928 Err(_) => {
3929 tracing::warn!("Invalid DEEPSEEK_HTTP_HEADERS value, expected format: header1=val1,header2=val2");
3930 None
3931 }
3932 })
3933 .filter(|headers| !headers.is_empty()),
3934 deepseek_base_url: std::env::var("CODEWHALE_BASE_URL")
3935 .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
3936 .ok()
3937 .filter(|v| !v.trim().is_empty()),
3938 nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
3939 .or_else(|_| std::env::var("NIM_BASE_URL"))
3940 .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
3941 .ok()
3942 .filter(|v| !v.trim().is_empty()),
3943 openai_base_url: std::env::var("OPENAI_BASE_URL")
3944 .ok()
3945 .filter(|v| !v.trim().is_empty()),
3946 atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
3947 .ok()
3948 .filter(|v| !v.trim().is_empty()),
3949 volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
3950 .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
3951 .or_else(|_| std::env::var("ARK_BASE_URL"))
3952 .ok()
3953 .filter(|v| !v.trim().is_empty()),
3954 wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
3955 .or_else(|_| std::env::var("WANJIE_BASE_URL"))
3956 .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
3957 .ok()
3958 .filter(|v| !v.trim().is_empty()),
3959 openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
3960 .ok()
3961 .filter(|v| !v.trim().is_empty()),
3962 xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
3963 .or_else(|_| std::env::var("MIMO_BASE_URL"))
3964 .ok()
3965 .filter(|v| !v.trim().is_empty()),
3966 novita_base_url: std::env::var("NOVITA_BASE_URL")
3967 .ok()
3968 .filter(|v| !v.trim().is_empty()),
3969 fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
3970 .ok()
3971 .filter(|v| !v.trim().is_empty()),
3972 siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
3973 .ok()
3974 .filter(|v| !v.trim().is_empty()),
3975 siliconflow_model: std::env::var("SILICONFLOW_MODEL")
3976 .ok()
3977 .filter(|v| !v.trim().is_empty()),
3978 arcee_base_url: std::env::var("ARCEE_BASE_URL")
3979 .ok()
3980 .filter(|v| !v.trim().is_empty()),
3981 moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
3982 .or_else(|_| std::env::var("KIMI_BASE_URL"))
3983 .ok()
3984 .filter(|v| !v.trim().is_empty()),
3985 sglang_base_url: std::env::var("SGLANG_BASE_URL")
3986 .ok()
3987 .filter(|v| !v.trim().is_empty()),
3988 vllm_base_url: std::env::var("VLLM_BASE_URL")
3989 .ok()
3990 .filter(|v| !v.trim().is_empty()),
3991 ollama_base_url: std::env::var("OLLAMA_BASE_URL")
3992 .ok()
3993 .filter(|v| !v.trim().is_empty()),
3994 huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
3995 .or_else(|_| std::env::var("HF_BASE_URL"))
3996 .ok()
3997 .filter(|v| !v.trim().is_empty()),
3998 huggingface_model: std::env::var("HUGGINGFACE_MODEL")
3999 .or_else(|_| std::env::var("HF_MODEL"))
4000 .ok()
4001 .filter(|v| !v.trim().is_empty()),
4002 together_base_url: std::env::var("TOGETHER_BASE_URL")
4003 .ok()
4004 .filter(|v| !v.trim().is_empty()),
4005 together_model: std::env::var("TOGETHER_MODEL")
4006 .ok()
4007 .filter(|v| !v.trim().is_empty()),
4008 openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
4009 .or_else(|_| std::env::var("CODEX_BASE_URL"))
4010 .ok()
4011 .filter(|v| !v.trim().is_empty()),
4012 openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
4013 .or_else(|_| std::env::var("CODEX_MODEL"))
4014 .ok()
4015 .filter(|v| !v.trim().is_empty()),
4016 anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
4017 .ok()
4018 .filter(|v| !v.trim().is_empty()),
4019 anthropic_model: std::env::var("ANTHROPIC_MODEL")
4020 .ok()
4021 .filter(|v| !v.trim().is_empty()),
4022 zai_base_url: std::env::var("ZAI_BASE_URL")
4023 .or_else(|_| std::env::var("Z_AI_BASE_URL"))
4024 .ok()
4025 .filter(|v| !v.trim().is_empty()),
4026 zai_model: std::env::var("ZAI_MODEL")
4027 .or_else(|_| std::env::var("Z_AI_MODEL"))
4028 .ok()
4029 .filter(|v| !v.trim().is_empty()),
4030 stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
4031 .or_else(|_| std::env::var("STEP_BASE_URL"))
4032 .ok()
4033 .filter(|v| !v.trim().is_empty()),
4034 stepfun_model: std::env::var("STEPFUN_MODEL")
4035 .or_else(|_| std::env::var("STEP_MODEL"))
4036 .ok()
4037 .filter(|v| !v.trim().is_empty()),
4038 minimax_base_url: std::env::var("MINIMAX_BASE_URL")
4039 .ok()
4040 .filter(|v| !v.trim().is_empty()),
4041 minimax_model: std::env::var("MINIMAX_MODEL")
4042 .ok()
4043 .filter(|v| !v.trim().is_empty()),
4044 deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
4045 .ok()
4046 .filter(|v| !v.trim().is_empty()),
4047 deepinfra_model: std::env::var("DEEPINFRA_MODEL")
4048 .ok()
4049 .filter(|v| !v.trim().is_empty()),
4050 }
4051 }
4052
4053 fn load_provider() -> (Option<ProviderKind>, Option<&'static str>) {
4054 if let Ok(value) = std::env::var("CODEWHALE_PROVIDER") {
4055 let parsed = ProviderKind::parse(&value);
4056 return (parsed, parsed.map(|_| "CODEWHALE_PROVIDER"));
4057 }
4058
4059 if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
4060 let parsed = ProviderKind::parse(&value);
4061 return (parsed, parsed.map(|_| "DEEPSEEK_PROVIDER"));
4062 }
4063
4064 (None, None)
4065 }
4066
4067 fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
4068 match provider {
4071 ProviderKind::Deepseek => self.deepseek_base_url.clone(),
4072 ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
4073 ProviderKind::Openai => self.openai_base_url.clone(),
4074 ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
4075 ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
4076 ProviderKind::Volcengine => self.volcengine_base_url.clone(),
4077 ProviderKind::Openrouter => self.openrouter_base_url.clone(),
4078 ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
4079 ProviderKind::Novita => self.novita_base_url.clone(),
4080 ProviderKind::Fireworks => self.fireworks_base_url.clone(),
4081 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
4082 self.siliconflow_base_url.clone()
4083 }
4084 ProviderKind::Arcee => self.arcee_base_url.clone(),
4085 ProviderKind::Moonshot => self.moonshot_base_url.clone(),
4086 ProviderKind::Sglang => self.sglang_base_url.clone(),
4087 ProviderKind::Vllm => self.vllm_base_url.clone(),
4088 ProviderKind::Ollama => self.ollama_base_url.clone(),
4089 ProviderKind::Huggingface => self.huggingface_base_url.clone(),
4090 ProviderKind::Together => self.together_base_url.clone(),
4091 ProviderKind::OpenaiCodex => self.openai_codex_base_url.clone(),
4092 ProviderKind::Anthropic => self.anthropic_base_url.clone(),
4093 ProviderKind::Zai => self.zai_base_url.clone(),
4094 ProviderKind::Stepfun => self.stepfun_base_url.clone(),
4095 ProviderKind::Minimax => self.minimax_base_url.clone(),
4096 ProviderKind::Deepinfra => self.deepinfra_base_url.clone(),
4097 }
4098 }
4099
4100 fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
4101 let model = match provider {
4102 ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
4103 ProviderKind::Volcengine => self.volcengine_model.clone(),
4104 ProviderKind::Openrouter => self.openrouter_model.clone(),
4105 ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => {
4106 self.siliconflow_model.clone()
4107 }
4108 ProviderKind::Arcee => self.arcee_model.clone(),
4109 ProviderKind::Moonshot => self.moonshot_model.clone(),
4110 ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
4111 ProviderKind::Novita => self.novita_model.clone(),
4112 ProviderKind::Fireworks => self.fireworks_model.clone(),
4113 ProviderKind::Huggingface => self.huggingface_model.clone(),
4114 ProviderKind::Together => self.together_model.clone(),
4115 ProviderKind::OpenaiCodex => self.openai_codex_model.clone(),
4116 ProviderKind::Anthropic => self.anthropic_model.clone(),
4117 ProviderKind::Zai => self.zai_model.clone(),
4118 ProviderKind::Stepfun => self.stepfun_model.clone(),
4119 ProviderKind::Minimax => self.minimax_model.clone(),
4120 ProviderKind::Deepinfra => self.deepinfra_model.clone(),
4121 _ => None,
4122 }?;
4123
4124 if provider_preserves_custom_base_url_model(provider, base_url) {
4125 Some(model.trim().to_string())
4126 } else {
4127 Some(normalize_model_for_provider(provider, &model))
4128 }
4129 }
4130}
4131
4132#[cfg(test)]
4133mod tests {
4134 use super::*;
4135 use std::env;
4136 use std::ffi::OsString;
4137 use std::sync::Arc;
4138 use std::sync::{Mutex, OnceLock};
4139
4140 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
4141 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
4142 LOCK.get_or_init(|| Mutex::new(()))
4143 .lock()
4144 .unwrap_or_else(std::sync::PoisonError::into_inner)
4145 }
4146
4147 #[test]
4148 fn network_policy_toml_deserializes_proxy_hosts() {
4149 let policy: NetworkPolicyToml = toml::from_str(
4150 r#"
4151 default = "allow"
4152 proxy = ["github.com", ".githubusercontent.com"]
4153 "#,
4154 )
4155 .expect("network policy toml");
4156
4157 assert_eq!(policy.default, "allow");
4158 assert_eq!(policy.proxy, ["github.com", ".githubusercontent.com"]);
4159 assert!(policy.audit);
4160 }
4161
4162 #[test]
4163 fn permissions_toml_deserializes_typed_ask_rules() {
4164 let permissions: PermissionsToml = toml::from_str(
4165 r#"
4166 [[rules]]
4167 tool = "exec_shell"
4168 command = "cargo test"
4169
4170 [[rules]]
4171 tool = "read_file"
4172 path = "secrets/api_key.txt"
4173 "#,
4174 )
4175 .expect("permissions toml");
4176
4177 assert_eq!(
4178 permissions.rules,
4179 vec![
4180 ToolAskRule::exec_shell("cargo test"),
4181 ToolAskRule::file_path("read_file", "secrets/api_key.txt"),
4182 ]
4183 );
4184 }
4185
4186 #[test]
4187 fn permissions_toml_rejects_typed_allow_deny_shape() {
4188 let err = toml::from_str::<PermissionsToml>(
4189 r#"
4190 [[rules]]
4191 tool = "exec_shell"
4192 decision = "allow"
4193 command = "cargo test"
4194 "#,
4195 )
4196 .expect_err("permissions.toml should be ask-only in this slice");
4197
4198 assert!(err.message().contains("unknown field"));
4199 }
4200
4201 #[test]
4202 fn hotbar_defaults_when_config_is_absent() {
4203 let config = ConfigToml::default();
4204
4205 let resolved = config.resolve_hotbar_bindings(&DEFAULT_HOTBAR_ACTIONS);
4206
4207 assert_eq!(resolved.warnings, Vec::new());
4208 assert_eq!(resolved.bindings, default_hotbar_bindings());
4209 assert_eq!(
4210 resolved
4211 .bindings
4212 .iter()
4213 .map(|binding| (binding.slot, binding.action.as_str()))
4214 .collect::<Vec<_>>(),
4215 vec![
4216 (1, "voice.toggle"),
4217 (2, "session.compact"),
4218 (3, "mode.plan"),
4219 (4, "mode.agent"),
4220 (5, "mode.yolo"),
4221 (6, "palette.open"),
4222 (7, "sidebar.toggle"),
4223 (8, "trust.toggle"),
4224 ]
4225 );
4226 }
4227
4228 #[test]
4229 fn hotbar_tables_parse_and_round_trip() {
4230 let config: ConfigToml = toml::from_str(
4231 r#"
4232[[hotbar]]
4233slot = 1
4234label = "Plan"
4235action = "mode.plan"
4236
4237[[hotbar]]
4238slot = 2
4239action = "session.compact"
4240"#,
4241 )
4242 .expect("parse hotbar tables");
4243
4244 let resolved = config.resolve_hotbar_bindings(&["mode.plan", "session.compact"]);
4245
4246 assert_eq!(
4247 resolved.bindings,
4248 vec![
4249 HotbarBinding {
4250 slot: 1,
4251 action: "mode.plan".to_string(),
4252 label: Some("Plan".to_string()),
4253 },
4254 HotbarBinding {
4255 slot: 2,
4256 action: "session.compact".to_string(),
4257 label: None,
4258 },
4259 ]
4260 );
4261 assert_eq!(resolved.warnings, Vec::new());
4262
4263 let serialized = toml::to_string_pretty(&config).expect("serialize config");
4264 let round_tripped: ConfigToml =
4265 toml::from_str(&serialized).expect("deserialize serialized config");
4266 assert_eq!(round_tripped.hotbar, config.hotbar);
4267 }
4268
4269 #[test]
4270 fn hotbar_validation_warns_without_dropping_unknown_actions() {
4271 let config: ConfigToml = toml::from_str(
4272 r#"
4273[[hotbar]]
4274slot = 0
4275action = "mode.plan"
4276
4277[[hotbar]]
4278slot = 2
4279action = "mode.plan"
4280
4281[[hotbar]]
4282slot = 2
4283action = "custom.action"
4284
4285[[hotbar]]
4286slot = 9
4287action = "mode.agent"
4288"#,
4289 )
4290 .expect("parse hotbar tables");
4291
4292 let resolved = config.resolve_hotbar_bindings(&["mode.plan", "mode.agent"]);
4293
4294 assert_eq!(
4295 resolved.bindings,
4296 vec![HotbarBinding {
4297 slot: 2,
4298 action: "custom.action".to_string(),
4299 label: None,
4300 }]
4301 );
4302 assert_eq!(
4303 resolved.warnings,
4304 vec![
4305 HotbarConfigWarning::SlotOutOfRange {
4306 slot: 0,
4307 action: "mode.plan".to_string(),
4308 },
4309 HotbarConfigWarning::UnknownAction {
4310 slot: 2,
4311 action: "custom.action".to_string(),
4312 },
4313 HotbarConfigWarning::DuplicateSlot {
4314 slot: 2,
4315 previous_action: "mode.plan".to_string(),
4316 replacement_action: "custom.action".to_string(),
4317 },
4318 HotbarConfigWarning::SlotOutOfRange {
4319 slot: 9,
4320 action: "mode.agent".to_string(),
4321 },
4322 ]
4323 );
4324 assert!(resolved.warnings[1].to_string().contains("keeping binding"));
4325 }
4326
4327 #[test]
4328 fn config_store_loads_sibling_permissions_toml() {
4329 use std::time::{SystemTime, UNIX_EPOCH};
4330
4331 let unique = SystemTime::now()
4332 .duration_since(UNIX_EPOCH)
4333 .expect("clock")
4334 .as_nanos();
4335 let dir = std::env::temp_dir().join(format!(
4336 "codewhale-permissions-schema-{}-{unique}",
4337 std::process::id()
4338 ));
4339 fs::create_dir_all(&dir).expect("mkdir");
4340 let config_path = dir.join(CONFIG_FILE_NAME);
4341 fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
4342 fs::write(
4343 dir.join(PERMISSIONS_FILE_NAME),
4344 r#"
4345 [[rules]]
4346 tool = "exec_shell"
4347 command = "cargo test"
4348
4349 [[rules]]
4350 tool = "read_file"
4351 path = "secrets/api_key.txt"
4352 "#,
4353 )
4354 .expect("write permissions");
4355
4356 let store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
4357
4358 assert_eq!(store.config.model.as_deref(), Some("deepseek-v4-flash"));
4359 assert_eq!(
4360 store.permissions().rules.as_slice(),
4361 &[
4362 ToolAskRule::exec_shell("cargo test"),
4363 ToolAskRule::file_path("read_file", "secrets/api_key.txt"),
4364 ]
4365 );
4366 assert_eq!(
4367 store.permissions_path(),
4368 config_path.with_file_name(PERMISSIONS_FILE_NAME)
4369 );
4370
4371 let _ = fs::remove_dir_all(dir);
4372 }
4373
4374 #[test]
4375 fn config_store_loads_permissions_even_when_config_is_absent() {
4376 use std::time::{SystemTime, UNIX_EPOCH};
4377
4378 let unique = SystemTime::now()
4379 .duration_since(UNIX_EPOCH)
4380 .expect("clock")
4381 .as_nanos();
4382 let dir = std::env::temp_dir().join(format!(
4383 "codewhale-permissions-only-{}-{unique}",
4384 std::process::id()
4385 ));
4386 fs::create_dir_all(&dir).expect("mkdir");
4387 let config_path = dir.join(CONFIG_FILE_NAME);
4388 fs::write(
4389 dir.join(PERMISSIONS_FILE_NAME),
4390 r#"
4391 [[rules]]
4392 tool = "exec_shell"
4393 command = "cargo check"
4394 "#,
4395 )
4396 .expect("write permissions");
4397
4398 let store = ConfigStore::load(Some(config_path)).expect("load config store");
4399
4400 assert!(store.config.model.is_none());
4401 assert_eq!(
4402 store.permissions().rules.as_slice(),
4403 &[ToolAskRule::exec_shell("cargo check")]
4404 );
4405
4406 let _ = fs::remove_dir_all(dir);
4407 }
4408
4409 #[test]
4410 fn config_store_exec_policy_engine_uses_sibling_permissions() {
4411 use std::time::{SystemTime, UNIX_EPOCH};
4412
4413 let unique = SystemTime::now()
4414 .duration_since(UNIX_EPOCH)
4415 .expect("clock")
4416 .as_nanos();
4417 let dir = std::env::temp_dir().join(format!(
4418 "codewhale-permissions-engine-{}-{unique}",
4419 std::process::id()
4420 ));
4421 fs::create_dir_all(&dir).expect("mkdir");
4422 let config_path = dir.join(CONFIG_FILE_NAME);
4423 fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
4424 fs::write(
4425 dir.join(PERMISSIONS_FILE_NAME),
4426 r#"
4427 [[rules]]
4428 tool = "exec_shell"
4429 command = "cargo test"
4430 "#,
4431 )
4432 .expect("write permissions");
4433
4434 let store = ConfigStore::load(Some(config_path)).expect("load config store");
4435 let decision = store
4436 .exec_policy_engine()
4437 .check(codewhale_execpolicy::ExecPolicyContext {
4438 command: "cargo test --workspace",
4439 cwd: "/workspace",
4440 tool: Some("exec_shell"),
4441 path: None,
4442 ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted,
4443 sandbox_mode: Some("workspace-write"),
4444 })
4445 .expect("policy check");
4446
4447 assert!(decision.allow);
4448 assert!(decision.requires_approval);
4449 assert_eq!(
4450 decision.matched_rule.as_deref(),
4451 Some("tool=exec_shell command=cargo test")
4452 );
4453
4454 let _ = fs::remove_dir_all(dir);
4455 }
4456
4457 #[test]
4458 fn config_store_appends_ask_rules_without_losing_comments_or_duplicates() {
4459 let dir = tempfile::tempdir().expect("tempdir");
4460 let config_path = dir.path().join(CONFIG_FILE_NAME);
4461 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
4462 fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
4463 fs::write(
4464 &permissions_path,
4465 r#"# keep this permission note
4466[[rules]]
4467tool = "exec_shell"
4468command = "cargo check"
4469"#,
4470 )
4471 .expect("write permissions");
4472
4473 let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
4474 let existing = ToolAskRule::exec_shell("cargo check");
4475 let added_rule = ToolAskRule::file_path("read_file", "docs/README.md");
4476 let added = store
4477 .append_ask_rules(&[existing, added_rule.clone(), added_rule.clone()])
4478 .expect("append ask rules");
4479
4480 assert_eq!(added, 1);
4481 assert_eq!(
4482 store.permissions().rules,
4483 vec![ToolAskRule::exec_shell("cargo check"), added_rule.clone(),]
4484 );
4485 let body = fs::read_to_string(&permissions_path).expect("read permissions");
4486 assert!(body.contains("# keep this permission note"));
4487 assert_eq!(body.matches("docs/README.md").count(), 1);
4488 assert!(!body.contains("decision"));
4489
4490 let before_duplicate_append = body;
4491 assert_eq!(
4492 store
4493 .append_ask_rules(&[added_rule])
4494 .expect("dedupe ask rule"),
4495 0
4496 );
4497 assert_eq!(
4498 fs::read_to_string(&permissions_path).expect("read unchanged permissions"),
4499 before_duplicate_append
4500 );
4501
4502 let reloaded = ConfigStore::load(Some(dir.path().join(CONFIG_FILE_NAME)))
4503 .expect("reload config store");
4504 assert_eq!(reloaded.permissions(), store.permissions());
4505 }
4506
4507 #[test]
4508 fn config_store_appends_ask_rule_to_inline_rules_array() {
4509 let dir = tempfile::tempdir().expect("tempdir");
4510 let config_path = dir.path().join(CONFIG_FILE_NAME);
4511 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
4512 fs::write(
4513 &permissions_path,
4514 "# inline rules stay valid\nrules = [{ tool = \"exec_shell\", command = \"cargo check\" }]\n",
4515 )
4516 .expect("write permissions");
4517
4518 let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
4519 assert_eq!(
4520 store
4521 .append_ask_rules(&[ToolAskRule::file_path("read_file", "README.md")])
4522 .expect("append inline ask rule"),
4523 1
4524 );
4525
4526 let body = fs::read_to_string(&permissions_path).expect("read permissions");
4527 assert!(body.contains("# inline rules stay valid"));
4528 let parsed: PermissionsToml = toml::from_str(&body).expect("parse persisted permissions");
4529 assert_eq!(
4530 parsed.rules,
4531 vec![
4532 ToolAskRule::exec_shell("cargo check"),
4533 ToolAskRule::file_path("read_file", "README.md"),
4534 ]
4535 );
4536 }
4537
4538 #[test]
4539 fn config_store_does_not_overwrite_invalid_permissions_file() {
4540 let dir = tempfile::tempdir().expect("tempdir");
4541 let config_path = dir.path().join(CONFIG_FILE_NAME);
4542 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
4543 let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
4544 let invalid = "rules = \"not-an-array\"\n";
4545 fs::write(&permissions_path, invalid).expect("write invalid permissions");
4546
4547 let error = store
4548 .append_ask_rules(&[ToolAskRule::exec_shell("cargo test")])
4549 .expect_err("invalid permissions should fail");
4550
4551 assert!(error.to_string().contains("failed to parse permissions"));
4552 assert_eq!(
4553 fs::read_to_string(&permissions_path).expect("read invalid permissions"),
4554 invalid
4555 );
4556 assert!(store.permissions().is_empty());
4557 }
4558
4559 #[test]
4560 fn duplicate_append_refreshes_permissions_changed_on_disk() {
4561 let dir = tempfile::tempdir().expect("tempdir");
4562 let config_path = dir.path().join(CONFIG_FILE_NAME);
4563 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
4564 let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
4565 fs::write(
4566 permissions_path,
4567 "[[rules]]\ntool = \"exec_shell\"\ncommand = \"cargo check\"\n",
4568 )
4569 .expect("write external permissions update");
4570
4571 assert_eq!(
4572 store
4573 .append_ask_rules(&[ToolAskRule::exec_shell("cargo check")])
4574 .expect("dedupe external ask rule"),
4575 0
4576 );
4577 assert_eq!(
4578 store.permissions().rules,
4579 vec![ToolAskRule::exec_shell("cargo check")]
4580 );
4581 }
4582
4583 #[cfg(unix)]
4584 #[test]
4585 fn config_store_secures_persisted_permissions_file() {
4586 let dir = tempfile::tempdir().expect("tempdir");
4587 let config_path = dir.path().join(CONFIG_FILE_NAME);
4588 let permissions_path = dir.path().join(PERMISSIONS_FILE_NAME);
4589 let mut store = ConfigStore::load(Some(config_path)).expect("load config store");
4590
4591 store
4592 .append_ask_rules(&[ToolAskRule::exec_shell("cargo test")])
4593 .expect("append ask rule");
4594
4595 let mode = fs::metadata(permissions_path)
4596 .expect("permissions metadata")
4597 .permissions()
4598 .mode()
4599 & 0o777;
4600 assert_eq!(mode, 0o600);
4601 }
4602
4603 struct EnvGuard {
4604 deepseek_api_key: Option<OsString>,
4605 deepseek_base_url: Option<OsString>,
4606 deepseek_http_headers: Option<OsString>,
4607 deepseek_model: Option<OsString>,
4608 deepseek_default_text_model: Option<OsString>,
4609 deepseek_provider: Option<OsString>,
4610 deepseek_auth_mode: Option<OsString>,
4611 nvidia_api_key: Option<OsString>,
4612 nvidia_nim_api_key: Option<OsString>,
4613 nim_base_url: Option<OsString>,
4614 nvidia_base_url: Option<OsString>,
4615 nvidia_nim_base_url: Option<OsString>,
4616 openrouter_api_key: Option<OsString>,
4617 openrouter_base_url: Option<OsString>,
4618 openrouter_model: Option<OsString>,
4619 xiaomi_mimo_token_plan_api_key: Option<OsString>,
4620 mimo_token_plan_api_key: Option<OsString>,
4621 xiaomi_mimo_api_key: Option<OsString>,
4622 xiaomi_api_key: Option<OsString>,
4623 mimo_api_key: Option<OsString>,
4624 xiaomi_mimo_base_url: Option<OsString>,
4625 mimo_base_url: Option<OsString>,
4626 xiaomi_mimo_model: Option<OsString>,
4627 mimo_model: Option<OsString>,
4628 xiaomi_mimo_mode: Option<OsString>,
4629 mimo_mode: Option<OsString>,
4630 wanjie_ark_api_key: Option<OsString>,
4631 volcengine_api_key: Option<OsString>,
4632 volcengine_ark_api_key: Option<OsString>,
4633 ark_api_key: Option<OsString>,
4634 volcengine_base_url: Option<OsString>,
4635 volcengine_ark_base_url: Option<OsString>,
4636 ark_base_url: Option<OsString>,
4637 wanjie_ark_base_url: Option<OsString>,
4638 wanjie_base_url: Option<OsString>,
4639 wanjie_maas_base_url: Option<OsString>,
4640 volcengine_model: Option<OsString>,
4641 volcengine_ark_model: Option<OsString>,
4642 wanjie_ark_model: Option<OsString>,
4643 wanjie_model: Option<OsString>,
4644 wanjie_maas_model: Option<OsString>,
4645 novita_api_key: Option<OsString>,
4646 novita_base_url: Option<OsString>,
4647 novita_model: Option<OsString>,
4648 fireworks_api_key: Option<OsString>,
4649 fireworks_base_url: Option<OsString>,
4650 fireworks_model: Option<OsString>,
4651 siliconflow_api_key: Option<OsString>,
4652 siliconflow_base_url: Option<OsString>,
4653 siliconflow_model: Option<OsString>,
4654 arcee_api_key: Option<OsString>,
4655 arcee_base_url: Option<OsString>,
4656 arcee_model: Option<OsString>,
4657 moonshot_api_key: Option<OsString>,
4658 moonshot_base_url: Option<OsString>,
4659 moonshot_model: Option<OsString>,
4660 kimi_api_key: Option<OsString>,
4661 kimi_base_url: Option<OsString>,
4662 kimi_model: Option<OsString>,
4663 kimi_model_name: Option<OsString>,
4664 zai_api_key: Option<OsString>,
4665 z_ai_api_key: Option<OsString>,
4666 zai_base_url: Option<OsString>,
4667 zai_model: Option<OsString>,
4668 stepfun_api_key: Option<OsString>,
4669 step_api_key: Option<OsString>,
4670 stepfun_base_url: Option<OsString>,
4671 stepfun_model: Option<OsString>,
4672 minimax_api_key: Option<OsString>,
4673 minimax_base_url: Option<OsString>,
4674 minimax_model: Option<OsString>,
4675 sglang_api_key: Option<OsString>,
4676 sglang_base_url: Option<OsString>,
4677 vllm_api_key: Option<OsString>,
4678 vllm_base_url: Option<OsString>,
4679 ollama_api_key: Option<OsString>,
4680 ollama_base_url: Option<OsString>,
4681 huggingface_api_key: Option<OsString>,
4682 huggingface_token: Option<OsString>,
4683 huggingface_base_url: Option<OsString>,
4684 hf_base_url: Option<OsString>,
4685 huggingface_model: Option<OsString>,
4686 hf_model: Option<OsString>,
4687 codewhale_provider: Option<OsString>,
4688 codewhale_model: Option<OsString>,
4689 codewhale_base_url: Option<OsString>,
4690 }
4691
4692 impl EnvGuard {
4693 fn without_deepseek_runtime_overrides() -> Self {
4694 let guard = Self {
4695 deepseek_api_key: env::var_os("DEEPSEEK_API_KEY"),
4696 deepseek_base_url: env::var_os("DEEPSEEK_BASE_URL"),
4697 deepseek_http_headers: env::var_os("DEEPSEEK_HTTP_HEADERS"),
4698 deepseek_model: env::var_os("DEEPSEEK_MODEL"),
4699 deepseek_default_text_model: env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL"),
4700 deepseek_provider: env::var_os("DEEPSEEK_PROVIDER"),
4701 deepseek_auth_mode: env::var_os("DEEPSEEK_AUTH_MODE"),
4702 codewhale_provider: env::var_os("CODEWHALE_PROVIDER"),
4703 codewhale_model: env::var_os("CODEWHALE_MODEL"),
4704 codewhale_base_url: env::var_os("CODEWHALE_BASE_URL"),
4705 nvidia_api_key: env::var_os("NVIDIA_API_KEY"),
4706 nvidia_nim_api_key: env::var_os("NVIDIA_NIM_API_KEY"),
4707 nim_base_url: env::var_os("NIM_BASE_URL"),
4708 nvidia_base_url: env::var_os("NVIDIA_BASE_URL"),
4709 nvidia_nim_base_url: env::var_os("NVIDIA_NIM_BASE_URL"),
4710 openrouter_api_key: env::var_os("OPENROUTER_API_KEY"),
4711 openrouter_base_url: env::var_os("OPENROUTER_BASE_URL"),
4712 openrouter_model: env::var_os("OPENROUTER_MODEL"),
4713 xiaomi_mimo_token_plan_api_key: env::var_os("XIAOMI_MIMO_TOKEN_PLAN_API_KEY"),
4714 mimo_token_plan_api_key: env::var_os("MIMO_TOKEN_PLAN_API_KEY"),
4715 xiaomi_mimo_api_key: env::var_os("XIAOMI_MIMO_API_KEY"),
4716 xiaomi_api_key: env::var_os("XIAOMI_API_KEY"),
4717 mimo_api_key: env::var_os("MIMO_API_KEY"),
4718 xiaomi_mimo_base_url: env::var_os("XIAOMI_MIMO_BASE_URL"),
4719 mimo_base_url: env::var_os("MIMO_BASE_URL"),
4720 xiaomi_mimo_model: env::var_os("XIAOMI_MIMO_MODEL"),
4721 mimo_model: env::var_os("MIMO_MODEL"),
4722 xiaomi_mimo_mode: env::var_os("XIAOMI_MIMO_MODE"),
4723 mimo_mode: env::var_os("MIMO_MODE"),
4724 wanjie_ark_api_key: env::var_os("WANJIE_ARK_API_KEY"),
4725 volcengine_api_key: env::var_os("VOLCENGINE_API_KEY"),
4726 volcengine_ark_api_key: env::var_os("VOLCENGINE_ARK_API_KEY"),
4727 ark_api_key: env::var_os("ARK_API_KEY"),
4728 volcengine_base_url: env::var_os("VOLCENGINE_BASE_URL"),
4729 volcengine_ark_base_url: env::var_os("VOLCENGINE_ARK_BASE_URL"),
4730 ark_base_url: env::var_os("ARK_BASE_URL"),
4731 wanjie_ark_base_url: env::var_os("WANJIE_ARK_BASE_URL"),
4732 wanjie_base_url: env::var_os("WANJIE_BASE_URL"),
4733 wanjie_maas_base_url: env::var_os("WANJIE_MAAS_BASE_URL"),
4734 volcengine_model: env::var_os("VOLCENGINE_MODEL"),
4735 volcengine_ark_model: env::var_os("VOLCENGINE_ARK_MODEL"),
4736 wanjie_ark_model: env::var_os("WANJIE_ARK_MODEL"),
4737 wanjie_model: env::var_os("WANJIE_MODEL"),
4738 wanjie_maas_model: env::var_os("WANJIE_MAAS_MODEL"),
4739 novita_api_key: env::var_os("NOVITA_API_KEY"),
4740 novita_base_url: env::var_os("NOVITA_BASE_URL"),
4741 novita_model: env::var_os("NOVITA_MODEL"),
4742 fireworks_api_key: env::var_os("FIREWORKS_API_KEY"),
4743 fireworks_base_url: env::var_os("FIREWORKS_BASE_URL"),
4744 fireworks_model: env::var_os("FIREWORKS_MODEL"),
4745 siliconflow_api_key: env::var_os("SILICONFLOW_API_KEY"),
4746 siliconflow_base_url: env::var_os("SILICONFLOW_BASE_URL"),
4747 siliconflow_model: env::var_os("SILICONFLOW_MODEL"),
4748 arcee_api_key: env::var_os("ARCEE_API_KEY"),
4749 arcee_base_url: env::var_os("ARCEE_BASE_URL"),
4750 arcee_model: env::var_os("ARCEE_MODEL"),
4751 moonshot_api_key: env::var_os("MOONSHOT_API_KEY"),
4752 moonshot_base_url: env::var_os("MOONSHOT_BASE_URL"),
4753 moonshot_model: env::var_os("MOONSHOT_MODEL"),
4754 kimi_api_key: env::var_os("KIMI_API_KEY"),
4755 kimi_base_url: env::var_os("KIMI_BASE_URL"),
4756 kimi_model: env::var_os("KIMI_MODEL"),
4757 kimi_model_name: env::var_os("KIMI_MODEL_NAME"),
4758 zai_api_key: env::var_os("ZAI_API_KEY"),
4759 z_ai_api_key: env::var_os("Z_AI_API_KEY"),
4760 zai_base_url: env::var_os("ZAI_BASE_URL"),
4761 zai_model: env::var_os("ZAI_MODEL"),
4762 stepfun_api_key: env::var_os("STEPFUN_API_KEY"),
4763 step_api_key: env::var_os("STEP_API_KEY"),
4764 stepfun_base_url: env::var_os("STEPFUN_BASE_URL"),
4765 stepfun_model: env::var_os("STEPFUN_MODEL"),
4766 minimax_api_key: env::var_os("MINIMAX_API_KEY"),
4767 minimax_base_url: env::var_os("MINIMAX_BASE_URL"),
4768 minimax_model: env::var_os("MINIMAX_MODEL"),
4769 sglang_api_key: env::var_os("SGLANG_API_KEY"),
4770 sglang_base_url: env::var_os("SGLANG_BASE_URL"),
4771 vllm_api_key: env::var_os("VLLM_API_KEY"),
4772 vllm_base_url: env::var_os("VLLM_BASE_URL"),
4773 ollama_api_key: env::var_os("OLLAMA_API_KEY"),
4774 ollama_base_url: env::var_os("OLLAMA_BASE_URL"),
4775 huggingface_api_key: env::var_os("HUGGINGFACE_API_KEY"),
4776 huggingface_token: env::var_os("HF_TOKEN"),
4777 huggingface_base_url: env::var_os("HUGGINGFACE_BASE_URL"),
4778 hf_base_url: env::var_os("HF_BASE_URL"),
4779 huggingface_model: env::var_os("HUGGINGFACE_MODEL"),
4780 hf_model: env::var_os("HF_MODEL"),
4781 };
4782 unsafe {
4784 env::remove_var("DEEPSEEK_API_KEY");
4785 env::remove_var("DEEPSEEK_BASE_URL");
4786 env::remove_var("DEEPSEEK_HTTP_HEADERS");
4787 env::remove_var("DEEPSEEK_MODEL");
4788 env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL");
4789 env::remove_var("DEEPSEEK_PROVIDER");
4790 env::remove_var("DEEPSEEK_AUTH_MODE");
4791 env::remove_var("CODEWHALE_PROVIDER");
4792 env::remove_var("CODEWHALE_MODEL");
4793 env::remove_var("CODEWHALE_BASE_URL");
4794 env::remove_var("NVIDIA_API_KEY");
4795 env::remove_var("NVIDIA_NIM_API_KEY");
4796 env::remove_var("NIM_BASE_URL");
4797 env::remove_var("NVIDIA_BASE_URL");
4798 env::remove_var("NVIDIA_NIM_BASE_URL");
4799 env::remove_var("OPENROUTER_API_KEY");
4800 env::remove_var("OPENROUTER_BASE_URL");
4801 env::remove_var("OPENROUTER_MODEL");
4802 env::remove_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY");
4803 env::remove_var("MIMO_TOKEN_PLAN_API_KEY");
4804 env::remove_var("XIAOMI_MIMO_API_KEY");
4805 env::remove_var("XIAOMI_API_KEY");
4806 env::remove_var("MIMO_API_KEY");
4807 env::remove_var("XIAOMI_MIMO_BASE_URL");
4808 env::remove_var("MIMO_BASE_URL");
4809 env::remove_var("XIAOMI_MIMO_MODEL");
4810 env::remove_var("MIMO_MODEL");
4811 env::remove_var("XIAOMI_MIMO_MODE");
4812 env::remove_var("MIMO_MODE");
4813 env::remove_var("WANJIE_ARK_API_KEY");
4814 env::remove_var("VOLCENGINE_API_KEY");
4815 env::remove_var("VOLCENGINE_ARK_API_KEY");
4816 env::remove_var("ARK_API_KEY");
4817 env::remove_var("VOLCENGINE_BASE_URL");
4818 env::remove_var("VOLCENGINE_ARK_BASE_URL");
4819 env::remove_var("ARK_BASE_URL");
4820 env::remove_var("WANJIE_ARK_BASE_URL");
4821 env::remove_var("WANJIE_BASE_URL");
4822 env::remove_var("WANJIE_MAAS_BASE_URL");
4823 env::remove_var("VOLCENGINE_MODEL");
4824 env::remove_var("VOLCENGINE_ARK_MODEL");
4825 env::remove_var("WANJIE_ARK_MODEL");
4826 env::remove_var("WANJIE_MODEL");
4827 env::remove_var("WANJIE_MAAS_MODEL");
4828 env::remove_var("NOVITA_API_KEY");
4829 env::remove_var("NOVITA_BASE_URL");
4830 env::remove_var("NOVITA_MODEL");
4831 env::remove_var("FIREWORKS_API_KEY");
4832 env::remove_var("FIREWORKS_BASE_URL");
4833 env::remove_var("FIREWORKS_MODEL");
4834 env::remove_var("SILICONFLOW_API_KEY");
4835 env::remove_var("SILICONFLOW_BASE_URL");
4836 env::remove_var("SILICONFLOW_MODEL");
4837 env::remove_var("ARCEE_API_KEY");
4838 env::remove_var("ARCEE_BASE_URL");
4839 env::remove_var("ARCEE_MODEL");
4840 env::remove_var("MOONSHOT_API_KEY");
4841 env::remove_var("MOONSHOT_BASE_URL");
4842 env::remove_var("MOONSHOT_MODEL");
4843 env::remove_var("KIMI_API_KEY");
4844 env::remove_var("KIMI_BASE_URL");
4845 env::remove_var("KIMI_MODEL");
4846 env::remove_var("KIMI_MODEL_NAME");
4847 env::remove_var("ZAI_API_KEY");
4848 env::remove_var("Z_AI_API_KEY");
4849 env::remove_var("ZAI_BASE_URL");
4850 env::remove_var("ZAI_MODEL");
4851 env::remove_var("STEPFUN_API_KEY");
4852 env::remove_var("STEP_API_KEY");
4853 env::remove_var("STEPFUN_BASE_URL");
4854 env::remove_var("STEPFUN_MODEL");
4855 env::remove_var("MINIMAX_API_KEY");
4856 env::remove_var("MINIMAX_BASE_URL");
4857 env::remove_var("MINIMAX_MODEL");
4858 env::remove_var("SGLANG_API_KEY");
4859 env::remove_var("SGLANG_BASE_URL");
4860 env::remove_var("VLLM_API_KEY");
4861 env::remove_var("VLLM_BASE_URL");
4862 env::remove_var("OLLAMA_API_KEY");
4863 env::remove_var("OLLAMA_BASE_URL");
4864 env::remove_var("HUGGINGFACE_API_KEY");
4865 env::remove_var("HF_TOKEN");
4866 env::remove_var("HUGGINGFACE_BASE_URL");
4867 env::remove_var("HF_BASE_URL");
4868 env::remove_var("HUGGINGFACE_MODEL");
4869 env::remove_var("HF_MODEL");
4870 }
4871 guard
4872 }
4873
4874 unsafe fn restore_var(key: &str, value: Option<OsString>) {
4875 if let Some(value) = value {
4876 unsafe { env::set_var(key, value) };
4877 } else {
4878 unsafe { env::remove_var(key) };
4879 }
4880 }
4881 }
4882
4883 impl Drop for EnvGuard {
4884 fn drop(&mut self) {
4885 unsafe {
4887 Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take());
4888 Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take());
4889 Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take());
4890 Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take());
4891 Self::restore_var(
4892 "DEEPSEEK_DEFAULT_TEXT_MODEL",
4893 self.deepseek_default_text_model.take(),
4894 );
4895 Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take());
4896 Self::restore_var("DEEPSEEK_AUTH_MODE", self.deepseek_auth_mode.take());
4897 Self::restore_var("CODEWHALE_PROVIDER", self.codewhale_provider.take());
4898 Self::restore_var("CODEWHALE_MODEL", self.codewhale_model.take());
4899 Self::restore_var("CODEWHALE_BASE_URL", self.codewhale_base_url.take());
4900 Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take());
4901 Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take());
4902 Self::restore_var("NIM_BASE_URL", self.nim_base_url.take());
4903 Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take());
4904 Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take());
4905 Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take());
4906 Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take());
4907 Self::restore_var("OPENROUTER_MODEL", self.openrouter_model.take());
4908 Self::restore_var(
4909 "XIAOMI_MIMO_TOKEN_PLAN_API_KEY",
4910 self.xiaomi_mimo_token_plan_api_key.take(),
4911 );
4912 Self::restore_var(
4913 "MIMO_TOKEN_PLAN_API_KEY",
4914 self.mimo_token_plan_api_key.take(),
4915 );
4916 Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take());
4917 Self::restore_var("XIAOMI_API_KEY", self.xiaomi_api_key.take());
4918 Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take());
4919 Self::restore_var("XIAOMI_MIMO_BASE_URL", self.xiaomi_mimo_base_url.take());
4920 Self::restore_var("MIMO_BASE_URL", self.mimo_base_url.take());
4921 Self::restore_var("XIAOMI_MIMO_MODEL", self.xiaomi_mimo_model.take());
4922 Self::restore_var("MIMO_MODEL", self.mimo_model.take());
4923 Self::restore_var("XIAOMI_MIMO_MODE", self.xiaomi_mimo_mode.take());
4924 Self::restore_var("MIMO_MODE", self.mimo_mode.take());
4925 Self::restore_var("WANJIE_ARK_API_KEY", self.wanjie_ark_api_key.take());
4926 Self::restore_var("VOLCENGINE_API_KEY", self.volcengine_api_key.take());
4927 Self::restore_var("VOLCENGINE_ARK_API_KEY", self.volcengine_ark_api_key.take());
4928 Self::restore_var("ARK_API_KEY", self.ark_api_key.take());
4929 Self::restore_var("VOLCENGINE_BASE_URL", self.volcengine_base_url.take());
4930 Self::restore_var(
4931 "VOLCENGINE_ARK_BASE_URL",
4932 self.volcengine_ark_base_url.take(),
4933 );
4934 Self::restore_var("ARK_BASE_URL", self.ark_base_url.take());
4935 Self::restore_var("WANJIE_ARK_BASE_URL", self.wanjie_ark_base_url.take());
4936 Self::restore_var("WANJIE_BASE_URL", self.wanjie_base_url.take());
4937 Self::restore_var("WANJIE_MAAS_BASE_URL", self.wanjie_maas_base_url.take());
4938 Self::restore_var("VOLCENGINE_MODEL", self.volcengine_model.take());
4939 Self::restore_var("VOLCENGINE_ARK_MODEL", self.volcengine_ark_model.take());
4940 Self::restore_var("WANJIE_ARK_MODEL", self.wanjie_ark_model.take());
4941 Self::restore_var("WANJIE_MODEL", self.wanjie_model.take());
4942 Self::restore_var("WANJIE_MAAS_MODEL", self.wanjie_maas_model.take());
4943 Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take());
4944 Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take());
4945 Self::restore_var("NOVITA_MODEL", self.novita_model.take());
4946 Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take());
4947 Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take());
4948 Self::restore_var("FIREWORKS_MODEL", self.fireworks_model.take());
4949 Self::restore_var("SILICONFLOW_API_KEY", self.siliconflow_api_key.take());
4950 Self::restore_var("SILICONFLOW_BASE_URL", self.siliconflow_base_url.take());
4951 Self::restore_var("SILICONFLOW_MODEL", self.siliconflow_model.take());
4952 Self::restore_var("ARCEE_API_KEY", self.arcee_api_key.take());
4953 Self::restore_var("ARCEE_BASE_URL", self.arcee_base_url.take());
4954 Self::restore_var("ARCEE_MODEL", self.arcee_model.take());
4955 Self::restore_var("MOONSHOT_API_KEY", self.moonshot_api_key.take());
4956 Self::restore_var("MOONSHOT_BASE_URL", self.moonshot_base_url.take());
4957 Self::restore_var("MOONSHOT_MODEL", self.moonshot_model.take());
4958 Self::restore_var("KIMI_API_KEY", self.kimi_api_key.take());
4959 Self::restore_var("KIMI_BASE_URL", self.kimi_base_url.take());
4960 Self::restore_var("KIMI_MODEL", self.kimi_model.take());
4961 Self::restore_var("KIMI_MODEL_NAME", self.kimi_model_name.take());
4962 Self::restore_var("ZAI_API_KEY", self.zai_api_key.take());
4963 Self::restore_var("Z_AI_API_KEY", self.z_ai_api_key.take());
4964 Self::restore_var("ZAI_BASE_URL", self.zai_base_url.take());
4965 Self::restore_var("ZAI_MODEL", self.zai_model.take());
4966 Self::restore_var("STEPFUN_API_KEY", self.stepfun_api_key.take());
4967 Self::restore_var("STEP_API_KEY", self.step_api_key.take());
4968 Self::restore_var("STEPFUN_BASE_URL", self.stepfun_base_url.take());
4969 Self::restore_var("STEPFUN_MODEL", self.stepfun_model.take());
4970 Self::restore_var("MINIMAX_API_KEY", self.minimax_api_key.take());
4971 Self::restore_var("MINIMAX_BASE_URL", self.minimax_base_url.take());
4972 Self::restore_var("MINIMAX_MODEL", self.minimax_model.take());
4973 Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take());
4974 Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take());
4975 Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take());
4976 Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take());
4977 Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take());
4978 Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take());
4979 Self::restore_var("HUGGINGFACE_API_KEY", self.huggingface_api_key.take());
4980 Self::restore_var("HF_TOKEN", self.huggingface_token.take());
4981 Self::restore_var("HUGGINGFACE_BASE_URL", self.huggingface_base_url.take());
4982 Self::restore_var("HF_BASE_URL", self.hf_base_url.take());
4983 Self::restore_var("HUGGINGFACE_MODEL", self.huggingface_model.take());
4984 Self::restore_var("HF_MODEL", self.hf_model.take());
4985 }
4986 }
4987 }
4988
4989 struct RecordingSecretsStore {
4990 gets: Mutex<Vec<String>>,
4991 value: Option<String>,
4992 }
4993
4994 impl RecordingSecretsStore {
4995 fn with_value(value: &str) -> Self {
4996 Self {
4997 gets: Mutex::new(Vec::new()),
4998 value: Some(value.to_string()),
4999 }
5000 }
5001 }
5002
5003 impl codewhale_secrets::KeyringStore for RecordingSecretsStore {
5004 fn get(&self, key: &str) -> Result<Option<String>, codewhale_secrets::SecretsError> {
5005 self.gets.lock().unwrap().push(key.to_string());
5006 Ok(self.value.clone())
5007 }
5008
5009 fn set(&self, _key: &str, _value: &str) -> Result<(), codewhale_secrets::SecretsError> {
5010 Ok(())
5011 }
5012
5013 fn delete(&self, _key: &str) -> Result<(), codewhale_secrets::SecretsError> {
5014 Ok(())
5015 }
5016
5017 fn backend_name(&self) -> &'static str {
5018 "recording"
5019 }
5020 }
5021
5022 #[test]
5023 fn root_deepseek_fields_are_runtime_fallbacks() {
5024 let _lock = env_lock();
5025 let _env = EnvGuard::without_deepseek_runtime_overrides();
5026 let config = ConfigToml {
5027 api_key: Some("root-key".to_string()),
5028 base_url: Some("https://api.deepseek.com".to_string()),
5029 default_text_model: Some("deepseek-v4-pro".to_string()),
5030 ..ConfigToml::default()
5031 };
5032
5033 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5034
5035 assert_eq!(resolved.provider, ProviderKind::Deepseek);
5036 assert_eq!(resolved.api_key.as_deref(), Some("root-key"));
5037 assert_eq!(resolved.base_url, "https://api.deepseek.com");
5038 assert_eq!(resolved.model, "deepseek-v4-pro");
5039 }
5040
5041 #[test]
5042 fn deepseek_runtime_defaults_to_beta_endpoint() {
5043 let _lock = env_lock();
5044 let _env = EnvGuard::without_deepseek_runtime_overrides();
5045 let config = ConfigToml::default();
5046
5047 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5048
5049 assert_eq!(resolved.provider, ProviderKind::Deepseek);
5050 assert_eq!(resolved.base_url, DEFAULT_DEEPSEEK_BASE_URL);
5051 assert_eq!(resolved.model, DEFAULT_DEEPSEEK_MODEL);
5052 }
5053
5054 #[test]
5055 fn provider_specific_deepseek_fields_override_tui_compat_fields() {
5056 let _lock = env_lock();
5057 let _env = EnvGuard::without_deepseek_runtime_overrides();
5058 let mut config = ConfigToml {
5059 api_key: Some("root-key".to_string()),
5060 base_url: Some("https://api.deepseek.com".to_string()),
5061 default_text_model: Some("deepseek-v4-pro".to_string()),
5062 ..ConfigToml::default()
5063 };
5064 config.providers.deepseek.api_key = Some("provider-key".to_string());
5065 config.providers.deepseek.base_url = Some("https://gateway.example/v1".to_string());
5066 config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
5067
5068 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5069
5070 assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
5071 assert_eq!(resolved.base_url, "https://gateway.example/v1");
5072 assert_eq!(resolved.model, "deepseek-v4-flash");
5073 }
5074
5075 #[test]
5076 fn provider_http_headers_override_root_headers() {
5077 let _lock = env_lock();
5078 let _env = EnvGuard::without_deepseek_runtime_overrides();
5079 let mut config = ConfigToml {
5080 api_key: Some("root-key".to_string()),
5081 base_url: Some("https://api.deepseek.com".to_string()),
5082 default_text_model: Some("deepseek-v4-pro".to_string()),
5083 ..ConfigToml::default()
5084 };
5085 config.providers.deepseek.api_key = Some("provider-key".to_string());
5086 config.providers.deepseek.base_url = Some("https://gateway.example/v1".to_string());
5087 config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
5088 config
5089 .http_headers
5090 .insert("X-Shared".to_string(), "root".to_string());
5091 config
5092 .providers
5093 .deepseek
5094 .http_headers
5095 .insert("X-Model-Provider-Id".to_string(), "tongyi".to_string());
5096 config
5097 .providers
5098 .deepseek
5099 .http_headers
5100 .insert("X-Shared".to_string(), "provider".to_string());
5101
5102 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5103
5104 assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
5105 assert_eq!(resolved.base_url, "https://gateway.example/v1");
5106 assert_eq!(resolved.model, "deepseek-v4-flash");
5107 assert_eq!(
5108 resolved
5109 .http_headers
5110 .get("X-Model-Provider-Id")
5111 .map(String::as_str),
5112 Some("tongyi")
5113 );
5114 assert_eq!(
5115 resolved.http_headers.get("X-Shared").map(String::as_str),
5116 Some("provider")
5117 );
5118 }
5119
5120 #[test]
5121 fn insecure_skip_tls_verify_resolves_only_for_active_provider() {
5122 let _lock = env_lock();
5123 let _env = EnvGuard::without_deepseek_runtime_overrides();
5124 let mut config = ConfigToml {
5125 provider: ProviderKind::Openai,
5126 ..ConfigToml::default()
5127 };
5128 config.providers.deepseek.insecure_skip_tls_verify = Some(true);
5129
5130 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5131
5132 assert_eq!(resolved.provider, ProviderKind::Openai);
5133 assert!(!resolved.insecure_skip_tls_verify);
5134
5135 config.providers.openai.insecure_skip_tls_verify = Some(true);
5136 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5137
5138 assert_eq!(resolved.provider, ProviderKind::Openai);
5139 assert!(resolved.insecure_skip_tls_verify);
5140 }
5141
5142 #[test]
5143 fn http_headers_env_overrides_config() {
5144 let _lock = env_lock();
5145 let _env = EnvGuard::without_deepseek_runtime_overrides();
5146 let mut config = ConfigToml::default();
5147 config
5148 .http_headers
5149 .insert("X-Model-Provider-Id".to_string(), "from-file".to_string());
5150 unsafe {
5152 env::set_var("DEEPSEEK_HTTP_HEADERS", "X-Model-Provider-Id=from-env");
5153 }
5154
5155 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5156
5157 assert_eq!(
5158 resolved
5159 .http_headers
5160 .get("X-Model-Provider-Id")
5161 .map(String::as_str),
5162 Some("from-env")
5163 );
5164 }
5165
5166 #[test]
5167 fn nvidia_nim_provider_defaults_to_catalog_endpoint_and_model() {
5168 let _lock = env_lock();
5169 let _env = EnvGuard::without_deepseek_runtime_overrides();
5170 let config = ConfigToml {
5171 provider: ProviderKind::NvidiaNim,
5172 ..ConfigToml::default()
5173 };
5174
5175 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5176
5177 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
5178 assert_eq!(resolved.base_url, DEFAULT_NVIDIA_NIM_BASE_URL);
5179 assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
5180 }
5181
5182 #[test]
5183 fn nvidia_nim_provider_uses_provider_specific_credentials() {
5184 let _lock = env_lock();
5185 let _env = EnvGuard::without_deepseek_runtime_overrides();
5186 let mut config = ConfigToml {
5187 provider: ProviderKind::NvidiaNim,
5188 ..ConfigToml::default()
5189 };
5190 config.providers.nvidia_nim.api_key = Some("nim-key".to_string());
5191 config.providers.nvidia_nim.base_url = Some("https://nim.example/v1".to_string());
5192 config.providers.nvidia_nim.model = Some("deepseek-ai/deepseek-v4-pro".to_string());
5193
5194 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5195
5196 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
5197 assert_eq!(resolved.api_key.as_deref(), Some("nim-key"));
5198 assert_eq!(resolved.base_url, "https://nim.example/v1");
5199 assert_eq!(resolved.model, "deepseek-ai/deepseek-v4-pro");
5200 }
5201
5202 #[test]
5203 fn nvidia_nim_provider_normalizes_flash_aliases() {
5204 let _lock = env_lock();
5205 let _env = EnvGuard::without_deepseek_runtime_overrides();
5206 let cli = CliRuntimeOverrides {
5207 provider: Some(ProviderKind::NvidiaNim),
5208 model: Some("deepseek-v4-flash".to_string()),
5209 ..CliRuntimeOverrides::default()
5210 };
5211
5212 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
5213
5214 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
5215 assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_FLASH_MODEL);
5216 }
5217
5218 #[test]
5219 fn nvidia_nim_provider_uses_nvidia_env_credentials() {
5220 let _lock = env_lock();
5221 let _env = EnvGuard::without_deepseek_runtime_overrides();
5222 unsafe {
5224 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
5225 env::set_var("NVIDIA_API_KEY", "nim-env-key");
5226 env::set_var("NVIDIA_NIM_BASE_URL", "https://nim-env.example/v1");
5227 }
5228
5229 let config = ConfigToml::default();
5230 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5231
5232 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
5233 assert_eq!(resolved.api_key.as_deref(), Some("nim-env-key"));
5234 assert_eq!(resolved.base_url, "https://nim-env.example/v1");
5235 assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
5236 }
5237
5238 #[test]
5239 fn nvidia_nim_provider_accepts_short_nim_base_url_alias() {
5240 let _lock = env_lock();
5241 let _env = EnvGuard::without_deepseek_runtime_overrides();
5242 unsafe {
5244 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
5245 env::set_var("NVIDIA_API_KEY", "nim-env-key");
5246 env::set_var("NIM_BASE_URL", "https://short-nim.example/v1");
5247 }
5248
5249 let config = ConfigToml::default();
5250 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5251
5252 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
5253 assert_eq!(resolved.base_url, "https://short-nim.example/v1");
5254 }
5255
5256 #[test]
5257 fn nvidia_nim_provider_can_fallback_to_deepseek_api_key_env() {
5258 let _lock = env_lock();
5259 let _env = EnvGuard::without_deepseek_runtime_overrides();
5260 unsafe {
5262 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
5263 env::set_var("DEEPSEEK_API_KEY", "deepseek-compat-key");
5264 }
5265
5266 let config = ConfigToml::default();
5267 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5268
5269 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
5270 assert_eq!(resolved.api_key.as_deref(), Some("deepseek-compat-key"));
5271 }
5272
5273 #[test]
5274 fn list_values_redacts_root_api_key() {
5275 let config = ConfigToml {
5276 api_key: Some("sk-deepseek-secret".to_string()),
5277 ..ConfigToml::default()
5278 };
5279
5280 let values = config.list_values();
5281
5282 assert_eq!(
5283 values.get("api_key").map(String::as_str),
5284 Some("sk-d***cret")
5285 );
5286 }
5287
5288 #[test]
5289 fn list_values_fully_redacts_short_api_key() {
5290 let config = ConfigToml {
5291 api_key: Some("short-key".to_string()),
5292 ..ConfigToml::default()
5293 };
5294
5295 let values = config.list_values();
5296
5297 assert_eq!(values.get("api_key").map(String::as_str), Some("********"));
5298 }
5299
5300 #[test]
5301 fn get_display_value_redacts_sensitive_keys() {
5302 let mut config = ConfigToml {
5303 api_key: Some("sk-deepseek-secret".to_string()),
5304 ..ConfigToml::default()
5305 };
5306 config.providers.openrouter.api_key = Some("openrouter-secret-value".to_string());
5307 config.model = Some("deepseek-v4-pro".to_string());
5308
5309 assert_eq!(
5310 config.get_display_value("api_key").as_deref(),
5311 Some("sk-d***cret")
5312 );
5313 assert_eq!(
5314 config
5315 .get_display_value("providers.openrouter.api_key")
5316 .as_deref(),
5317 Some("open***alue")
5318 );
5319 assert_eq!(
5320 config.get_display_value("model").as_deref(),
5321 Some("deepseek-v4-pro")
5322 );
5323 }
5324
5325 #[test]
5326 fn config_display_redacts_nested_extra_secrets() {
5327 let mut config = ConfigToml::default();
5328 let mut profile = toml::map::Map::new();
5329 profile.insert(
5330 "chatgpt_access_token".to_string(),
5331 toml::Value::String("raw-chatgpt-access-token-value".to_string()),
5332 );
5333 profile.insert(
5334 "safe_label".to_string(),
5335 toml::Value::String("visible".to_string()),
5336 );
5337
5338 let mut nested = toml::map::Map::new();
5339 nested.insert(
5340 "refresh_token".to_string(),
5341 toml::Value::String("raw-refresh-token-value".to_string()),
5342 );
5343 nested.insert("expires_at".to_string(), toml::Value::Integer(1234));
5344 profile.insert("session".to_string(), toml::Value::Table(nested));
5345
5346 config
5347 .extras
5348 .insert("extras".to_string(), toml::Value::Table(profile));
5349
5350 let listed = config.list_values();
5351 let rendered = listed.get("extras").expect("extras are listed");
5352
5353 assert!(rendered.contains("chatgpt_access_token"));
5354 assert!(rendered.contains("refresh_token"));
5355 assert!(rendered.contains("safe_label = \"visible\""));
5356 assert!(!rendered.contains("raw-chatgpt-access-token-value"));
5357 assert!(!rendered.contains("raw-refresh-token-value"));
5358
5359 let display = config
5360 .get_display_value("extras")
5361 .expect("extras display value");
5362 assert!(!display.contains("raw-chatgpt-access-token-value"));
5363 assert!(!display.contains("raw-refresh-token-value"));
5364 }
5365
5366 #[test]
5367 fn config_display_redacts_sensitive_extra_leaf_keys_and_headers() {
5368 let mut config = ConfigToml::default();
5369 config.extras.insert(
5370 "chatgpt_access_token".to_string(),
5371 toml::Value::String("raw-chatgpt-token-value".to_string()),
5372 );
5373 config.http_headers.insert(
5374 "Authorization".to_string(),
5375 "Bearer raw-header-token".to_string(),
5376 );
5377 config
5378 .http_headers
5379 .insert("X-Test".to_string(), "ok".to_string());
5380
5381 assert_eq!(
5382 config.get_display_value("chatgpt_access_token").as_deref(),
5383 Some("\"raw-***alue\"")
5384 );
5385
5386 let headers = config
5387 .list_values()
5388 .get("http_headers")
5389 .expect("headers are listed")
5390 .clone();
5391 assert!(headers.contains("Authorization=Bear***oken"));
5392 assert!(headers.contains("X-Test=ok"));
5393 assert!(!headers.contains("raw-header-token"));
5394 }
5395
5396 #[test]
5397 fn hook_sinks_config_uses_separate_table_from_lifecycle_hooks() -> Result<()> {
5398 let raw = r#"
5399[hooks]
5400enabled = true
5401default_timeout_secs = 20
5402
5403[[hooks.hooks]]
5404event = "message_submit"
5405command = "echo ok"
5406
5407[hook_sinks]
5408unix_socket_path = "/tmp/cw-hooks.sock"
5409"#;
5410
5411 let config: ConfigToml = toml::from_str(raw)?;
5412
5413 assert_eq!(
5414 config.get_value("hook_sinks.unix_socket_path").as_deref(),
5415 Some("/tmp/cw-hooks.sock")
5416 );
5417 assert!(
5418 config.extras.contains_key("hooks"),
5419 "legacy lifecycle hooks table must remain an opaque extra"
5420 );
5421
5422 let serialized = toml::to_string_pretty(&config)?;
5423 let round_tripped: ConfigToml = toml::from_str(&serialized)?;
5424 let hooks = round_tripped
5425 .extras
5426 .get("hooks")
5427 .and_then(toml::Value::as_table)
5428 .expect("hooks table preserved");
5429
5430 assert_eq!(
5431 hooks.get("enabled").and_then(toml::Value::as_bool),
5432 Some(true)
5433 );
5434 assert_eq!(
5435 hooks
5436 .get("default_timeout_secs")
5437 .and_then(toml::Value::as_integer),
5438 Some(20)
5439 );
5440 assert!(
5441 hooks.get("hooks").and_then(toml::Value::as_array).is_some(),
5442 "nested lifecycle hooks array must survive config rewrites"
5443 );
5444 assert_eq!(
5445 round_tripped
5446 .get_value("hook_sinks.unix_socket_path")
5447 .as_deref(),
5448 Some("/tmp/cw-hooks.sock")
5449 );
5450
5451 Ok(())
5452 }
5453
5454 #[test]
5455 fn hook_sinks_unix_socket_path_round_trips_through_key_value_api() -> Result<()> {
5456 let mut config = ConfigToml::default();
5457
5458 config.set_value("hook_sinks.unix_socket_path", "/tmp/cw-events.sock")?;
5459
5460 assert_eq!(
5461 config.get_value("hook_sinks.unix_socket_path").as_deref(),
5462 Some("/tmp/cw-events.sock")
5463 );
5464 assert_eq!(
5465 config
5466 .list_values()
5467 .get("hook_sinks.unix_socket_path")
5468 .map(String::as_str),
5469 Some("/tmp/cw-events.sock")
5470 );
5471
5472 config.unset_value("hook_sinks.unix_socket_path")?;
5473 assert_eq!(config.get_value("hook_sinks.unix_socket_path"), None);
5474
5475 Ok(())
5476 }
5477
5478 #[test]
5490 fn moonshot_kimi_code_smoke_config_set_then_resolve() -> Result<()> {
5491 let _lock = env_lock();
5492 let _env = EnvGuard::without_deepseek_runtime_overrides();
5493
5494 let mut config = ConfigToml {
5495 provider: ProviderKind::Deepseek,
5496 default_text_model: Some("deepseek-v4-pro".to_string()),
5497 ..ConfigToml::default()
5498 };
5499
5500 config.set_value("providers.moonshot.api_key", "kimi-code-key-placeholder")?;
5502 config.set_value("providers.moonshot.auth_mode", "api_key")?;
5503 config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?;
5504 config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?;
5505
5506 unsafe { env::set_var("CODEWHALE_PROVIDER", "moonshot") };
5509
5510 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
5511
5512 assert_eq!(resolved.provider, ProviderKind::Moonshot);
5513 assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
5514 assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
5515 assert_eq!(resolved.auth_mode.as_deref(), Some("api_key"));
5516 assert_eq!(
5517 resolved.api_key.as_deref(),
5518 Some("kimi-code-key-placeholder")
5519 );
5520 assert_eq!(
5521 resolved.api_key_source,
5522 Some(RuntimeApiKeySource::ConfigFile)
5523 );
5524 Ok(())
5525 }
5526
5527 #[test]
5528 fn moonshot_provider_config_values_round_trip() -> Result<()> {
5529 let mut config = ConfigToml::default();
5530
5531 config.set_value("providers.moonshot.api_key", "moonshot-secret-value")?;
5532 config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?;
5533 config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?;
5534 config.set_value("providers.moonshot.auth_mode", "api_key")?;
5535 config.set_value("providers.moonshot.http_headers", "X-Test=ok")?;
5536
5537 assert_eq!(
5538 config
5539 .get_display_value("providers.moonshot.api_key")
5540 .as_deref(),
5541 Some("moon***alue")
5542 );
5543 assert_eq!(
5544 config.get_value("providers.moonshot.base_url").as_deref(),
5545 Some(DEFAULT_KIMI_CODE_BASE_URL)
5546 );
5547 assert_eq!(
5548 config.get_value("providers.moonshot.model").as_deref(),
5549 Some(DEFAULT_KIMI_CODE_MODEL)
5550 );
5551 assert_eq!(
5552 config.get_value("providers.moonshot.auth_mode").as_deref(),
5553 Some("api_key")
5554 );
5555 assert_eq!(
5556 config
5557 .list_values()
5558 .get("providers.moonshot.api_key")
5559 .map(String::as_str),
5560 Some("moon***alue")
5561 );
5562
5563 config.unset_value("providers.moonshot.auth_mode")?;
5564 config.unset_value("providers.moonshot.base_url")?;
5565 config.unset_value("providers.moonshot.model")?;
5566
5567 assert_eq!(config.get_value("providers.moonshot.auth_mode"), None);
5568 assert_eq!(config.get_value("providers.moonshot.base_url"), None);
5569 assert_eq!(config.get_value("providers.moonshot.model"), None);
5570 Ok(())
5571 }
5572
5573 #[test]
5574 fn siliconflow_cn_provider_config_values_round_trip() -> Result<()> {
5575 let mut config = ConfigToml::default();
5576
5577 config.set_value("providers.siliconflow_cn.api_key", "sf-cn-secret-value")?;
5578 config.set_value(
5579 "providers.siliconflow_cn.base_url",
5580 DEFAULT_SILICONFLOW_CN_BASE_URL,
5581 )?;
5582 config.set_value("providers.siliconflow_cn.model", DEFAULT_SILICONFLOW_MODEL)?;
5583 config.set_value("providers.siliconflow_cn.http_headers", "X-Test=ok")?;
5584
5585 assert_eq!(
5586 config
5587 .get_display_value("providers.siliconflow_cn.api_key")
5588 .as_deref(),
5589 Some("sf-c***alue")
5590 );
5591 assert_eq!(
5592 config
5593 .get_value("providers.siliconflow_cn.base_url")
5594 .as_deref(),
5595 Some(DEFAULT_SILICONFLOW_CN_BASE_URL)
5596 );
5597 assert_eq!(
5598 config
5599 .get_value("providers.siliconflow_cn.model")
5600 .as_deref(),
5601 Some(DEFAULT_SILICONFLOW_MODEL)
5602 );
5603 assert_eq!(
5604 config
5605 .list_values()
5606 .get("providers.siliconflow_cn.api_key")
5607 .map(String::as_str),
5608 Some("sf-c***alue")
5609 );
5610
5611 config.unset_value("providers.siliconflow_cn.api_key")?;
5612 config.unset_value("providers.siliconflow_cn.base_url")?;
5613 config.unset_value("providers.siliconflow_cn.model")?;
5614 config.unset_value("providers.siliconflow_cn.http_headers")?;
5615
5616 assert_eq!(config.get_value("providers.siliconflow_cn.api_key"), None);
5617 assert_eq!(config.get_value("providers.siliconflow_cn.base_url"), None);
5618 assert_eq!(config.get_value("providers.siliconflow_cn.model"), None);
5619 assert_eq!(
5620 config.get_value("providers.siliconflow_cn.http_headers"),
5621 None
5622 );
5623 Ok(())
5624 }
5625
5626 #[test]
5627 fn volcengine_provider_config_values_round_trip() -> Result<()> {
5628 let mut config = ConfigToml::default();
5629
5630 config.set_value("providers.volcengine.api_key", "volcengine-secret-value")?;
5631 config.set_value("providers.volcengine.base_url", DEFAULT_VOLCENGINE_BASE_URL)?;
5632 config.set_value("providers.volcengine.model", DEFAULT_VOLCENGINE_MODEL)?;
5633 config.set_value("providers.volcengine.http_headers", "X-Test=ok")?;
5634
5635 assert_eq!(
5636 config
5637 .get_display_value("providers.volcengine.api_key")
5638 .as_deref(),
5639 Some("volc***alue")
5640 );
5641 assert_eq!(
5642 config.get_value("providers.volcengine.base_url").as_deref(),
5643 Some(DEFAULT_VOLCENGINE_BASE_URL)
5644 );
5645 assert_eq!(
5646 config.get_value("providers.volcengine.model").as_deref(),
5647 Some(DEFAULT_VOLCENGINE_MODEL)
5648 );
5649 assert_eq!(
5650 config
5651 .get_value("providers.volcengine.http_headers")
5652 .as_deref(),
5653 Some("X-Test=ok")
5654 );
5655 assert_eq!(
5656 config
5657 .list_values()
5658 .get("providers.volcengine.http_headers")
5659 .map(String::as_str),
5660 Some("X-Test=ok")
5661 );
5662
5663 config.unset_value("providers.volcengine.http_headers")?;
5664 assert_eq!(config.get_value("providers.volcengine.http_headers"), None);
5665 Ok(())
5666 }
5667
5668 #[test]
5669 fn provider_key_value_api_covers_all_provider_metadata_entries() -> Result<()> {
5670 for provider in ProviderKind::ALL {
5671 let table = provider.provider().provider_config_key();
5672 let mut config = ConfigToml::default();
5673 let api_key = format!("secret-value-for-{table}-123456");
5674 let api_key_path = format!("providers.{table}.api_key");
5675 let base_url_path = format!("providers.{table}.base_url");
5676 let model_path = format!("providers.{table}.model");
5677 let headers_path = format!("providers.{table}.http_headers");
5678 let mode_path = format!("providers.{table}.mode");
5679 let auth_mode_path = format!("providers.{table}.auth_mode");
5680 let insecure_path = format!("providers.{table}.insecure_skip_tls_verify");
5681 let path_suffix_path = format!("providers.{table}.path_suffix");
5682
5683 config.set_value(&api_key_path, &api_key)?;
5684 config.set_value(&base_url_path, "https://gateway.example/v1")?;
5685 config.set_value(&model_path, "provider-test-model")?;
5686 config.set_value(&headers_path, "X-Test=ok")?;
5687 config.set_value(&mode_path, "concise")?;
5688 config.set_value(&auth_mode_path, "api_key")?;
5689 config.set_value(&insecure_path, "true")?;
5690 config.set_value(&path_suffix_path, "/chat/completions")?;
5691
5692 assert_eq!(
5693 config.get_value(&api_key_path).as_deref(),
5694 Some(api_key.as_str())
5695 );
5696 assert_eq!(
5697 config.get_value(&base_url_path).as_deref(),
5698 Some("https://gateway.example/v1")
5699 );
5700 assert_eq!(
5701 config.get_value(&model_path).as_deref(),
5702 Some("provider-test-model")
5703 );
5704 assert_eq!(
5705 config.get_value(&headers_path).as_deref(),
5706 Some("X-Test=ok")
5707 );
5708 assert_eq!(config.get_value(&mode_path).as_deref(), Some("concise"));
5709 assert_eq!(
5710 config.get_value(&auth_mode_path).as_deref(),
5711 Some("api_key")
5712 );
5713 assert_eq!(config.get_value(&insecure_path).as_deref(), Some("true"));
5714 assert_eq!(
5715 config.get_value(&path_suffix_path).as_deref(),
5716 Some("/chat/completions")
5717 );
5718
5719 let listed = config.list_values();
5720 let listed_api_key = listed
5721 .get(&api_key_path)
5722 .expect("provider API key is listed");
5723 assert!(listed_api_key.contains("***"));
5724 assert_ne!(listed_api_key, &api_key);
5725 assert_eq!(
5726 listed.get(&headers_path).map(String::as_str),
5727 Some("X-Test=ok")
5728 );
5729 assert_eq!(listed.get(&insecure_path).map(String::as_str), Some("true"));
5730
5731 config.unset_value(&api_key_path)?;
5732 config.unset_value(&base_url_path)?;
5733 config.unset_value(&model_path)?;
5734 config.unset_value(&headers_path)?;
5735 config.unset_value(&mode_path)?;
5736 config.unset_value(&auth_mode_path)?;
5737 config.unset_value(&insecure_path)?;
5738 config.unset_value(&path_suffix_path)?;
5739
5740 assert_eq!(config.get_value(&api_key_path), None);
5741 assert_eq!(config.get_value(&base_url_path), None);
5742 assert_eq!(config.get_value(&model_path), None);
5743 assert_eq!(config.get_value(&headers_path), None);
5744 assert_eq!(config.get_value(&mode_path), None);
5745 assert_eq!(config.get_value(&auth_mode_path), None);
5746 assert_eq!(config.get_value(&insecure_path), None);
5747 assert_eq!(config.get_value(&path_suffix_path), None);
5748
5749 if provider == ProviderKind::Deepseek {
5750 assert_eq!(config.api_key, None);
5751 assert_eq!(config.base_url, None);
5752 assert_eq!(config.default_text_model, None);
5753 assert!(config.http_headers.is_empty());
5754 }
5755 }
5756
5757 Ok(())
5758 }
5759
5760 #[test]
5761 fn project_merge_denies_credentials_endpoints_and_provider_selection() {
5762 let mut base = ConfigToml {
5763 provider: ProviderKind::Deepseek,
5764 api_key: Some("user-key".to_string()),
5765 base_url: Some("https://api.deepseek.com".to_string()),
5766 default_text_model: Some("deepseek-v4-flash".to_string()),
5767 ..ConfigToml::default()
5768 };
5769 base.providers.openrouter.api_key = Some("user-openrouter-key".to_string());
5770 base.providers.openrouter.path_suffix = Some("/chat/completions".to_string());
5771
5772 let mut project = ConfigToml {
5773 provider: ProviderKind::Openrouter,
5774 api_key: Some("attacker-key".to_string()),
5775 base_url: Some("https://evil.example/v1".to_string()),
5776 default_text_model: Some("deepseek-v4-pro".to_string()),
5777 auth_mode: Some("oauth".to_string()),
5778 telemetry: Some(true),
5779 ..ConfigToml::default()
5780 };
5781 project.providers.openrouter.api_key = Some("attacker-openrouter-key".to_string());
5782 project.providers.openrouter.base_url = Some("https://evil.example/openrouter".to_string());
5783 project.providers.openrouter.insecure_skip_tls_verify = Some(true);
5784 project.providers.openrouter.path_suffix = Some("/attacker/chat".to_string());
5785 project.providers.openrouter.model = Some("deepseek/deepseek-v4-pro".to_string());
5786 project.providers.volcengine.model = Some("DeepSeek-V4-Pro".to_string());
5787 project.providers.moonshot.model = Some("kimi-k2.6".to_string());
5788
5789 base.merge_project_overrides(project);
5790
5791 assert_eq!(base.provider, ProviderKind::Deepseek);
5792 assert_eq!(base.api_key.as_deref(), Some("user-key"));
5793 assert_eq!(base.base_url.as_deref(), Some("https://api.deepseek.com"));
5794 assert_eq!(base.auth_mode, None);
5795 assert_eq!(base.telemetry, None);
5796 assert_eq!(
5797 base.providers.openrouter.api_key.as_deref(),
5798 Some("user-openrouter-key")
5799 );
5800 assert_eq!(base.providers.openrouter.base_url, None);
5801 assert_eq!(base.providers.openrouter.insecure_skip_tls_verify, None);
5802 assert_eq!(
5803 base.providers.openrouter.path_suffix.as_deref(),
5804 Some("/chat/completions")
5805 );
5806 assert_eq!(base.default_text_model.as_deref(), Some("deepseek-v4-pro"));
5807 assert_eq!(
5808 base.providers.openrouter.model.as_deref(),
5809 Some("deepseek/deepseek-v4-pro")
5810 );
5811 assert_eq!(
5812 base.providers.volcengine.model.as_deref(),
5813 Some("DeepSeek-V4-Pro")
5814 );
5815 assert_eq!(base.providers.moonshot.model.as_deref(), Some("kimi-k2.6"));
5816 }
5817
5818 #[test]
5819 fn project_merge_forwards_all_provider_model_overrides() {
5820 let mut project_toml = String::new();
5821 for provider in ProviderKind::ALL {
5822 let key = provider.provider().provider_config_key();
5823 project_toml.push_str(&format!(
5824 "[providers.{key}]\nmodel = \"project-{key}-model\"\n\n"
5825 ));
5826 }
5827
5828 let project: ConfigToml =
5829 toml::from_str(&project_toml).expect("project provider overrides parse");
5830 let mut base = ConfigToml::default();
5831
5832 base.merge_project_overrides(project);
5833
5834 for provider in ProviderKind::ALL {
5835 let key = provider.provider().provider_config_key();
5836 let expected = format!("project-{key}-model");
5837 assert_eq!(
5838 base.providers.for_provider(provider).model.as_deref(),
5839 Some(expected.as_str()),
5840 "provider {key} should merge repo-local model override"
5841 );
5842 }
5843 }
5844
5845 #[test]
5846 fn project_merge_only_tightens_approval_and_sandbox_policy() {
5847 let mut strict = ConfigToml {
5848 approval_policy: Some("never".to_string()),
5849 sandbox_mode: Some("read-only".to_string()),
5850 ..ConfigToml::default()
5851 };
5852 strict.merge_project_overrides(ConfigToml {
5853 approval_policy: Some("on-request".to_string()),
5854 sandbox_mode: Some("workspace-write".to_string()),
5855 ..ConfigToml::default()
5856 });
5857 assert_eq!(strict.approval_policy.as_deref(), Some("never"));
5858 assert_eq!(strict.sandbox_mode.as_deref(), Some("read-only"));
5859
5860 let mut permissive = ConfigToml {
5861 approval_policy: Some("auto".to_string()),
5862 sandbox_mode: Some("workspace-write".to_string()),
5863 ..ConfigToml::default()
5864 };
5865 permissive.merge_project_overrides(ConfigToml {
5866 approval_policy: Some("never".to_string()),
5867 sandbox_mode: Some("read-only".to_string()),
5868 ..ConfigToml::default()
5869 });
5870 assert_eq!(permissive.approval_policy.as_deref(), Some("never"));
5871 assert_eq!(permissive.sandbox_mode.as_deref(), Some("read-only"));
5872
5873 let mut unset = ConfigToml::default();
5874 unset.merge_project_overrides(ConfigToml {
5875 approval_policy: Some("on-request".to_string()),
5876 sandbox_mode: Some("workspace-write".to_string()),
5877 ..ConfigToml::default()
5878 });
5879 assert_eq!(unset.approval_policy, None);
5880 assert_eq!(unset.sandbox_mode, None);
5881 }
5882
5883 #[test]
5884 fn list_values_redacts_unicode_api_key_without_byte_slicing() {
5885 let config = ConfigToml {
5886 api_key: Some("密钥密钥密钥密钥123456789".to_string()),
5887 ..ConfigToml::default()
5888 };
5889
5890 let values = config.list_values();
5891
5892 assert_eq!(
5893 values.get("api_key").map(String::as_str),
5894 Some("密钥密钥***6789")
5895 );
5896 }
5897
5898 #[test]
5899 fn app_homes_prefer_home_env_before_platform_home_fallback() {
5900 let _lock = env_lock();
5901 struct HomeEnvGuard {
5902 home: Option<OsString>,
5903 userprofile: Option<OsString>,
5904 codewhale_home: Option<OsString>,
5905 }
5906
5907 impl Drop for HomeEnvGuard {
5908 fn drop(&mut self) {
5909 unsafe {
5911 match self.home.take() {
5912 Some(value) => env::set_var("HOME", value),
5913 None => env::remove_var("HOME"),
5914 }
5915 match self.userprofile.take() {
5916 Some(value) => env::set_var("USERPROFILE", value),
5917 None => env::remove_var("USERPROFILE"),
5918 }
5919 match self.codewhale_home.take() {
5920 Some(value) => env::set_var("CODEWHALE_HOME", value),
5921 None => env::remove_var("CODEWHALE_HOME"),
5922 }
5923 }
5924 }
5925 }
5926
5927 let home =
5928 std::env::temp_dir().join(format!("codewhale-config-home-env-{}", std::process::id()));
5929 let userprofile = std::env::temp_dir().join(format!(
5930 "codewhale-config-userprofile-{}",
5931 std::process::id()
5932 ));
5933 let _env = HomeEnvGuard {
5934 home: env::var_os("HOME"),
5935 userprofile: env::var_os("USERPROFILE"),
5936 codewhale_home: env::var_os("CODEWHALE_HOME"),
5937 };
5938 unsafe {
5940 env::set_var("HOME", &home);
5941 env::set_var("USERPROFILE", &userprofile);
5942 env::remove_var("CODEWHALE_HOME");
5943 }
5944
5945 assert_eq!(
5946 codewhale_home().expect("codewhale home"),
5947 home.join(CODEWHALE_APP_DIR)
5948 );
5949 assert_eq!(
5950 legacy_deepseek_home().expect("legacy home"),
5951 home.join(LEGACY_APP_DIR)
5952 );
5953
5954 let explicit = std::env::temp_dir().join(format!(
5955 "codewhale-config-explicit-home-{}",
5956 std::process::id()
5957 ));
5958 unsafe {
5960 env::set_var("CODEWHALE_HOME", &explicit);
5961 }
5962 assert_eq!(codewhale_home().expect("explicit home"), explicit);
5963 }
5964
5965 #[test]
5966 fn migrate_config_reports_copied_legacy_path() {
5967 let _lock = env_lock();
5968 struct HomeEnvGuard {
5969 home: Option<OsString>,
5970 userprofile: Option<OsString>,
5971 codewhale_home: Option<OsString>,
5972 }
5973
5974 impl Drop for HomeEnvGuard {
5975 fn drop(&mut self) {
5976 unsafe {
5978 match self.home.take() {
5979 Some(value) => env::set_var("HOME", value),
5980 None => env::remove_var("HOME"),
5981 }
5982 match self.userprofile.take() {
5983 Some(value) => env::set_var("USERPROFILE", value),
5984 None => env::remove_var("USERPROFILE"),
5985 }
5986 match self.codewhale_home.take() {
5987 Some(value) => env::set_var("CODEWHALE_HOME", value),
5988 None => env::remove_var("CODEWHALE_HOME"),
5989 }
5990 }
5991 }
5992 }
5993
5994 struct LegacyConfigGuard {
5995 path: PathBuf,
5996 original: Option<Vec<u8>>,
5997 }
5998
5999 impl LegacyConfigGuard {
6000 fn install(path: PathBuf, contents: &[u8]) -> Self {
6001 let original = fs::read(&path).ok();
6002 fs::create_dir_all(path.parent().expect("legacy config parent"))
6003 .expect("legacy dir");
6004 fs::write(&path, contents).expect("legacy config");
6005 Self { path, original }
6006 }
6007 }
6008
6009 impl Drop for LegacyConfigGuard {
6010 fn drop(&mut self) {
6011 if let Some(original) = self.original.take() {
6012 let _ = fs::write(&self.path, original);
6013 } else {
6014 let _ = fs::remove_file(&self.path);
6015 if let Some(parent) = self.path.parent() {
6016 let _ = fs::remove_dir(parent);
6017 }
6018 }
6019 }
6020 }
6021
6022 let unique = std::time::SystemTime::now()
6023 .duration_since(std::time::UNIX_EPOCH)
6024 .expect("clock")
6025 .as_nanos();
6026 let home = std::env::temp_dir().join(format!(
6027 "codewhale-config-migration-{}-{unique}",
6028 std::process::id()
6029 ));
6030 let legacy_dir = home.join(LEGACY_APP_DIR);
6031 let primary_dir = home.join(CODEWHALE_APP_DIR);
6032 let legacy_config = legacy_dir.join(CONFIG_FILE_NAME);
6033 let _legacy =
6034 LegacyConfigGuard::install(legacy_config.clone(), b"provider = \"deepseek\"\n");
6035
6036 let _env = HomeEnvGuard {
6037 home: env::var_os("HOME"),
6038 userprofile: env::var_os("USERPROFILE"),
6039 codewhale_home: env::var_os("CODEWHALE_HOME"),
6040 };
6041 unsafe {
6043 env::set_var("HOME", &home);
6044 env::set_var("USERPROFILE", &home);
6045 env::set_var("CODEWHALE_HOME", &primary_dir);
6046 }
6047
6048 let migration = migrate_config_if_needed()
6049 .expect("migration")
6050 .expect("legacy config should be copied");
6051
6052 assert_eq!(migration.legacy_path, legacy_config);
6053 assert_eq!(migration.primary_path, primary_dir.join(CONFIG_FILE_NAME));
6054 let notice = migration.user_notice();
6055 assert!(notice.contains(&legacy_dir.join(CONFIG_FILE_NAME).display().to_string()));
6056 assert!(notice.contains(&primary_dir.join(CONFIG_FILE_NAME).display().to_string()));
6057 assert!(notice.contains(".codewhale path for future edits"));
6058 assert!(notice.contains(".deepseek file remains only as a compatibility fallback"));
6059 assert_eq!(
6060 fs::read_to_string(primary_dir.join(CONFIG_FILE_NAME)).expect("primary config"),
6061 "provider = \"deepseek\"\n"
6062 );
6063
6064 let _ = fs::remove_dir_all(home);
6065 }
6066
6067 struct StateEnvRestore {
6071 home: Option<OsString>,
6072 userprofile: Option<OsString>,
6073 codewhale_home: Option<OsString>,
6074 }
6075
6076 impl Drop for StateEnvRestore {
6077 fn drop(&mut self) {
6078 unsafe {
6080 match self.home.take() {
6081 Some(value) => env::set_var("HOME", value),
6082 None => env::remove_var("HOME"),
6083 }
6084 match self.userprofile.take() {
6085 Some(value) => env::set_var("USERPROFILE", value),
6086 None => env::remove_var("USERPROFILE"),
6087 }
6088 match self.codewhale_home.take() {
6089 Some(value) => env::set_var("CODEWHALE_HOME", value),
6090 None => env::remove_var("CODEWHALE_HOME"),
6091 }
6092 }
6093 }
6094 }
6095
6096 struct StateDirEnv {
6100 home: PathBuf,
6101 _restore: StateEnvRestore,
6102 }
6103
6104 impl StateDirEnv {
6105 fn install(unique: u128) -> Self {
6106 let home = std::env::temp_dir().join(format!(
6107 "codewhale-state-migration-{}-{unique}",
6108 std::process::id()
6109 ));
6110 let restore = StateEnvRestore {
6111 home: env::var_os("HOME"),
6112 userprofile: env::var_os("USERPROFILE"),
6113 codewhale_home: env::var_os("CODEWHALE_HOME"),
6114 };
6115 unsafe {
6117 env::set_var("HOME", &home);
6118 env::set_var("USERPROFILE", &home);
6119 env::set_var("CODEWHALE_HOME", home.join(CODEWHALE_APP_DIR));
6120 }
6121 Self {
6122 home,
6123 _restore: restore,
6124 }
6125 }
6126 fn legacy(&self, sub: &str) -> PathBuf {
6127 self.home.join(LEGACY_APP_DIR).join(sub)
6128 }
6129 fn primary(&self, sub: &str) -> PathBuf {
6130 self.home.join(CODEWHALE_APP_DIR).join(sub)
6131 }
6132 }
6133
6134 #[test]
6135 fn ensure_state_dir_relocates_legacy_subdir_on_first_write() {
6136 let _lock = env_lock();
6137 let unique = std::time::SystemTime::now()
6138 .duration_since(std::time::UNIX_EPOCH)
6139 .expect("clock")
6140 .as_nanos();
6141 let state_env = StateDirEnv::install(unique);
6142 fs::create_dir_all(state_env.legacy("slop_ledger")).expect("legacy dir");
6144 fs::write(
6145 state_env.legacy("slop_ledger").join("slop_ledger.json"),
6146 b"legacy",
6147 )
6148 .expect("legacy file");
6149 assert!(!state_env.primary("slop_ledger").exists());
6150
6151 let dir = ensure_state_dir("slop_ledger").expect("ensure_state_dir");
6152 assert_eq!(dir, state_env.primary("slop_ledger"));
6153 assert_eq!(
6155 fs::read_to_string(state_env.primary("slop_ledger").join("slop_ledger.json"))
6156 .expect("migrated file"),
6157 "legacy"
6158 );
6159 assert!(
6161 !state_env.legacy("slop_ledger").exists(),
6162 "legacy subdir should be removed after relocation"
6163 );
6164 ensure_state_dir("slop_ledger").expect("idempotent ensure");
6166 let _ = fs::remove_dir_all(&state_env.home);
6167 }
6168
6169 #[test]
6170 fn ensure_state_dir_writes_to_primary_when_both_exist() {
6171 let _lock = env_lock();
6172 let unique = std::time::SystemTime::now()
6173 .duration_since(std::time::UNIX_EPOCH)
6174 .expect("clock")
6175 .as_nanos();
6176 let state_env = StateDirEnv::install(unique);
6177 fs::create_dir_all(state_env.primary("sessions")).expect("primary dir");
6179 fs::write(state_env.primary("sessions").join("a.json"), b"primary").expect("primary file");
6180 fs::create_dir_all(state_env.legacy("sessions")).expect("legacy dir");
6181 fs::write(state_env.legacy("sessions").join("old.json"), b"legacy").expect("legacy file");
6182
6183 let dir = ensure_state_dir("sessions").expect("ensure_state_dir");
6184 assert_eq!(dir, state_env.primary("sessions"));
6185 assert_eq!(
6187 fs::read_to_string(state_env.primary("sessions").join("a.json")).expect("primary"),
6188 "primary"
6189 );
6190 assert!(
6191 state_env.legacy("sessions").exists(),
6192 "existing legacy orphan must not be deleted when primary exists"
6193 );
6194 let _ = fs::remove_dir_all(&state_env.home);
6195 }
6196
6197 #[test]
6198 fn resolve_state_dir_still_finds_legacy_for_backfill() {
6199 let _lock = env_lock();
6200 let unique = std::time::SystemTime::now()
6201 .duration_since(std::time::UNIX_EPOCH)
6202 .expect("clock")
6203 .as_nanos();
6204 let state_env = StateDirEnv::install(unique);
6205 fs::create_dir_all(state_env.legacy("catalog")).expect("legacy dir");
6207 assert_eq!(
6208 resolve_state_dir("catalog").expect("resolve"),
6209 state_env.legacy("catalog")
6210 );
6211 ensure_state_dir("catalog").expect("ensure");
6214 assert_eq!(
6215 resolve_state_dir("catalog").expect("resolve after migrate"),
6216 state_env.primary("catalog")
6217 );
6218 let _ = fs::remove_dir_all(&state_env.home);
6219 }
6220
6221 #[test]
6222 fn state_resolvers_reject_path_traversal_subdirs() {
6223 for bad in ["..", "../secret", "/etc", "a/../../b"] {
6226 let err = ensure_state_dir(bad)
6227 .err()
6228 .unwrap_or_else(|| panic!("expected {bad:?} to be rejected"));
6229 assert!(
6230 format!("{err:#}").contains("state subdir"),
6231 "expected rejection of {bad:?}, got {err:#}"
6232 );
6233 assert!(
6234 resolve_state_dir(bad).is_err(),
6235 "read resolver must also reject {bad:?}"
6236 );
6237 }
6238 assert!(ensure_safe_state_subdir(".").is_ok());
6240 assert!(ensure_safe_state_subdir("sessions").is_ok());
6241 assert!(ensure_safe_state_subdir("a/b").is_ok());
6242 assert!(ensure_safe_state_subdir("").is_err());
6243 }
6244
6245 #[test]
6246 fn normalize_config_file_path_rejects_traversal() {
6247 let err = normalize_config_file_path(PathBuf::from("../config.toml"))
6248 .expect_err("traversal path should fail");
6249 assert!(format!("{err:#}").contains("cannot contain '..'"));
6250 }
6251
6252 #[cfg(unix)]
6253 #[test]
6254 fn save_clamps_existing_config_permissions() {
6255 use std::time::{SystemTime, UNIX_EPOCH};
6256
6257 let unique = SystemTime::now()
6258 .duration_since(UNIX_EPOCH)
6259 .expect("clock")
6260 .as_nanos();
6261 let dir = std::env::temp_dir().join(format!(
6262 "deepseek-config-perms-{}-{unique}",
6263 std::process::id()
6264 ));
6265 fs::create_dir_all(&dir).expect("mkdir");
6266 let path = dir.join(CONFIG_FILE_NAME);
6267 fs::write(&path, "api_key = \"old\"\n").expect("seed config");
6268 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod seed");
6269
6270 let store = ConfigStore {
6271 path: path.clone(),
6272 config: ConfigToml {
6273 api_key: Some("new-secret".to_string()),
6274 ..ConfigToml::default()
6275 },
6276 permissions: PermissionsToml::default(),
6277 original_raw: None,
6278 };
6279 store.save().expect("save");
6280
6281 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
6282 assert_eq!(mode, 0o600);
6283
6284 let _ = fs::remove_dir_all(dir);
6285 }
6286
6287 #[test]
6288 fn config_store_save_skips_identical_serialized_body() {
6289 use std::time::{SystemTime, UNIX_EPOCH};
6290
6291 let unique = SystemTime::now()
6292 .duration_since(UNIX_EPOCH)
6293 .expect("clock")
6294 .as_nanos();
6295 let dir = std::env::temp_dir().join(format!(
6296 "codewhale-config-noop-save-{}-{unique}",
6297 std::process::id()
6298 ));
6299 fs::create_dir_all(&dir).expect("mkdir");
6300 let path = dir.join(CONFIG_FILE_NAME);
6301 let config = ConfigToml {
6302 model: Some("deepseek-v4-flash".to_string()),
6303 ..ConfigToml::default()
6304 };
6305 let body = toml::to_string_pretty(&config).expect("serialize");
6306 fs::write(&path, &body).expect("seed config");
6307 #[cfg(unix)]
6308 fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).expect("chmod seed");
6309
6310 let store = ConfigStore {
6311 path: path.clone(),
6312 config,
6313 permissions: PermissionsToml::default(),
6314 original_raw: None,
6315 };
6316 store.save().expect("identical save should not rewrite");
6317
6318 #[cfg(unix)]
6319 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("chmod restore");
6320 assert_eq!(fs::read_to_string(&path).expect("read config"), body);
6321 assert!(
6322 !config_backup_path(&path).exists(),
6323 "no-op save must not create a migration backup"
6324 );
6325
6326 let _ = fs::remove_dir_all(dir);
6327 }
6328
6329 #[test]
6330 fn config_store_save_creates_one_time_backup_before_changed_write() {
6331 use std::time::{SystemTime, UNIX_EPOCH};
6332
6333 let unique = SystemTime::now()
6334 .duration_since(UNIX_EPOCH)
6335 .expect("clock")
6336 .as_nanos();
6337 let dir = std::env::temp_dir().join(format!(
6338 "codewhale-config-backup-save-{}-{unique}",
6339 std::process::id()
6340 ));
6341 fs::create_dir_all(&dir).expect("mkdir");
6342 let path = dir.join(CONFIG_FILE_NAME);
6343 let original = "model = \"deepseek-v4-flash\"\n";
6344 fs::write(&path, original).expect("seed config");
6345
6346 let store = ConfigStore {
6347 path: path.clone(),
6348 config: ConfigToml {
6349 model: Some("deepseek-v4-pro".to_string()),
6350 ..ConfigToml::default()
6351 },
6352 permissions: PermissionsToml::default(),
6353 original_raw: None,
6354 };
6355 store.save().expect("changed save");
6356
6357 let backup_path = config_backup_path(&path);
6358 assert_eq!(
6359 fs::read_to_string(&backup_path).expect("read backup"),
6360 original
6361 );
6362 let updated = fs::read_to_string(&path).expect("read updated config");
6363 assert!(updated.contains("model = \"deepseek-v4-pro\""));
6364
6365 let _ = fs::remove_dir_all(dir);
6366 }
6367
6368 #[test]
6369 fn config_store_save_preserves_comments() {
6370 let dir = tempfile::tempdir().expect("tempdir");
6371 let config_path = dir.path().join(CONFIG_FILE_NAME);
6372 let original = "# my model\nmodel = \"deepseek-v4-flash\"\n# end comment\n";
6373 fs::write(&config_path, original).expect("write config");
6374
6375 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
6376 store.config.model = Some("deepseek-v4-pro".to_string());
6377 store.save().expect("save");
6378
6379 let body = fs::read_to_string(&config_path).expect("read config");
6380 assert!(body.contains("# my model"), "prefix comment preserved");
6381 assert!(body.contains("# end comment"), "suffix comment preserved");
6382 assert!(body.contains("model = \"deepseek-v4-pro\""));
6383 }
6384
6385 #[test]
6386 fn config_store_save_preserves_disabled_keys() {
6387 let dir = tempfile::tempdir().expect("tempdir");
6388 let config_path = dir.path().join(CONFIG_FILE_NAME);
6389 fs::write(
6390 &config_path,
6391 "# my note\nmodel = \"deepseek-v4-flash\"\n# base_url = \"http://localhost:11434/v1\"\n",
6392 )
6393 .expect("write config");
6394
6395 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
6396 store.config.model = Some("deepseek-v4-pro".to_string());
6397 store.save().expect("save");
6398
6399 let body = fs::read_to_string(&config_path).expect("read config");
6400 assert!(
6401 body.contains("# base_url = \"http://localhost:11434/v1\""),
6402 "disabled key preserved as comment"
6403 );
6404 assert!(body.contains("model = \"deepseek-v4-pro\""));
6405 }
6406
6407 #[test]
6408 fn config_store_save_preserves_comments_with_other_keys() {
6409 let dir = tempfile::tempdir().expect("tempdir");
6412 let config_path = dir.path().join(CONFIG_FILE_NAME);
6413 fs::write(
6414 &config_path,
6415 "# my deepseek key\napi_key = \"sk-1234\"\n\n# my current model\nmodel = \"deepseek-v4-flash\"\n",
6416 )
6417 .expect("write config");
6418
6419 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load config store");
6420 store.config.model = Some("deepseek-v4-pro".to_string());
6421 store.save().expect("save");
6422
6423 let body = fs::read_to_string(&config_path).expect("read config");
6424 assert!(body.contains("# my deepseek key"), "api_key comment lost");
6425 assert!(body.contains("# my current model"), "model comment lost");
6426 assert!(
6427 body.contains("model = \"deepseek-v4-pro\""),
6428 "new model not written"
6429 );
6430 assert!(body.contains("api_key = \"sk-1234\""), "api_key lost");
6431 }
6432
6433 #[test]
6434 fn merge_and_preserve_comments_returns_err_on_invalid_serialized() {
6435 let err = merge_and_preserve_comments("{{{ not toml", "model = 1\n")
6436 .expect_err("invalid serialized should fail");
6437 assert!(
6438 format!("{err:#}").contains("failed to parse serialized"),
6439 "unexpected error: {err:#}"
6440 );
6441 }
6442
6443 #[test]
6444 fn merge_and_preserve_comments_returns_err_on_invalid_original() {
6445 let err = merge_and_preserve_comments("model = 1\n", "{{{ not toml")
6446 .expect_err("invalid original should fail");
6447 assert!(
6448 format!("{err:#}").contains("failed to parse original"),
6449 "unexpected error: {err:#}"
6450 );
6451 }
6452
6453 #[test]
6454 fn config_store_save_falls_back_when_comment_merge_fails() {
6455 let dir = tempfile::tempdir().expect("tempdir");
6456 let config_path = dir.path().join(CONFIG_FILE_NAME);
6457 fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config");
6461
6462 let store = ConfigStore {
6464 path: config_path.clone(),
6465 config: ConfigToml {
6466 model: Some("deepseek-v4-pro".to_string()),
6467 ..ConfigToml::default()
6468 },
6469 permissions: PermissionsToml::default(),
6470 original_raw: Some("{ broken".to_string()),
6471 };
6472 store
6473 .save()
6474 .expect("save should succeed even when merge fails");
6475
6476 let body = fs::read_to_string(&config_path).expect("read config");
6477 assert!(
6478 body.contains("deepseek-v4-pro"),
6479 "config should be written: {body}"
6480 );
6481 }
6482
6483 #[test]
6484 fn provider_kind_parses_openrouter_and_novita_aliases() {
6485 assert_eq!(
6486 ProviderKind::parse("openrouter"),
6487 Some(ProviderKind::Openrouter)
6488 );
6489 assert_eq!(
6490 ProviderKind::parse("OPEN_ROUTER"),
6491 Some(ProviderKind::Openrouter)
6492 );
6493 assert_eq!(
6494 ProviderKind::parse("xiaomi-mimo"),
6495 Some(ProviderKind::XiaomiMimo)
6496 );
6497 assert_eq!(
6498 ProviderKind::parse("xiaomi"),
6499 Some(ProviderKind::XiaomiMimo)
6500 );
6501 assert_eq!(ProviderKind::parse("novita"), Some(ProviderKind::Novita));
6502 assert_eq!(ProviderKind::parse("Novita"), Some(ProviderKind::Novita));
6503 assert_eq!(
6504 ProviderKind::parse("fireworks-ai"),
6505 Some(ProviderKind::Fireworks)
6506 );
6507 assert_eq!(
6508 ProviderKind::parse("silicon-flow"),
6509 Some(ProviderKind::Siliconflow)
6510 );
6511 assert_eq!(
6512 ProviderKind::parse("silicon_flow"),
6513 Some(ProviderKind::Siliconflow)
6514 );
6515 assert_eq!(ProviderKind::parse("kimi"), Some(ProviderKind::Moonshot));
6516 assert_eq!(
6517 ProviderKind::parse("moonshot-ai"),
6518 Some(ProviderKind::Moonshot)
6519 );
6520 assert_eq!(ProviderKind::parse("sg-lang"), Some(ProviderKind::Sglang));
6521 assert_eq!(ProviderKind::parse("v-llm"), Some(ProviderKind::Vllm));
6522 assert_eq!(ProviderKind::parse("vllm"), Some(ProviderKind::Vllm));
6523 assert_eq!(ProviderKind::parse("ollama"), Some(ProviderKind::Ollama));
6524 assert_eq!(
6525 ProviderKind::parse("ollama-local"),
6526 Some(ProviderKind::Ollama)
6527 );
6528 assert_eq!(
6529 ProviderKind::parse("wanjie-ark"),
6530 Some(ProviderKind::WanjieArk)
6531 );
6532 assert_eq!(
6533 ProviderKind::parse("ark_wanjie"),
6534 Some(ProviderKind::WanjieArk)
6535 );
6536 for alias in ["huggingface", "hugging-face", "hugging_face", "hf"] {
6537 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Huggingface));
6538
6539 let parsed: ConfigToml =
6540 toml::from_str(&format!("provider = \"{alias}\"")).expect("huggingface alias");
6541 assert_eq!(parsed.provider, ProviderKind::Huggingface);
6542 }
6543
6544 for alias in ["deepinfra", "deep-infra", "deep_infra"] {
6545 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Deepinfra));
6546
6547 let parsed: ConfigToml =
6548 toml::from_str(&format!("provider = \"{alias}\"")).expect("deepinfra alias");
6549 assert_eq!(parsed.provider, ProviderKind::Deepinfra);
6550 }
6551
6552 let parsed: ConfigToml =
6553 toml::from_str("provider = \"ark-wanjie\"").expect("wanjie provider alias");
6554 assert_eq!(parsed.provider, ProviderKind::WanjieArk);
6555
6556 let parsed: ConfigToml =
6557 toml::from_str("provider = \"silicon-flow\"").expect("siliconflow provider alias");
6558 assert_eq!(parsed.provider, ProviderKind::Siliconflow);
6559 }
6560
6561 #[test]
6562 fn unknown_provider_error_lists_huggingface() {
6563 let mut config = ConfigToml::default();
6564 let err = config
6565 .set_value("provider", "not-a-provider")
6566 .expect_err("unknown provider should fail");
6567 let message = err.to_string();
6568 assert!(message.contains("unknown provider 'not-a-provider'"));
6569 assert!(message.contains("huggingface"));
6570 }
6571
6572 #[test]
6573 fn provider_kind_accepts_legacy_deepseek_cn_aliases() {
6574 for alias in [
6575 "deepseek-cn",
6576 "deepseek_china",
6577 "deepseekcn",
6578 "deepseek-china",
6579 ] {
6580 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Deepseek));
6581
6582 let parsed: ConfigToml =
6583 toml::from_str(&format!("provider = \"{alias}\"")).expect("legacy provider alias");
6584 assert_eq!(parsed.provider, ProviderKind::Deepseek);
6585 }
6586 }
6587
6588 #[test]
6589 fn provider_metadata_registry_covers_every_provider_kind_once() {
6590 let providers = provider::all_providers();
6591 assert_eq!(providers.len(), ProviderKind::ALL.len());
6592
6593 for (kind, provider) in ProviderKind::ALL.iter().zip(providers.iter()) {
6594 assert_eq!(provider.kind(), *kind);
6595 assert_eq!(provider.id(), kind.as_str());
6596 assert_eq!(kind.provider().id(), kind.as_str());
6597 }
6598
6599 let mut ids = std::collections::BTreeSet::new();
6600 for provider in providers {
6601 assert!(ids.insert(provider.id()), "duplicate provider id");
6602 }
6603 }
6604
6605 #[test]
6606 fn provider_metadata_lookup_does_not_fall_back_to_deepseek() {
6607 assert!(provider::lookup_provider("not-a-provider").is_none());
6608 assert!(provider::resolve_provider("not-a-provider").is_none());
6609 assert!(provider::lookup_provider("deepseek-cn").is_none());
6610 assert_eq!(
6611 provider::resolve_provider("deepseek-cn")
6612 .expect("legacy alias resolves")
6613 .kind(),
6614 ProviderKind::Deepseek
6615 );
6616 }
6617
6618 #[test]
6619 fn provider_metadata_preserves_alias_and_config_key_semantics() {
6620 assert_eq!(
6621 provider::resolve_provider("open_router")
6622 .expect("openrouter alias")
6623 .kind(),
6624 ProviderKind::Openrouter
6625 );
6626 assert_eq!(
6627 provider::resolve_provider("xiaomi")
6628 .expect("xiaomi alias")
6629 .kind(),
6630 ProviderKind::XiaomiMimo
6631 );
6632 assert_eq!(
6633 provider::resolve_provider("kimi")
6634 .expect("kimi alias")
6635 .kind(),
6636 ProviderKind::Moonshot
6637 );
6638 assert_eq!(
6639 provider::resolve_provider("hf")
6640 .expect("huggingface alias")
6641 .kind(),
6642 ProviderKind::Huggingface
6643 );
6644
6645 let siliconflow_cn =
6646 provider::resolve_provider("siliconflow-cn").expect("siliconflow-cn alias resolves");
6647 assert_eq!(siliconflow_cn.kind(), ProviderKind::SiliconflowCN);
6648 assert_eq!(siliconflow_cn.id(), "siliconflow-CN");
6649 assert_eq!(siliconflow_cn.provider_config_key(), "siliconflow_cn");
6650
6651 let config = ProvidersToml::default();
6652 let shared_table = config.for_provider(ProviderKind::SiliconflowCN);
6653 assert!(!std::ptr::eq(
6654 shared_table,
6655 config.for_provider(ProviderKind::Siliconflow)
6656 ));
6657 }
6658
6659 #[test]
6660 fn provider_metadata_defaults_match_runtime_helpers() {
6661 for kind in ProviderKind::ALL {
6662 let provider = kind.provider();
6663 assert_eq!(provider.default_model(), default_model_for_provider(kind));
6664 assert_eq!(
6665 provider.default_base_url(),
6666 default_base_url_for_provider(kind)
6667 );
6668 assert!(!provider.display_name().trim().is_empty());
6669 assert!(!provider.env_vars().is_empty());
6670 let expected_wire = match kind {
6674 ProviderKind::OpenaiCodex => provider::WireFormat::Responses,
6675 ProviderKind::Anthropic => provider::WireFormat::AnthropicMessages,
6676 _ => provider::WireFormat::ChatCompletions,
6677 };
6678 assert_eq!(provider.wire(), expected_wire);
6679 }
6680 }
6681
6682 #[test]
6683 fn openrouter_provider_defaults_to_canonical_endpoint_and_model() {
6684 let _lock = env_lock();
6685 let _env = EnvGuard::without_deepseek_runtime_overrides();
6686 let config = ConfigToml {
6687 provider: ProviderKind::Openrouter,
6688 ..ConfigToml::default()
6689 };
6690
6691 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6692
6693 assert_eq!(resolved.provider, ProviderKind::Openrouter);
6694 assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL);
6695 assert_eq!(resolved.model, DEFAULT_OPENROUTER_MODEL);
6696 }
6697
6698 #[test]
6699 fn xiaomi_mimo_provider_defaults_to_canonical_endpoint_and_model() {
6700 let _lock = env_lock();
6701 let _env = EnvGuard::without_deepseek_runtime_overrides();
6702 let config = ConfigToml {
6703 provider: ProviderKind::XiaomiMimo,
6704 ..ConfigToml::default()
6705 };
6706
6707 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6708
6709 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6710 assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
6711 assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL);
6712 }
6713
6714 #[test]
6715 fn xiaomi_provider_alias_table_maps_to_mimo_runtime_config() {
6716 let _lock = env_lock();
6717 let _env = EnvGuard::without_deepseek_runtime_overrides();
6718 let config: ConfigToml = toml::from_str(
6719 r#"
6720provider = "xiaomi-mimo"
6721default_text_model = "deepseek/deepseek-v4-pro"
6722
6723[providers.xiaomi]
6724api_key = "mimo-table-key"
6725base_url = "https://token-plan-sgp.xiaomimimo.com/v1"
6726model = "mimo-v2.5-pro"
6727"#,
6728 )
6729 .expect("xiaomi provider alias config");
6730
6731 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6732
6733 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6734 assert_eq!(resolved.api_key.as_deref(), Some("mimo-table-key"));
6735 assert_eq!(
6736 resolved.base_url,
6737 "https://token-plan-sgp.xiaomimimo.com/v1"
6738 );
6739 assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL);
6740 }
6741
6742 #[test]
6743 fn xiaomi_token_plan_key_rewrites_saved_pay_as_you_go_base_url() {
6744 let _lock = env_lock();
6745 let _env = EnvGuard::without_deepseek_runtime_overrides();
6746 let config: ConfigToml = toml::from_str(
6747 r#"
6748provider = "xiaomi-mimo"
6749
6750[providers.xiaomi_mimo]
6751api_key = "tp-test-token-plan-key"
6752base_url = "https://api.xiaomimimo.com/v1"
6753model = "mimo-v2.5-pro"
6754"#,
6755 )
6756 .expect("xiaomi token-plan config");
6757
6758 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6759
6760 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6761 assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
6762 assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL);
6763 }
6764
6765 #[test]
6766 fn xiaomi_mimo_token_plan_mode_accepts_region_aliases() {
6767 let _lock = env_lock();
6768 let _env = EnvGuard::without_deepseek_runtime_overrides();
6769 let config: ConfigToml = toml::from_str(
6770 r#"
6771provider = "mimo"
6772
6773[providers.mimo]
6774mode = "token-plan-ams"
6775"#,
6776 )
6777 .expect("xiaomi token-plan region config");
6778
6779 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6780
6781 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6782 assert_eq!(resolved.base_url, XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL);
6783 }
6784
6785 #[test]
6786 fn xiaomi_mimo_unknown_mode_stays_on_token_plan_endpoint() {
6787 let _lock = env_lock();
6788 let _env = EnvGuard::without_deepseek_runtime_overrides();
6789 let config: ConfigToml = toml::from_str(
6790 r#"
6791provider = "mimo"
6792
6793[providers.mimo]
6794mode = "token-plan-usa"
6795"#,
6796 )
6797 .expect("xiaomi token-plan unknown mode config");
6798
6799 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6800
6801 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
6802 assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
6803 }
6804
6805 #[test]
6806 fn xiaomi_mimo_aliases_resolve_to_canonical_models() {
6807 assert_eq!(
6808 normalize_model_for_provider(ProviderKind::XiaomiMimo, "omni"),
6809 "mimo-v2.5"
6810 );
6811 assert_eq!(
6812 normalize_model_for_provider(ProviderKind::XiaomiMimo, "tts"),
6813 "mimo-v2.5-tts"
6814 );
6815 assert_eq!(
6816 normalize_model_for_provider(ProviderKind::XiaomiMimo, "voice-design"),
6817 "mimo-v2.5-tts-voicedesign"
6818 );
6819 assert_eq!(
6820 normalize_model_for_provider(ProviderKind::XiaomiMimo, "voiceclone"),
6821 "mimo-v2.5-tts-voiceclone"
6822 );
6823 assert_eq!(
6824 normalize_model_for_provider(ProviderKind::XiaomiMimo, "custom-mimo-model"),
6825 "custom-mimo-model"
6826 );
6827 }
6828
6829 #[test]
6830 fn zai_aliases_resolve_to_canonical_models() {
6831 assert_eq!(
6834 normalize_model_for_provider(ProviderKind::Zai, "glm-5.1"),
6835 ZAI_GLM_5_1_MODEL
6836 );
6837 assert_eq!(
6838 normalize_model_for_provider(ProviderKind::Zai, "glm-5-2"),
6839 DEFAULT_ZAI_MODEL
6840 );
6841 assert_eq!(DEFAULT_ZAI_MODEL, ZAI_GLM_5_2_MODEL);
6842 assert_eq!(
6843 normalize_model_for_provider(ProviderKind::Zai, "glm-5-turbo"),
6844 ZAI_GLM_5_TURBO_MODEL
6845 );
6846 assert_eq!(
6847 normalize_model_for_provider(ProviderKind::Zai, "custom-glm-preview"),
6848 "custom-glm-preview"
6849 );
6850 }
6851
6852 #[test]
6853 fn novita_provider_defaults_to_canonical_endpoint_and_model() {
6854 let _lock = env_lock();
6855 let _env = EnvGuard::without_deepseek_runtime_overrides();
6856 let config = ConfigToml {
6857 provider: ProviderKind::Novita,
6858 ..ConfigToml::default()
6859 };
6860
6861 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6862
6863 assert_eq!(resolved.provider, ProviderKind::Novita);
6864 assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL);
6865 assert_eq!(resolved.model, DEFAULT_NOVITA_MODEL);
6866 }
6867
6868 #[test]
6869 fn fireworks_provider_defaults_to_canonical_endpoint_and_model() {
6870 let _lock = env_lock();
6871 let _env = EnvGuard::without_deepseek_runtime_overrides();
6872 let config = ConfigToml {
6873 provider: ProviderKind::Fireworks,
6874 ..ConfigToml::default()
6875 };
6876
6877 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6878
6879 assert_eq!(resolved.provider, ProviderKind::Fireworks);
6880 assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL);
6881 assert_eq!(resolved.model, DEFAULT_FIREWORKS_MODEL);
6882 }
6883
6884 #[test]
6885 fn siliconflow_provider_defaults_to_canonical_endpoint_and_model() {
6886 let _lock = env_lock();
6887 let _env = EnvGuard::without_deepseek_runtime_overrides();
6888 let config = ConfigToml {
6889 provider: ProviderKind::Siliconflow,
6890 ..ConfigToml::default()
6891 };
6892
6893 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6894
6895 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
6896 assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_BASE_URL);
6897 assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL);
6898 }
6899
6900 #[test]
6901 fn siliconflow_cn_config_falls_back_to_shared_table_when_unset() {
6902 let _lock = env_lock();
6903 let _env = EnvGuard::without_deepseek_runtime_overrides();
6904 let mut config = ConfigToml {
6905 provider: ProviderKind::SiliconflowCN,
6906 ..ConfigToml::default()
6907 };
6908 config.providers.siliconflow.api_key = Some("sf-shared-key".to_string());
6909 config.providers.siliconflow.base_url = Some(DEFAULT_SILICONFLOW_BASE_URL.to_string());
6910 config.providers.siliconflow.model = Some("deepseek-chat".to_string());
6911 config.providers.siliconflow_cn.base_url =
6912 Some(DEFAULT_SILICONFLOW_CN_BASE_URL.to_string());
6913
6914 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6915
6916 assert_eq!(resolved.provider, ProviderKind::SiliconflowCN);
6917 assert_eq!(resolved.api_key.as_deref(), Some("sf-shared-key"));
6918 assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_CN_BASE_URL);
6919 assert_eq!(resolved.model, DEFAULT_SILICONFLOW_FLASH_MODEL);
6920 }
6921
6922 #[test]
6923 fn moonshot_provider_defaults_to_kimi_k27_code() {
6924 let _lock = env_lock();
6925 let _env = EnvGuard::without_deepseek_runtime_overrides();
6926 let config = ConfigToml {
6927 provider: ProviderKind::Moonshot,
6928 ..ConfigToml::default()
6929 };
6930
6931 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6932
6933 assert_eq!(resolved.provider, ProviderKind::Moonshot);
6934 assert_eq!(resolved.base_url, DEFAULT_MOONSHOT_BASE_URL);
6935 assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL);
6936 }
6937
6938 #[test]
6939 fn zai_stepfun_and_minimax_default_to_first_party_routes() {
6940 let _lock = env_lock();
6941 let _env = EnvGuard::without_deepseek_runtime_overrides();
6942
6943 for (provider, expected_base_url, expected_model) in [
6944 (ProviderKind::Zai, DEFAULT_ZAI_BASE_URL, DEFAULT_ZAI_MODEL),
6945 (
6946 ProviderKind::Stepfun,
6947 DEFAULT_STEPFUN_BASE_URL,
6948 DEFAULT_STEPFUN_MODEL,
6949 ),
6950 (
6951 ProviderKind::Minimax,
6952 DEFAULT_MINIMAX_BASE_URL,
6953 DEFAULT_MINIMAX_MODEL,
6954 ),
6955 ] {
6956 let config = ConfigToml {
6957 provider,
6958 ..ConfigToml::default()
6959 };
6960 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
6961
6962 assert_eq!(resolved.provider, provider);
6963 assert_eq!(resolved.base_url, expected_base_url);
6964 assert_eq!(resolved.model, expected_model);
6965 }
6966 }
6967
6968 #[test]
6969 fn first_party_provider_env_model_overrides_pass_through() {
6970 let _lock = env_lock();
6971 let _env = EnvGuard::without_deepseek_runtime_overrides();
6972 unsafe {
6973 env::set_var("CODEWHALE_PROVIDER", "minimax");
6974 env::set_var("MINIMAX_MODEL", "MiniMax-M2.7-highspeed");
6975 env::set_var("MINIMAX_BASE_URL", "https://minimax.example/v1");
6976 }
6977
6978 let resolved =
6979 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6980
6981 assert_eq!(resolved.provider, ProviderKind::Minimax);
6982 assert_eq!(resolved.base_url, "https://minimax.example/v1");
6983 assert_eq!(resolved.model, "MiniMax-M2.7-highspeed");
6984 }
6985
6986 #[test]
6987 fn minimax_env_model_override_canonicalizes_known_aliases() {
6988 let _lock = env_lock();
6989 let _env = EnvGuard::without_deepseek_runtime_overrides();
6990 unsafe {
6991 env::set_var("CODEWHALE_PROVIDER", "minimax");
6992 env::set_var("MINIMAX_MODEL", "minimax-m2-5-highspeed");
6993 }
6994
6995 let resolved =
6996 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
6997
6998 assert_eq!(resolved.provider, ProviderKind::Minimax);
6999 assert_eq!(resolved.model, "MiniMax-M2.5-highspeed");
7000 }
7001
7002 #[test]
7003 fn moonshot_provider_preserves_explicit_kimi_k26() {
7004 let _lock = env_lock();
7005 let _env = EnvGuard::without_deepseek_runtime_overrides();
7006 let mut config = ConfigToml {
7007 provider: ProviderKind::Moonshot,
7008 ..ConfigToml::default()
7009 };
7010 config.providers.moonshot.model = Some("kimi-k2.6".to_string());
7011
7012 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7013
7014 assert_eq!(resolved.provider, ProviderKind::Moonshot);
7015 assert_eq!(resolved.model, MOONSHOT_KIMI_K2_6_MODEL);
7016 }
7017
7018 #[test]
7019 fn moonshot_kimi_oauth_uses_kimi_code_endpoint_and_model() {
7020 let _lock = env_lock();
7021 let _env = EnvGuard::without_deepseek_runtime_overrides();
7022 let mut config = ConfigToml {
7023 provider: ProviderKind::Moonshot,
7024 ..ConfigToml::default()
7025 };
7026 config.providers.moonshot.auth_mode = Some("kimi_oauth".to_string());
7027
7028 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7029
7030 assert_eq!(resolved.provider, ProviderKind::Moonshot);
7031 assert_eq!(resolved.auth_mode.as_deref(), Some("kimi_oauth"));
7032 assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
7033 assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
7034 assert_eq!(resolved.api_key, None);
7035 assert_eq!(resolved.api_key_source, None);
7036 }
7037
7038 #[test]
7039 fn moonshot_kimi_code_api_key_endpoint_defaults_to_kimi_for_coding() {
7040 let _lock = env_lock();
7041 let _env = EnvGuard::without_deepseek_runtime_overrides();
7042 let mut config = ConfigToml {
7043 provider: ProviderKind::Moonshot,
7044 ..ConfigToml::default()
7045 };
7046 config.providers.moonshot.api_key = Some("kimi-code-key".to_string());
7047 config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string());
7048
7049 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7050
7051 assert_eq!(resolved.provider, ProviderKind::Moonshot);
7052 assert_eq!(resolved.auth_mode, None);
7053 assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
7054 assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
7055 assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key"));
7056 assert_eq!(
7057 resolved.api_key_source,
7058 Some(RuntimeApiKeySource::ConfigFile)
7059 );
7060 }
7061
7062 #[test]
7066 fn codewhale_provider_env_switches_active_provider() {
7067 let _lock = env_lock();
7068 let _env = EnvGuard::without_deepseek_runtime_overrides();
7069 unsafe {
7071 env::set_var("CODEWHALE_PROVIDER", "moonshot");
7072 }
7073 let mut config = ConfigToml {
7074 provider: ProviderKind::Deepseek,
7075 ..ConfigToml::default()
7076 };
7077 config.providers.moonshot.api_key = Some("kimi-code-key".to_string());
7078 config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string());
7079
7080 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7081
7082 assert_eq!(resolved.provider, ProviderKind::Moonshot);
7083 assert_eq!(
7084 resolved.provider_source,
7085 ProviderSource::Env("CODEWHALE_PROVIDER")
7086 );
7087 assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
7088 assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
7089 assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key"));
7090 }
7091
7092 #[test]
7097 fn codewhale_provider_env_wins_over_deepseek_provider_env() {
7098 let _lock = env_lock();
7099 let _env = EnvGuard::without_deepseek_runtime_overrides();
7100 unsafe {
7102 env::set_var("CODEWHALE_PROVIDER", "moonshot");
7103 env::set_var("DEEPSEEK_PROVIDER", "openrouter");
7104 }
7105 let config = ConfigToml {
7106 provider: ProviderKind::Deepseek,
7107 ..ConfigToml::default()
7108 };
7109
7110 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7111
7112 assert_eq!(resolved.provider, ProviderKind::Moonshot);
7113 assert_eq!(
7114 resolved.provider_source,
7115 ProviderSource::Env("CODEWHALE_PROVIDER")
7116 );
7117 }
7118
7119 #[test]
7120 fn legacy_deepseek_provider_env_records_provider_source() {
7121 let _lock = env_lock();
7122 let _env = EnvGuard::without_deepseek_runtime_overrides();
7123 unsafe {
7125 env::set_var("DEEPSEEK_PROVIDER", "openrouter");
7126 }
7127 let config = ConfigToml {
7128 provider: ProviderKind::Deepseek,
7129 ..ConfigToml::default()
7130 };
7131
7132 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7133
7134 assert_eq!(resolved.provider, ProviderKind::Openrouter);
7135 assert_eq!(
7136 resolved.provider_source,
7137 ProviderSource::Env("DEEPSEEK_PROVIDER")
7138 );
7139 }
7140
7141 #[test]
7142 fn cli_provider_records_provider_source() {
7143 let _lock = env_lock();
7144 let _env = EnvGuard::without_deepseek_runtime_overrides();
7145 unsafe {
7147 env::set_var("CODEWHALE_PROVIDER", "moonshot");
7148 }
7149 let cli = CliRuntimeOverrides {
7150 provider: Some(ProviderKind::Openai),
7151 ..CliRuntimeOverrides::default()
7152 };
7153 let config = ConfigToml {
7154 provider: ProviderKind::Deepseek,
7155 ..ConfigToml::default()
7156 };
7157
7158 let resolved = config.resolve_runtime_options(&cli);
7159
7160 assert_eq!(resolved.provider, ProviderKind::Openai);
7161 assert_eq!(resolved.provider_source, ProviderSource::Cli);
7162 }
7163
7164 #[test]
7165 fn config_provider_records_provider_source() {
7166 let _lock = env_lock();
7167 let _env = EnvGuard::without_deepseek_runtime_overrides();
7168 let config = ConfigToml {
7169 provider: ProviderKind::Moonshot,
7170 ..ConfigToml::default()
7171 };
7172
7173 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7174
7175 assert_eq!(resolved.provider, ProviderKind::Moonshot);
7176 assert_eq!(resolved.provider_source, ProviderSource::Config);
7177 }
7178
7179 #[test]
7183 fn codewhale_model_env_alias_overrides_default_for_active_provider() {
7184 let _lock = env_lock();
7185 let _env = EnvGuard::without_deepseek_runtime_overrides();
7186 unsafe {
7188 env::set_var("CODEWHALE_PROVIDER", "moonshot");
7189 env::set_var("CODEWHALE_MODEL", "custom-kimi-test-model");
7190 }
7191 let config = ConfigToml::default();
7192
7193 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7194
7195 assert_eq!(resolved.provider, ProviderKind::Moonshot);
7196 assert_eq!(resolved.model, "custom-kimi-test-model");
7197 }
7198
7199 #[test]
7200 fn blank_codewhale_model_env_alias_does_not_override_default_for_active_provider() {
7201 let _lock = env_lock();
7202 let _env = EnvGuard::without_deepseek_runtime_overrides();
7203 unsafe {
7205 env::set_var("CODEWHALE_PROVIDER", "moonshot");
7206 env::set_var("CODEWHALE_MODEL", " ");
7207 }
7208 let config = ConfigToml::default();
7209
7210 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7211
7212 assert_eq!(resolved.provider, ProviderKind::Moonshot);
7213 assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL);
7214 }
7215
7216 #[test]
7217 fn deepseek_default_text_model_legacy_alias_still_overrides_active_provider_model() {
7218 let _lock = env_lock();
7219 let _env = EnvGuard::without_deepseek_runtime_overrides();
7220 unsafe {
7222 env::set_var("CODEWHALE_PROVIDER", "moonshot");
7223 env::set_var("DEEPSEEK_DEFAULT_TEXT_MODEL", "legacy-env-model");
7224 }
7225 let config = ConfigToml::default();
7226
7227 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7228
7229 assert_eq!(resolved.provider, ProviderKind::Moonshot);
7230 assert_eq!(resolved.model, "legacy-env-model");
7231 }
7232
7233 #[test]
7234 fn wanjie_ark_provider_defaults_to_openai_compatible_endpoint_and_model() {
7235 let _lock = env_lock();
7236 let _env = EnvGuard::without_deepseek_runtime_overrides();
7237 let config = ConfigToml {
7238 provider: ProviderKind::WanjieArk,
7239 ..ConfigToml::default()
7240 };
7241
7242 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7243
7244 assert_eq!(resolved.provider, ProviderKind::WanjieArk);
7245 assert_eq!(resolved.base_url, DEFAULT_WANJIE_ARK_BASE_URL);
7246 assert_eq!(resolved.model, DEFAULT_WANJIE_ARK_MODEL);
7247 }
7248
7249 #[test]
7250 fn sglang_provider_defaults_to_local_endpoint_and_model() {
7251 let _lock = env_lock();
7252 let _env = EnvGuard::without_deepseek_runtime_overrides();
7253 let config = ConfigToml {
7254 provider: ProviderKind::Sglang,
7255 ..ConfigToml::default()
7256 };
7257
7258 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7259
7260 assert_eq!(resolved.provider, ProviderKind::Sglang);
7261 assert_eq!(resolved.base_url, DEFAULT_SGLANG_BASE_URL);
7262 assert_eq!(resolved.model, DEFAULT_SGLANG_MODEL);
7263 }
7264
7265 #[test]
7266 fn vllm_provider_defaults_to_local_endpoint_and_model() {
7267 let _lock = env_lock();
7268 let _env = EnvGuard::without_deepseek_runtime_overrides();
7269 let config = ConfigToml {
7270 provider: ProviderKind::Vllm,
7271 ..ConfigToml::default()
7272 };
7273
7274 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7275
7276 assert_eq!(resolved.provider, ProviderKind::Vllm);
7277 assert_eq!(resolved.base_url, DEFAULT_VLLM_BASE_URL);
7278 assert_eq!(resolved.model, DEFAULT_VLLM_MODEL);
7279 }
7280
7281 #[test]
7282 fn ollama_provider_defaults_to_local_endpoint_and_small_model() {
7283 let _lock = env_lock();
7284 let _env = EnvGuard::without_deepseek_runtime_overrides();
7285 let config = ConfigToml {
7286 provider: ProviderKind::Ollama,
7287 ..ConfigToml::default()
7288 };
7289
7290 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7291
7292 assert_eq!(resolved.provider, ProviderKind::Ollama);
7293 assert_eq!(resolved.base_url, DEFAULT_OLLAMA_BASE_URL);
7294 assert_eq!(resolved.model, DEFAULT_OLLAMA_MODEL);
7295 assert_eq!(resolved.api_key, None);
7296 }
7297
7298 #[test]
7299 fn self_hosted_providers_do_not_probe_secret_store_by_default() {
7300 let _lock = env_lock();
7301 let _env = EnvGuard::without_deepseek_runtime_overrides();
7302 let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
7303 let secrets = Secrets::new(store.clone());
7304
7305 for provider in [
7306 ProviderKind::Sglang,
7307 ProviderKind::Vllm,
7308 ProviderKind::Ollama,
7309 ] {
7310 let config = ConfigToml {
7311 provider,
7312 ..ConfigToml::default()
7313 };
7314
7315 let resolved = config
7316 .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7317
7318 assert_eq!(resolved.provider, provider);
7319 assert_eq!(resolved.api_key, None);
7320 }
7321
7322 assert!(
7323 store.gets.lock().unwrap().is_empty(),
7324 "self-hosted providers should not read the secret store by default"
7325 );
7326 }
7327
7328 #[test]
7329 fn self_hosted_api_key_auth_can_use_secret_store_when_requested() {
7330 let _lock = env_lock();
7331 let _env = EnvGuard::without_deepseek_runtime_overrides();
7332 let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
7333 let secrets = Secrets::new(store.clone());
7334 let config = ConfigToml {
7335 provider: ProviderKind::Ollama,
7336 auth_mode: Some("api_key".to_string()),
7337 ..ConfigToml::default()
7338 };
7339
7340 let resolved =
7341 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7342
7343 assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key"));
7344 assert_eq!(store.gets.lock().unwrap().as_slice(), ["ollama"]);
7345 }
7346
7347 #[test]
7348 fn moonshot_api_key_mode_can_use_secret_store_by_default() {
7349 let _lock = env_lock();
7350 let _env = EnvGuard::without_deepseek_runtime_overrides();
7351 let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
7352 let secrets = Secrets::new(store.clone());
7353 let config = ConfigToml {
7354 provider: ProviderKind::Moonshot,
7355 ..ConfigToml::default()
7356 };
7357
7358 let resolved =
7359 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7360
7361 assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key"));
7362 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
7363 assert_eq!(store.gets.lock().unwrap().as_slice(), ["moonshot"]);
7364 }
7365
7366 #[test]
7367 fn loopback_custom_deepseek_base_url_does_not_probe_secret_store_by_default() {
7368 let _lock = env_lock();
7369 let _env = EnvGuard::without_deepseek_runtime_overrides();
7370 let store = Arc::new(RecordingSecretsStore::with_value("stale-deepseek-key"));
7371 let secrets = Secrets::new(store.clone());
7372 let config = ConfigToml {
7373 base_url: Some("http://127.0.0.1:8000/v1".to_string()),
7374 ..ConfigToml::default()
7375 };
7376
7377 let resolved =
7378 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
7379
7380 assert_eq!(resolved.provider, ProviderKind::Deepseek);
7381 assert_eq!(resolved.base_url, "http://127.0.0.1:8000/v1");
7382 assert_eq!(resolved.api_key, None);
7383 assert!(
7384 store.gets.lock().unwrap().is_empty(),
7385 "loopback custom endpoints should not read macOS Keychain or any secret store"
7386 );
7387 }
7388
7389 #[test]
7390 fn ollama_provider_preserves_model_tags() {
7391 let _lock = env_lock();
7392 let _env = EnvGuard::without_deepseek_runtime_overrides();
7393 let cli = CliRuntimeOverrides {
7394 provider: Some(ProviderKind::Ollama),
7395 model: Some("deepseek-coder-v2:16b".to_string()),
7396 ..CliRuntimeOverrides::default()
7397 };
7398
7399 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7400
7401 assert_eq!(resolved.provider, ProviderKind::Ollama);
7402 assert_eq!(resolved.model, "deepseek-coder-v2:16b");
7403 }
7404
7405 #[test]
7406 fn ollama_env_overrides_provider_base_url_and_optional_key() {
7407 let _lock = env_lock();
7408 let _env = EnvGuard::without_deepseek_runtime_overrides();
7409 unsafe {
7411 env::set_var("DEEPSEEK_PROVIDER", "ollama-local");
7412 env::set_var("OLLAMA_BASE_URL", "http://ollama.example/v1");
7413 env::set_var("OLLAMA_API_KEY", "ollama-env-key");
7414 }
7415
7416 let resolved =
7417 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7418
7419 assert_eq!(resolved.provider, ProviderKind::Ollama);
7420 assert_eq!(resolved.base_url, "http://ollama.example/v1");
7421 assert_eq!(resolved.api_key.as_deref(), Some("ollama-env-key"));
7422 }
7423
7424 #[test]
7425 fn openrouter_env_overrides_key_and_model_when_config_missing() {
7426 let _lock = env_lock();
7427 let _env = EnvGuard::without_deepseek_runtime_overrides();
7428 unsafe {
7430 env::set_var("DEEPSEEK_PROVIDER", "openrouter");
7431 env::set_var("OPENROUTER_API_KEY", "or-env-key");
7432 env::set_var("OPENROUTER_MODEL", "deepseek-v4-flash");
7433 }
7434
7435 let resolved =
7436 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7437
7438 assert_eq!(resolved.provider, ProviderKind::Openrouter);
7439 assert_eq!(resolved.api_key.as_deref(), Some("or-env-key"));
7440 assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL);
7441 assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL);
7442 }
7443
7444 #[test]
7445 fn xiaomi_mimo_env_overrides_provider_key_base_url_and_model() {
7446 let _lock = env_lock();
7447 let _env = EnvGuard::without_deepseek_runtime_overrides();
7448 unsafe {
7450 env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
7451 env::set_var("MIMO_API_KEY", "mimo-env-key");
7452 env::set_var("MIMO_BASE_URL", "https://mimo-gateway.example/v1");
7453 env::set_var("MIMO_MODEL", "mimo-v2.5");
7454 }
7455
7456 let resolved =
7457 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7458
7459 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
7460 assert_eq!(resolved.api_key.as_deref(), Some("mimo-env-key"));
7461 assert_eq!(resolved.base_url, "https://mimo-gateway.example/v1");
7462 assert_eq!(resolved.model, "mimo-v2.5");
7463 }
7464
7465 #[test]
7466 fn xiaomi_mimo_env_token_plan_mode_uses_token_plan_key_and_endpoint() {
7467 let _lock = env_lock();
7468 let _env = EnvGuard::without_deepseek_runtime_overrides();
7469 unsafe {
7471 env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
7472 env::set_var("XIAOMI_MIMO_MODE", "token-plan-cn");
7473 env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key");
7474 env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key");
7475 }
7476
7477 let resolved =
7478 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7479
7480 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
7481 assert_eq!(resolved.api_key.as_deref(), Some("tp-env-key"));
7482 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
7483 assert_eq!(resolved.base_url, XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL);
7484 }
7485
7486 #[test]
7487 fn xiaomi_mimo_env_pay_as_you_go_mode_prefers_standard_key() {
7488 let _lock = env_lock();
7489 let _env = EnvGuard::without_deepseek_runtime_overrides();
7490 unsafe {
7492 env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
7493 env::set_var("XIAOMI_MIMO_MODE", "pay-as-you-go");
7494 env::set_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "tp-env-key");
7495 env::set_var("XIAOMI_MIMO_API_KEY", "sk-env-key");
7496 }
7497
7498 let resolved =
7499 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7500
7501 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
7502 assert_eq!(resolved.api_key.as_deref(), Some("sk-env-key"));
7503 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
7504 assert_eq!(resolved.base_url, XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL);
7505 }
7506
7507 #[test]
7508 fn novita_env_overrides_key_and_model_when_config_missing() {
7509 let _lock = env_lock();
7510 let _env = EnvGuard::without_deepseek_runtime_overrides();
7511 unsafe {
7513 env::set_var("DEEPSEEK_PROVIDER", "novita");
7514 env::set_var("NOVITA_API_KEY", "novita-env-key");
7515 env::set_var("NOVITA_MODEL", "deepseek-v4-flash");
7516 }
7517
7518 let resolved =
7519 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7520
7521 assert_eq!(resolved.provider, ProviderKind::Novita);
7522 assert_eq!(resolved.api_key.as_deref(), Some("novita-env-key"));
7523 assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL);
7524 assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL);
7525 }
7526
7527 #[test]
7528 fn fireworks_env_overrides_key_and_model_when_config_missing() {
7529 let _lock = env_lock();
7530 let _env = EnvGuard::without_deepseek_runtime_overrides();
7531 unsafe {
7533 env::set_var("DEEPSEEK_PROVIDER", "fireworks");
7534 env::set_var("FIREWORKS_API_KEY", "fw-env-key");
7535 env::set_var(
7536 "FIREWORKS_MODEL",
7537 "accounts/fireworks/models/account-specific-model",
7538 );
7539 }
7540
7541 let resolved =
7542 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7543
7544 assert_eq!(resolved.provider, ProviderKind::Fireworks);
7545 assert_eq!(resolved.api_key.as_deref(), Some("fw-env-key"));
7546 assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL);
7547 assert_eq!(
7548 resolved.model,
7549 "accounts/fireworks/models/account-specific-model"
7550 );
7551 }
7552
7553 #[test]
7554 fn siliconflow_env_overrides_key_base_url_and_model() {
7555 let _lock = env_lock();
7556 let _env = EnvGuard::without_deepseek_runtime_overrides();
7557 unsafe {
7559 env::set_var("CODEWHALE_PROVIDER", "siliconflow");
7560 env::set_var("SILICONFLOW_API_KEY", "sf-env-key");
7561 env::set_var("SILICONFLOW_BASE_URL", "https://sf-mirror.example/v1");
7562 env::set_var("SILICONFLOW_MODEL", "deepseek-v4-flash");
7563 }
7564
7565 let resolved =
7566 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7567
7568 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
7569 assert_eq!(resolved.api_key.as_deref(), Some("sf-env-key"));
7570 assert_eq!(resolved.base_url, "https://sf-mirror.example/v1");
7571 assert_eq!(resolved.model, "deepseek-v4-flash");
7572 }
7573
7574 #[test]
7575 fn arcee_provider_defaults_to_direct_api_endpoint_and_model() {
7576 let _lock = env_lock();
7577 let _env = EnvGuard::without_deepseek_runtime_overrides();
7578 let config = ConfigToml {
7579 provider: ProviderKind::Arcee,
7580 ..ConfigToml::default()
7581 };
7582
7583 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7584
7585 assert_eq!(resolved.provider, ProviderKind::Arcee);
7586 assert_eq!(resolved.base_url, DEFAULT_ARCEE_BASE_URL);
7587 assert_eq!(resolved.model, DEFAULT_ARCEE_MODEL);
7588 }
7589
7590 #[test]
7591 fn arcee_env_overrides_key_base_url_and_model() {
7592 let _lock = env_lock();
7593 let _env = EnvGuard::without_deepseek_runtime_overrides();
7594 unsafe {
7596 env::set_var("CODEWHALE_PROVIDER", "arcee");
7597 env::set_var("ARCEE_API_KEY", "arcee-env-key");
7598 env::set_var("ARCEE_BASE_URL", "https://arcee-mirror.example/api/v1");
7599 env::set_var("ARCEE_MODEL", "trinity-large-preview");
7600 }
7601
7602 let resolved =
7603 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7604
7605 assert_eq!(resolved.provider, ProviderKind::Arcee);
7606 assert_eq!(resolved.api_key.as_deref(), Some("arcee-env-key"));
7607 assert_eq!(resolved.base_url, "https://arcee-mirror.example/api/v1");
7608 assert_eq!(resolved.model, "trinity-large-preview");
7609 }
7610
7611 #[test]
7612 fn arcee_provider_config_overrides_runtime_defaults() {
7613 let _lock = env_lock();
7614 let _env = EnvGuard::without_deepseek_runtime_overrides();
7615 let mut config = ConfigToml {
7616 provider: ProviderKind::Arcee,
7617 ..ConfigToml::default()
7618 };
7619 config.providers.arcee.api_key = Some("arcee-file-key".to_string());
7620 config.providers.arcee.base_url = Some(DEFAULT_ARCEE_BASE_URL.to_string());
7621 config.providers.arcee.model = Some("arcee-trinity-large-preview".to_string());
7622
7623 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7624
7625 assert_eq!(resolved.provider, ProviderKind::Arcee);
7626 assert_eq!(resolved.api_key.as_deref(), Some("arcee-file-key"));
7627 assert_eq!(resolved.base_url, DEFAULT_ARCEE_BASE_URL);
7628 assert_eq!(resolved.model, ARCEE_TRINITY_LARGE_PREVIEW_MODEL);
7629 }
7630
7631 #[test]
7632 fn huggingface_env_precedence_prefers_documented_names() {
7633 let _lock = env_lock();
7634 let _env = EnvGuard::without_deepseek_runtime_overrides();
7635 unsafe {
7637 env::set_var("CODEWHALE_PROVIDER", "hf");
7638 env::set_var("HUGGINGFACE_API_KEY", "hf-full-key");
7639 env::set_var("HF_TOKEN", "hf-token-fallback");
7640 env::set_var("HUGGINGFACE_BASE_URL", "https://hf-full.example/v1");
7641 env::set_var("HF_BASE_URL", "https://hf-short.example/v1");
7642 env::set_var("HUGGINGFACE_MODEL", "org/full-model");
7643 env::set_var("HF_MODEL", "org/short-model");
7644 }
7645
7646 let resolved =
7647 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7648
7649 assert_eq!(resolved.provider, ProviderKind::Huggingface);
7650 assert_eq!(resolved.api_key.as_deref(), Some("hf-full-key"));
7651 assert_eq!(resolved.base_url, "https://hf-full.example/v1");
7652 assert_eq!(resolved.model, "org/full-model");
7653 }
7654
7655 #[test]
7656 fn huggingface_short_env_fallbacks_resolve_when_primary_names_are_absent() {
7657 let _lock = env_lock();
7658 let _env = EnvGuard::without_deepseek_runtime_overrides();
7659 unsafe {
7661 env::set_var("CODEWHALE_PROVIDER", "huggingface");
7662 env::set_var("HF_TOKEN", "hf-token-fallback");
7663 env::set_var("HF_BASE_URL", "https://hf-short.example/v1");
7664 env::set_var("HF_MODEL", "org/short-model");
7665 }
7666
7667 let resolved =
7668 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7669
7670 assert_eq!(resolved.provider, ProviderKind::Huggingface);
7671 assert_eq!(resolved.api_key.as_deref(), Some("hf-token-fallback"));
7672 assert_eq!(resolved.base_url, "https://hf-short.example/v1");
7673 assert_eq!(resolved.model, "org/short-model");
7674 }
7675
7676 #[test]
7677 fn huggingface_token_fallback_resolves_when_primary_api_key_is_blank() {
7678 let _lock = env_lock();
7679 let _env = EnvGuard::without_deepseek_runtime_overrides();
7680 unsafe {
7682 env::set_var("CODEWHALE_PROVIDER", "huggingface");
7683 env::set_var("HUGGINGFACE_API_KEY", " ");
7684 env::set_var("HF_TOKEN", "hf-token-fallback");
7685 }
7686
7687 let resolved =
7688 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7689
7690 assert_eq!(resolved.provider, ProviderKind::Huggingface);
7691 assert_eq!(resolved.api_key.as_deref(), Some("hf-token-fallback"));
7692 }
7693
7694 #[test]
7695 fn siliconflow_cn_base_url_env_normalizes_model_aliases() {
7696 let _lock = env_lock();
7697 let _env = EnvGuard::without_deepseek_runtime_overrides();
7698 unsafe {
7700 env::set_var("CODEWHALE_PROVIDER", "siliconflow");
7701 env::set_var("SILICONFLOW_API_KEY", "sf-env-key");
7702 env::set_var("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1");
7703 }
7704
7705 for (alias, expected) in [
7706 ("deepseek-v4-flash", DEFAULT_SILICONFLOW_FLASH_MODEL),
7707 ("deepseek-reasoner", DEFAULT_SILICONFLOW_MODEL),
7708 ] {
7709 unsafe {
7711 env::set_var("SILICONFLOW_MODEL", alias);
7712 }
7713
7714 let resolved =
7715 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7716
7717 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
7718 assert_eq!(resolved.base_url, "https://api.siliconflow.cn/v1");
7719 assert_eq!(resolved.model, expected);
7720 }
7721 }
7722
7723 #[test]
7724 fn wanjie_ark_env_api_key_and_base_url_fall_back_when_config_missing() {
7725 let _lock = env_lock();
7726 let _env = EnvGuard::without_deepseek_runtime_overrides();
7727 unsafe {
7729 env::set_var("DEEPSEEK_PROVIDER", "wanjie-ark");
7730 env::set_var("WANJIE_ARK_API_KEY", "wanjie-env-key");
7731 env::set_var("WANJIE_ARK_BASE_URL", "https://wanjie.example/api/v1");
7732 env::set_var("WANJIE_ARK_MODEL", "account-model-id");
7733 }
7734
7735 let resolved =
7736 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7737
7738 assert_eq!(resolved.provider, ProviderKind::WanjieArk);
7739 assert_eq!(resolved.api_key.as_deref(), Some("wanjie-env-key"));
7740 assert_eq!(resolved.base_url, "https://wanjie.example/api/v1");
7741 assert_eq!(resolved.model, "account-model-id");
7742 }
7743
7744 #[test]
7745 fn volcengine_env_aliases_override_key_base_url_and_model() {
7746 let _lock = env_lock();
7747 let _env = EnvGuard::without_deepseek_runtime_overrides();
7748 unsafe {
7750 env::set_var("DEEPSEEK_PROVIDER", "volcengine");
7751 env::set_var("ARK_API_KEY", "volcengine-env-key");
7752 env::set_var("ARK_BASE_URL", "https://volcengine.example/api/coding/v3");
7753 env::set_var("VOLCENGINE_ARK_MODEL", "DeepSeek-V4-Flash");
7754 }
7755
7756 let resolved =
7757 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
7758
7759 assert_eq!(resolved.provider, ProviderKind::Volcengine);
7760 assert_eq!(resolved.api_key.as_deref(), Some("volcengine-env-key"));
7761 assert_eq!(
7762 resolved.base_url,
7763 "https://volcengine.example/api/coding/v3"
7764 );
7765 assert_eq!(resolved.model, "DeepSeek-V4-Flash");
7766 }
7767
7768 #[test]
7769 fn openrouter_provider_normalizes_flash_aliases() {
7770 let _lock = env_lock();
7771 let _env = EnvGuard::without_deepseek_runtime_overrides();
7772 let cli = CliRuntimeOverrides {
7773 provider: Some(ProviderKind::Openrouter),
7774 model: Some("deepseek-v4-flash".to_string()),
7775 ..CliRuntimeOverrides::default()
7776 };
7777
7778 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7779
7780 assert_eq!(resolved.provider, ProviderKind::Openrouter);
7781 assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL);
7782 }
7783
7784 #[test]
7785 fn qwen3_6_plus_resolves_to_canonical_on_openrouter() {
7786 let _lock = env_lock();
7787 let _env = EnvGuard::without_deepseek_runtime_overrides();
7788 let config = ConfigToml {
7789 provider: ProviderKind::Openrouter,
7790 ..ConfigToml::default()
7791 };
7792
7793 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides {
7794 model: Some("qwen3.6-plus".to_string()),
7795 ..CliRuntimeOverrides::default()
7796 });
7797
7798 assert_eq!(resolved.provider, ProviderKind::Openrouter);
7799 assert_eq!(resolved.model, OPENROUTER_QWEN_3_6_PLUS_MODEL);
7800 }
7801
7802 #[test]
7803 fn qwen3_6_plus_alias_qwen_dash_resolves() {
7804 let _lock = env_lock();
7805 let _env = EnvGuard::without_deepseek_runtime_overrides();
7806 let config = ConfigToml {
7807 provider: ProviderKind::Openrouter,
7808 ..ConfigToml::default()
7809 };
7810
7811 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides {
7812 model: Some("qwen-3.6-plus".to_string()),
7813 ..CliRuntimeOverrides::default()
7814 });
7815
7816 assert_eq!(resolved.model, OPENROUTER_QWEN_3_6_PLUS_MODEL);
7817 }
7818
7819 #[test]
7820 fn openrouter_provider_normalizes_recent_large_model_aliases() {
7821 let _lock = env_lock();
7822 let _env = EnvGuard::without_deepseek_runtime_overrides();
7823
7824 for (alias, expected) in [
7825 (
7826 "trinity-large-thinking",
7827 OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL,
7828 ),
7829 ("qwen3.6-flash", OPENROUTER_QWEN_3_6_FLASH_MODEL),
7830 ("qwen3.6-35b-a3b", OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
7831 ("qwen3.6-max-preview", OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL),
7832 ("qwen3.6-plus", OPENROUTER_QWEN_3_6_PLUS_MODEL),
7833 ("mimo-v2.5-pro", OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
7834 ("kimi-k2.7-code", OPENROUTER_KIMI_K2_7_CODE_MODEL),
7835 ("kimi", OPENROUTER_KIMI_K2_7_CODE_MODEL),
7836 ("kimi-k2.6", OPENROUTER_KIMI_K2_6_MODEL),
7837 ("minimax-m3", OPENROUTER_MINIMAX_M3_MODEL),
7838 ("minimax-2.7", OPENROUTER_MINIMAX_2_7_MODEL),
7839 ("gemma-4-31b-it", OPENROUTER_GEMMA_4_31B_MODEL),
7840 ("glm-5.1", OPENROUTER_GLM_5_1_MODEL),
7841 ("glm-5.2", OPENROUTER_GLM_5_2_MODEL),
7842 ] {
7843 let cli = CliRuntimeOverrides {
7844 provider: Some(ProviderKind::Openrouter),
7845 model: Some(alias.to_string()),
7846 ..CliRuntimeOverrides::default()
7847 };
7848
7849 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7850
7851 assert_eq!(resolved.provider, ProviderKind::Openrouter);
7852 assert_eq!(resolved.model, expected);
7853 }
7854 }
7855
7856 #[test]
7857 fn novita_provider_normalizes_flash_aliases() {
7858 let _lock = env_lock();
7859 let _env = EnvGuard::without_deepseek_runtime_overrides();
7860 let cli = CliRuntimeOverrides {
7861 provider: Some(ProviderKind::Novita),
7862 model: Some("deepseek-v4-flash".to_string()),
7863 ..CliRuntimeOverrides::default()
7864 };
7865
7866 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7867
7868 assert_eq!(resolved.provider, ProviderKind::Novita);
7869 assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL);
7870 }
7871
7872 #[test]
7873 fn siliconflow_provider_normalizes_flash_aliases() {
7874 let _lock = env_lock();
7875 let _env = EnvGuard::without_deepseek_runtime_overrides();
7876 let cli = CliRuntimeOverrides {
7877 provider: Some(ProviderKind::Siliconflow),
7878 model: Some("deepseek-v4-flash".to_string()),
7879 ..CliRuntimeOverrides::default()
7880 };
7881
7882 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7883
7884 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
7885 assert_eq!(resolved.model, DEFAULT_SILICONFLOW_FLASH_MODEL);
7886 }
7887
7888 #[test]
7889 fn siliconflow_provider_normalizes_reasoning_aliases_to_pro() {
7890 let _lock = env_lock();
7891 let _env = EnvGuard::without_deepseek_runtime_overrides();
7892
7893 for alias in ["deepseek-reasoner", "deepseek-r1"] {
7894 let cli = CliRuntimeOverrides {
7895 provider: Some(ProviderKind::Siliconflow),
7896 model: Some(alias.to_string()),
7897 ..CliRuntimeOverrides::default()
7898 };
7899
7900 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7901
7902 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
7903 assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL);
7904 }
7905 }
7906
7907 #[test]
7908 fn siliconflow_provider_preserves_deepseek_v3_2_alias() {
7909 let _lock = env_lock();
7910 let _env = EnvGuard::without_deepseek_runtime_overrides();
7911 let cli = CliRuntimeOverrides {
7912 provider: Some(ProviderKind::Siliconflow),
7913 model: Some("deepseek-v3.2".to_string()),
7914 ..CliRuntimeOverrides::default()
7915 };
7916
7917 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7918
7919 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
7920 assert_eq!(resolved.model, "deepseek-v3.2");
7921 }
7922
7923 #[test]
7924 fn sglang_provider_normalizes_flash_aliases() {
7925 let _lock = env_lock();
7926 let _env = EnvGuard::without_deepseek_runtime_overrides();
7927 let cli = CliRuntimeOverrides {
7928 provider: Some(ProviderKind::Sglang),
7929 model: Some("deepseek-v4-flash".to_string()),
7930 ..CliRuntimeOverrides::default()
7931 };
7932
7933 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7934
7935 assert_eq!(resolved.provider, ProviderKind::Sglang);
7936 assert_eq!(resolved.model, DEFAULT_SGLANG_FLASH_MODEL);
7937 }
7938
7939 #[test]
7940 fn vllm_provider_normalizes_flash_aliases() {
7941 let _lock = env_lock();
7942 let _env = EnvGuard::without_deepseek_runtime_overrides();
7943 let cli = CliRuntimeOverrides {
7944 provider: Some(ProviderKind::Vllm),
7945 model: Some("deepseek-v4-flash".to_string()),
7946 ..CliRuntimeOverrides::default()
7947 };
7948
7949 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
7950
7951 assert_eq!(resolved.provider, ProviderKind::Vllm);
7952 assert_eq!(resolved.model, DEFAULT_VLLM_FLASH_MODEL);
7953 }
7954
7955 #[test]
7956 fn openrouter_provider_specific_config_overrides_env() {
7957 let _lock = env_lock();
7958 let _env = EnvGuard::without_deepseek_runtime_overrides();
7959 let mut config = ConfigToml {
7960 provider: ProviderKind::Openrouter,
7961 ..ConfigToml::default()
7962 };
7963 config.providers.openrouter.api_key = Some("file-key".to_string());
7964 config.providers.openrouter.base_url = Some("https://or-mirror.example/v1".to_string());
7965
7966 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7967
7968 assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
7969 assert_eq!(resolved.base_url, "https://or-mirror.example/v1");
7970 }
7971
7972 #[test]
7973 fn openrouter_custom_base_url_preserves_provider_model() {
7974 let _lock = env_lock();
7975 let _env = EnvGuard::without_deepseek_runtime_overrides();
7976 let mut config = ConfigToml {
7977 provider: ProviderKind::Openrouter,
7978 ..ConfigToml::default()
7979 };
7980 config.providers.openrouter.base_url = Some("https://gateway.example.com/v1".to_string());
7981 config.providers.openrouter.model = Some("DeepSeek-V4-Pro".to_string());
7982
7983 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
7984
7985 assert_eq!(resolved.provider, ProviderKind::Openrouter);
7986 assert_eq!(resolved.base_url, "https://gateway.example.com/v1");
7987 assert_eq!(resolved.model, "DeepSeek-V4-Pro");
7988 }
7989
7990 #[test]
7991 fn fireworks_custom_base_url_preserves_provider_model() {
7992 let _lock = env_lock();
7993 let _env = EnvGuard::without_deepseek_runtime_overrides();
7994 let mut config = ConfigToml {
7995 provider: ProviderKind::Fireworks,
7996 ..ConfigToml::default()
7997 };
7998 config.providers.fireworks.base_url = Some("https://my-gateway.example/v1".to_string());
7999 config.providers.fireworks.model = Some("DeepSeek-V4-Pro".to_string());
8000
8001 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8002
8003 assert_eq!(resolved.provider, ProviderKind::Fireworks);
8004 assert_eq!(resolved.base_url, "https://my-gateway.example/v1");
8005 assert_eq!(resolved.model, "DeepSeek-V4-Pro");
8007 }
8008
8009 #[test]
8010 fn siliconflow_custom_base_url_preserves_provider_model() {
8011 let _lock = env_lock();
8012 let _env = EnvGuard::without_deepseek_runtime_overrides();
8013 let mut config = ConfigToml {
8014 provider: ProviderKind::Siliconflow,
8015 ..ConfigToml::default()
8016 };
8017 config.providers.siliconflow.base_url = Some("https://my-gateway.example/v1".to_string());
8018 config.providers.siliconflow.model = Some("DeepSeek-V4-Pro".to_string());
8019
8020 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8021
8022 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
8023 assert_eq!(resolved.base_url, "https://my-gateway.example/v1");
8024 assert_eq!(resolved.model, "DeepSeek-V4-Pro");
8025 }
8026
8027 #[test]
8028 fn config_file_resolves_above_env_and_keyring() {
8029 use codewhale_secrets::KeyringStore;
8030 let _lock = env_lock();
8031 let _env = EnvGuard::without_deepseek_runtime_overrides();
8032 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
8034
8035 let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
8036 store.set("deepseek", "ring-key").unwrap();
8037 let secrets = Secrets::new(store);
8038
8039 let mut config = ConfigToml::default();
8040 config.providers.deepseek.api_key = Some("file-key".to_string());
8041
8042 let resolved =
8043 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
8044 assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
8045 assert_eq!(
8046 resolved.api_key_source,
8047 Some(RuntimeApiKeySource::ConfigFile)
8048 );
8049
8050 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
8052 }
8053
8054 #[test]
8055 fn env_resolves_when_config_file_and_keyring_empty() {
8056 let _lock = env_lock();
8057 let _env = EnvGuard::without_deepseek_runtime_overrides();
8058 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
8060
8061 let secrets = Secrets::new(std::sync::Arc::new(
8062 codewhale_secrets::InMemoryKeyringStore::new(),
8063 ));
8064 let config = ConfigToml::default();
8065
8066 let resolved =
8067 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
8068 assert_eq!(resolved.api_key.as_deref(), Some("env-key"));
8069 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
8070
8071 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
8073 }
8074
8075 #[test]
8076 fn config_file_resolves_when_keyring_and_env_empty() {
8077 let _lock = env_lock();
8078 let _env = EnvGuard::without_deepseek_runtime_overrides();
8079
8080 let secrets = Secrets::new(std::sync::Arc::new(
8081 codewhale_secrets::InMemoryKeyringStore::new(),
8082 ));
8083 let mut config = ConfigToml::default();
8084 config.providers.deepseek.api_key = Some("file-key".to_string());
8085
8086 let resolved =
8087 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
8088 assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
8089 assert_eq!(
8090 resolved.api_key_source,
8091 Some(RuntimeApiKeySource::ConfigFile)
8092 );
8093 }
8094
8095 #[test]
8096 fn keyring_resolves_when_config_file_empty_even_if_env_is_set() {
8097 use codewhale_secrets::KeyringStore;
8098 let _lock = env_lock();
8099 let _env = EnvGuard::without_deepseek_runtime_overrides();
8100 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key") };
8102
8103 let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
8104 store.set("deepseek", "ring-key").unwrap();
8105 let secrets = Secrets::new(store);
8106
8107 let resolved = ConfigToml::default()
8108 .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
8109 assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
8110 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
8111
8112 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
8114 }
8115
8116 #[test]
8117 fn cli_flag_still_overrides_keyring() {
8118 use codewhale_secrets::KeyringStore;
8119 let _lock = env_lock();
8120 let _env = EnvGuard::without_deepseek_runtime_overrides();
8121
8122 let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
8123 store.set("deepseek", "ring-key").unwrap();
8124 let secrets = Secrets::new(store);
8125
8126 let cli = CliRuntimeOverrides {
8127 api_key: Some("cli-key".to_string()),
8128 ..CliRuntimeOverrides::default()
8129 };
8130 let resolved = ConfigToml::default().resolve_runtime_options_with_secrets(&cli, &secrets);
8131 assert_eq!(resolved.api_key.as_deref(), Some("cli-key"));
8132 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Cli));
8133 }
8134
8135 #[test]
8136 fn provider_chain_initial_current_is_active() {
8137 let chain = ProviderChain::new(
8138 ProviderKind::NvidiaNim,
8139 &[ProviderKind::Deepseek, ProviderKind::Openrouter],
8140 );
8141
8142 assert_eq!(chain.current(), ProviderKind::NvidiaNim);
8143 assert_eq!(chain.position(), 0);
8144 assert_eq!(
8145 chain.providers(),
8146 &[
8147 ProviderKind::NvidiaNim,
8148 ProviderKind::Deepseek,
8149 ProviderKind::Openrouter,
8150 ]
8151 );
8152 assert!(!chain.is_fallback_active());
8153 }
8154
8155 #[test]
8156 fn provider_chain_advance_switches_to_fallback() {
8157 let mut chain = ProviderChain::new(
8158 ProviderKind::NvidiaNim,
8159 &[ProviderKind::Deepseek, ProviderKind::Openrouter],
8160 );
8161
8162 assert!(chain.has_next());
8163 assert_eq!(chain.advance(), Some(ProviderKind::Deepseek));
8164 assert_eq!(chain.current(), ProviderKind::Deepseek);
8165 assert!(chain.is_fallback_active());
8166 }
8167
8168 #[test]
8169 fn provider_chain_exhausts_returns_none() {
8170 let mut chain = ProviderChain::new(ProviderKind::Deepseek, &[ProviderKind::Openrouter]);
8171
8172 assert_eq!(chain.advance(), Some(ProviderKind::Openrouter));
8173 assert!(!chain.has_next());
8174 assert_eq!(chain.advance(), None);
8175 }
8176
8177 #[test]
8178 fn provider_chain_skips_duplicates() {
8179 let chain = ProviderChain::new(
8180 ProviderKind::Deepseek,
8181 &[
8182 ProviderKind::Deepseek,
8183 ProviderKind::NvidiaNim,
8184 ProviderKind::Deepseek,
8185 ],
8186 );
8187
8188 assert_eq!(
8189 chain.providers(),
8190 &[ProviderKind::Deepseek, ProviderKind::NvidiaNim]
8191 );
8192 }
8193
8194 #[test]
8195 fn provider_chain_remaining_counts_current_and_untried_entries() {
8196 let mut chain = ProviderChain::new(
8197 ProviderKind::Deepseek,
8198 &[ProviderKind::NvidiaNim, ProviderKind::Openrouter],
8199 );
8200
8201 assert_eq!(chain.remaining(), 3);
8202 assert_eq!(chain.advance(), Some(ProviderKind::NvidiaNim));
8203 assert_eq!(chain.remaining(), 2);
8204 }
8205
8206 #[test]
8207 fn config_toml_parses_fallback_providers() {
8208 let config: ConfigToml = toml::from_str(
8209 r#"
8210provider = "nvidia-nim"
8211fallback_providers = ["deepseek", "openrouter"]
8212"#,
8213 )
8214 .expect("fallback providers config");
8215
8216 assert_eq!(config.provider, ProviderKind::NvidiaNim);
8217 assert_eq!(
8218 config.fallback_providers,
8219 [ProviderKind::Deepseek, ProviderKind::Openrouter]
8220 );
8221 }
8222
8223 #[test]
8224 fn empty_fallback_providers_do_not_serialize() {
8225 let serialized = toml::to_string_pretty(&ConfigToml::default()).expect("config serializes");
8226
8227 assert!(!serialized.contains("fallback_providers"));
8228 }
8229
8230 #[test]
8231 fn fleet_exec_config_default_matches_subagent_depth() {
8232 assert_eq!(
8236 FleetExecConfig::default().max_spawn_depth,
8237 DEFAULT_SPAWN_DEPTH
8238 );
8239 assert_eq!(FleetExecConfig::default().max_spawn_depth, 3);
8240 const { assert!(DEFAULT_SPAWN_DEPTH <= MAX_SPAWN_DEPTH_CEILING) };
8241 }
8242
8243 #[test]
8244 fn fleet_exec_config_parses_max_spawn_depth() {
8245 let config: ConfigToml = toml::from_str(
8246 r#"
8247[fleet.exec]
8248max_spawn_depth = 2
8249"#,
8250 )
8251 .expect("fleet exec config should parse");
8252
8253 assert_eq!(config.fleet.expect("fleet config").exec.max_spawn_depth, 2);
8254 }
8255
8256 #[test]
8257 fn fallback_providers_do_not_change_runtime_resolution() {
8258 let _lock = env_lock();
8259 let _env = EnvGuard::without_deepseek_runtime_overrides();
8260 let config = ConfigToml {
8261 provider: ProviderKind::NvidiaNim,
8262 fallback_providers: vec![ProviderKind::Deepseek],
8263 ..ConfigToml::default()
8264 };
8265
8266 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8267
8268 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
8269 }
8270
8271 #[test]
8272 fn harness_posture_default_is_standard() {
8273 let posture = HarnessPosture::default();
8274
8275 assert_eq!(
8276 posture,
8277 HarnessPosture {
8278 kind: HarnessPostureKind::Standard,
8279 max_subagents: 0,
8280 prefer_codebase_search: false,
8281 compaction_strategy: HarnessCompactionStrategy::Default,
8282 tool_surface: HarnessToolSurface::Full,
8283 safety_posture: HarnessSafetyPosture::Standard,
8284 }
8285 );
8286 }
8287
8288 #[test]
8289 fn harness_posture_factories_are_typed() {
8290 assert_eq!(
8291 HarnessPosture::cache_heavy(),
8292 HarnessPosture {
8293 kind: HarnessPostureKind::CacheHeavy,
8294 max_subagents: 10,
8295 prefer_codebase_search: false,
8296 compaction_strategy: HarnessCompactionStrategy::PrefixCache,
8297 tool_surface: HarnessToolSurface::Full,
8298 safety_posture: HarnessSafetyPosture::Standard,
8299 }
8300 );
8301 assert_eq!(
8302 HarnessPosture::lean(),
8303 HarnessPosture {
8304 kind: HarnessPostureKind::Lean,
8305 max_subagents: 20,
8306 prefer_codebase_search: true,
8307 compaction_strategy: HarnessCompactionStrategy::Aggressive,
8308 tool_surface: HarnessToolSurface::Full,
8309 safety_posture: HarnessSafetyPosture::Standard,
8310 }
8311 );
8312 }
8313
8314 #[test]
8315 fn harness_profile_serde_round_trips_as_a_whole_struct() {
8316 let profile = HarnessProfile {
8317 provider_route: "deepseek".to_string(),
8318 model_pattern: "deepseek-v4.*".to_string(),
8319 posture: HarnessPosture::cache_heavy(),
8320 };
8321
8322 let json = serde_json::to_string(&profile).expect("serialize profile");
8323 let round_tripped: HarnessProfile =
8324 serde_json::from_str(&json).expect("deserialize profile");
8325
8326 assert_eq!(round_tripped, profile);
8327 }
8328
8329 #[test]
8330 fn config_toml_accepts_harness_profiles() {
8331 let config: ConfigToml = toml::from_str(
8332 r#"
8333provider = "deepseek"
8334model = "deepseek-v4-pro"
8335
8336[[harness_profiles]]
8337provider_route = "deepseek"
8338model_pattern = "deepseek-v4.*"
8339
8340[harness_profiles.posture]
8341kind = "cache-heavy"
8342max_subagents = 10
8343compaction_strategy = "prefix-cache"
8344tool_surface = "read-only"
8345safety_posture = "strict"
8346"#,
8347 )
8348 .expect("parse harness profiles");
8349
8350 assert_eq!(
8351 config.harness_profiles,
8352 vec![HarnessProfile {
8353 provider_route: "deepseek".to_string(),
8354 model_pattern: "deepseek-v4.*".to_string(),
8355 posture: HarnessPosture {
8356 kind: HarnessPostureKind::CacheHeavy,
8357 max_subagents: 10,
8358 prefer_codebase_search: false,
8359 compaction_strategy: HarnessCompactionStrategy::PrefixCache,
8360 tool_surface: HarnessToolSurface::ReadOnly,
8361 safety_posture: HarnessSafetyPosture::Strict,
8362 },
8363 }]
8364 );
8365 }
8366
8367 #[test]
8368 fn harness_profile_matches_provider_alias_and_model_wildcard() {
8369 let profile = HarnessProfile {
8370 provider_route: "xiaomi-mimo".to_string(),
8371 model_pattern: "mimo-v2.?-pro".to_string(),
8372 posture: HarnessPosture::cache_heavy(),
8373 };
8374
8375 assert!(profile.matches_route("mimo", "mimo-v2.5-pro"));
8376 assert!(!profile.matches_route("mimo", "mimo-v2.50-pro"));
8377 assert!(!profile.matches_route("deepseek", "mimo-v2.5-pro"));
8378 }
8379
8380 #[test]
8381 fn resolve_harness_profile_returns_first_matching_profile() {
8382 let config = ConfigToml {
8383 harness_profiles: vec![
8384 HarnessProfile {
8385 provider_route: "deepseek".to_string(),
8386 model_pattern: "deepseek-v4-flash".to_string(),
8387 posture: HarnessPosture::lean(),
8388 },
8389 HarnessProfile {
8390 provider_route: "deepseek".to_string(),
8391 model_pattern: "deepseek-v4-*".to_string(),
8392 posture: HarnessPosture::cache_heavy(),
8393 },
8394 ],
8395 ..ConfigToml::default()
8396 };
8397
8398 let flash = config
8399 .resolve_harness_profile("deepseek-cn", "deepseek-v4-flash")
8400 .expect("exact profile should match first");
8401 assert_eq!(flash.posture.kind, HarnessPostureKind::Lean);
8402
8403 let pro = config
8404 .resolve_harness_profile("deepseek", "deepseek-v4-pro")
8405 .expect("wildcard profile should match pro model");
8406 assert_eq!(pro.posture.kind, HarnessPostureKind::CacheHeavy);
8407 }
8408
8409 #[test]
8410 fn resolve_harness_profile_uses_built_in_seed_when_config_has_no_match() {
8411 let config = ConfigToml::default();
8412
8413 let xiaomi = config
8414 .resolve_harness_profile("xiaomi", "mimo-v2.5-pro")
8415 .expect("direct Xiaomi MiMo seed should resolve");
8416 assert_eq!(xiaomi.provider_route, "xiaomi-mimo");
8417 assert_eq!(xiaomi.posture.kind, HarnessPostureKind::CacheHeavy);
8418
8419 let arcee = config
8420 .resolve_harness_profile("arcee", "trinity-large-thinking")
8421 .expect("direct Arcee seed should resolve");
8422 assert_eq!(arcee.posture.kind, HarnessPostureKind::CacheHeavy);
8423
8424 let local = config
8425 .resolve_harness_profile("vllm", "Qwen/Qwen3.6-Coder")
8426 .expect("local seed should resolve");
8427 assert_eq!(local.posture.kind, HarnessPostureKind::Lean);
8428 assert!(local.posture.prefer_codebase_search);
8429 }
8430
8431 #[test]
8432 fn configured_harness_profile_overrides_built_in_seed() {
8433 let config = ConfigToml {
8434 harness_profiles: vec![HarnessProfile {
8435 provider_route: "xiaomi-mimo".to_string(),
8436 model_pattern: "mimo-v2.5-pro".to_string(),
8437 posture: HarnessPosture {
8438 kind: HarnessPostureKind::Custom,
8439 max_subagents: 3,
8440 prefer_codebase_search: true,
8441 compaction_strategy: HarnessCompactionStrategy::Default,
8442 tool_surface: HarnessToolSurface::Auto,
8443 safety_posture: HarnessSafetyPosture::Strict,
8444 },
8445 }],
8446 ..ConfigToml::default()
8447 };
8448
8449 let profile = config
8450 .resolve_harness_profile("xiaomi-mimo", "mimo-v2.5-pro")
8451 .expect("configured profile should match first");
8452
8453 assert_eq!(profile.posture.kind, HarnessPostureKind::Custom);
8454 assert_eq!(profile.posture.max_subagents, 3);
8455 assert_eq!(profile.posture.tool_surface, HarnessToolSurface::Auto);
8456 assert_eq!(profile.posture.safety_posture, HarnessSafetyPosture::Strict);
8457 }
8458
8459 #[test]
8460 fn resolve_harness_profile_returns_none_when_route_or_model_misses() {
8461 let config = ConfigToml {
8462 harness_profiles: vec![HarnessProfile {
8463 provider_route: "huggingface".to_string(),
8464 model_pattern: "deepseek-ai/*".to_string(),
8465 posture: HarnessPosture::lean(),
8466 }],
8467 ..ConfigToml::default()
8468 };
8469
8470 assert!(
8471 config
8472 .resolve_harness_profile("openrouter", "deepseek-ai/DeepSeek-V4-Pro")
8473 .is_none()
8474 );
8475 assert!(
8476 config
8477 .resolve_harness_profile("deepseek", "Qwen/Qwen3.6-Coder")
8478 .is_none()
8479 );
8480 assert!(
8481 config
8482 .resolve_harness_profile("openai", "mimo-v2.5-pro")
8483 .is_none()
8484 );
8485 }
8486
8487 #[test]
8488 fn resolving_harness_profile_does_not_change_runtime_options() {
8489 let _lock = env_lock();
8490 let _env = EnvGuard::without_deepseek_runtime_overrides();
8491 let config = ConfigToml {
8492 provider: ProviderKind::Deepseek,
8493 model: Some("deepseek-v4-pro".to_string()),
8494 harness_profiles: vec![HarnessProfile {
8495 provider_route: "deepseek".to_string(),
8496 model_pattern: "deepseek-v4-*".to_string(),
8497 posture: HarnessPosture::lean(),
8498 }],
8499 ..ConfigToml::default()
8500 };
8501
8502 let profile = config
8503 .resolve_harness_profile("deepseek", "deepseek-v4-pro")
8504 .expect("profile should resolve for display/future runtime");
8505 assert_eq!(profile.posture.kind, HarnessPostureKind::Lean);
8506
8507 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
8508 assert_eq!(resolved.provider, ProviderKind::Deepseek);
8509 assert_eq!(resolved.model, "deepseek-v4-pro");
8510 }
8511
8512 #[test]
8513 fn harness_posture_kind_rejects_unknown_values() {
8514 let err = toml::from_str::<ConfigToml>(
8515 r#"
8516[[harness_profiles]]
8517provider_route = "deepseek"
8518model_pattern = "deepseek-v4.*"
8519
8520[harness_profiles.posture]
8521kind = "cahce-heavy"
8522"#,
8523 )
8524 .expect_err("misspelled kind should not deserialize as custom");
8525
8526 assert!(err.to_string().contains("cahce-heavy"));
8527 }
8528
8529 #[test]
8530 fn harness_posture_rejects_unknown_policy_keys() {
8531 let err = toml::from_str::<ConfigToml>(
8532 r#"
8533[[harness_profiles]]
8534provider_route = "deepseek"
8535model_pattern = "deepseek-v4.*"
8536
8537[harness_profiles.posture]
8538kind = "custom"
8539unknown_policy = "surprise"
8540"#,
8541 )
8542 .expect_err("unknown posture keys should not be ignored");
8543
8544 assert!(err.to_string().contains("unknown_policy"));
8545 }
8546
8547 #[test]
8548 fn test_verbosity_resolution() {
8549 let _lock = env_lock();
8550 let toml_str = r#"
8552 verbosity = "concise"
8553 "#;
8554 let config: ConfigToml = toml::from_str(toml_str).unwrap();
8555 assert_eq!(config.verbosity, Some("concise".to_string()));
8556
8557 let _env = EnvGuard::without_deepseek_runtime_overrides();
8559 unsafe {
8560 std::env::set_var("CODEWHALE_VERBOSITY", "normal");
8561 }
8562 let env_overrides = EnvRuntimeOverrides::load();
8563 assert_eq!(env_overrides.verbosity, Some("normal".to_string()));
8564 unsafe {
8565 std::env::remove_var("CODEWHALE_VERBOSITY");
8566 }
8567
8568 unsafe {
8570 std::env::set_var("DEEPSEEK_VERBOSITY", "concise");
8571 }
8572 let env_overrides = EnvRuntimeOverrides::load();
8573 assert_eq!(env_overrides.verbosity, Some("concise".to_string()));
8574 unsafe {
8575 std::env::remove_var("DEEPSEEK_VERBOSITY");
8576 }
8577 }
8578}