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