1use std::collections::BTreeMap;
2use std::fs;
3#[cfg(unix)]
4use std::io::Write;
5use std::path::{Component, Path, PathBuf};
6use std::sync::OnceLock;
7
8use anyhow::{Context, Result, bail};
9use codewhale_secrets::SecretSource;
10pub use codewhale_secrets::Secrets;
11use serde::{Deserialize, Serialize};
12
13#[cfg(unix)]
14use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
15
16pub const CONFIG_FILE_NAME: &str = "config.toml";
17const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro";
18const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
19const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
20const DEFAULT_OPENAI_MODEL: &str = "deepseek-v4-pro";
21const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta";
22const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
23const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
24const DEFAULT_ATLASCLOUD_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
25const DEFAULT_ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1";
26const DEFAULT_WANJIE_ARK_MODEL: &str = "deepseek-reasoner";
27const DEFAULT_WANJIE_ARK_BASE_URL: &str = "https://maas-openapi.wanjiedata.com/api/v1";
28const DEFAULT_VOLCENGINE_MODEL: &str = "DeepSeek-V4-Pro";
29const DEFAULT_VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/coding/v3";
30const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro";
31const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
32const OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL: &str = "arcee-ai/trinity-large-thinking";
33const OPENROUTER_GEMMA_4_31B_MODEL: &str = "google/gemma-4-31b-it";
34const OPENROUTER_GEMMA_4_26B_A4B_MODEL: &str = "google/gemma-4-26b-a4b-it";
35const OPENROUTER_GLM_5_1_MODEL: &str = "z-ai/glm-5.1";
36const OPENROUTER_KIMI_K2_6_MODEL: &str = "moonshotai/kimi-k2.6";
37const OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL: &str =
38 "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free";
39const OPENROUTER_QWEN_3_6_35B_A3B_MODEL: &str = "qwen/qwen3.6-35b-a3b";
40const OPENROUTER_QWEN_3_6_27B_MODEL: &str = "qwen/qwen3.6-27b";
41const OPENROUTER_TENCENT_HY3_PREVIEW_MODEL: &str = "tencent/hy3-preview";
42const OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL: &str = "xiaomi/mimo-v2.5-pro";
43const OPENROUTER_XIAOMI_MIMO_V2_5_MODEL: &str = "xiaomi/mimo-v2.5";
44const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro";
45const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro";
46const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
47const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro";
48const DEFAULT_SILICONFLOW_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
49const DEFAULT_SILICONFLOW_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
50const DEFAULT_MOONSHOT_MODEL: &str = "kimi-k2.6";
51const DEFAULT_MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1";
52const DEFAULT_KIMI_CODE_MODEL: &str = "kimi-for-coding";
53const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding/v1";
54const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
55const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
56const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
57const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://api.xiaomimimo.com/v1";
58const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/v1";
59const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
60const DEFAULT_SILICONFLOW_BASE_URL: &str = "https://api.siliconflow.com/v1";
61const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1";
62const DEFAULT_VLLM_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
63const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
64const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1";
65const DEFAULT_OLLAMA_MODEL: &str = "deepseek-coder:1.3b";
66const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
67
68#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
69#[serde(rename_all = "kebab-case")]
70pub enum ProviderKind {
71 #[default]
72 #[serde(
73 alias = "deepseek-cn",
74 alias = "deepseek_china",
75 alias = "deepseekcn",
76 alias = "deepseek-china"
77 )]
78 Deepseek,
79 NvidiaNim,
80 #[serde(alias = "open-ai")]
81 Openai,
82 Atlascloud,
83 #[serde(
84 alias = "wanjie",
85 alias = "wanjie_ark",
86 alias = "ark-wanjie",
87 alias = "ark_wanjie",
88 alias = "wanjie-maas",
89 alias = "wanjie_maas"
90 )]
91 WanjieArk,
92 #[serde(alias = "volcengine-ark", alias = "volcengine_ark", alias = "ark")]
93 Volcengine,
94 Openrouter,
95 #[serde(alias = "mimo", alias = "xiaomi", alias = "xiaomi_mimo")]
96 XiaomiMimo,
97 Novita,
98 Fireworks,
99 #[serde(alias = "silicon-flow", alias = "silicon_flow")]
100 Siliconflow,
101 Moonshot,
102 Sglang,
103 Vllm,
104 Ollama,
105}
106
107impl ProviderKind {
108 #[must_use]
109 pub fn as_str(self) -> &'static str {
110 match self {
111 Self::Deepseek => "deepseek",
112 Self::NvidiaNim => "nvidia-nim",
113 Self::Openai => "openai",
114 Self::Atlascloud => "atlascloud",
115 Self::WanjieArk => "wanjie-ark",
116 Self::Volcengine => "volcengine",
117 Self::Openrouter => "openrouter",
118 Self::XiaomiMimo => "xiaomi-mimo",
119 Self::Novita => "novita",
120 Self::Fireworks => "fireworks",
121 Self::Siliconflow => "siliconflow",
122 Self::Moonshot => "moonshot",
123 Self::Sglang => "sglang",
124 Self::Vllm => "vllm",
125 Self::Ollama => "ollama",
126 }
127 }
128
129 #[must_use]
130 pub fn parse(value: &str) -> Option<Self> {
131 match value.trim().to_ascii_lowercase().as_str() {
132 "deepseek" | "deep-seek" | "deepseek-cn" | "deepseek_china" | "deepseekcn"
133 | "deepseek-china" => Some(Self::Deepseek),
134 "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => Some(Self::NvidiaNim),
135 "openai" | "open-ai" => Some(Self::Openai),
136 "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => Some(Self::Atlascloud),
137 "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
138 | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => Some(Self::WanjieArk),
139 "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark"
140 | "volcengineark" => Some(Self::Volcengine),
141 "openrouter" | "open_router" => Some(Self::Openrouter),
142 "xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
143 Some(Self::XiaomiMimo)
144 }
145 "novita" => Some(Self::Novita),
146 "fireworks" | "fireworks-ai" => Some(Self::Fireworks),
147 "siliconflow" | "silicon-flow" | "silicon_flow" => Some(Self::Siliconflow),
148 "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => Some(Self::Moonshot),
149 "sglang" | "sg-lang" => Some(Self::Sglang),
150 "vllm" | "v-llm" => Some(Self::Vllm),
151 "ollama" | "ollama-local" => Some(Self::Ollama),
152 _ => None,
153 }
154 }
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, Default)]
158pub struct ProviderConfigToml {
159 pub api_key: Option<String>,
160 pub base_url: Option<String>,
161 pub model: Option<String>,
162 pub auth_mode: Option<String>,
163 #[serde(default)]
164 pub http_headers: BTreeMap<String, String>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize, Default)]
168pub struct ProvidersToml {
169 #[serde(default)]
170 pub deepseek: ProviderConfigToml,
171 #[serde(default)]
172 pub nvidia_nim: ProviderConfigToml,
173 #[serde(default)]
174 pub openai: ProviderConfigToml,
175 #[serde(default)]
176 pub atlascloud: ProviderConfigToml,
177 #[serde(default)]
178 pub wanjie_ark: ProviderConfigToml,
179 #[serde(default)]
180 pub volcengine: ProviderConfigToml,
181 #[serde(default)]
182 pub openrouter: ProviderConfigToml,
183 #[serde(default)]
184 pub xiaomi_mimo: ProviderConfigToml,
185 #[serde(default)]
186 pub novita: ProviderConfigToml,
187 #[serde(default)]
188 pub fireworks: ProviderConfigToml,
189 #[serde(default)]
190 pub siliconflow: ProviderConfigToml,
191 #[serde(default)]
192 pub moonshot: ProviderConfigToml,
193 #[serde(default)]
194 pub sglang: ProviderConfigToml,
195 #[serde(default)]
196 pub vllm: ProviderConfigToml,
197 #[serde(default)]
198 pub ollama: ProviderConfigToml,
199}
200
201impl ProvidersToml {
202 #[must_use]
203 pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
204 match provider {
205 ProviderKind::Deepseek => &self.deepseek,
206 ProviderKind::NvidiaNim => &self.nvidia_nim,
207 ProviderKind::Openai => &self.openai,
208 ProviderKind::Atlascloud => &self.atlascloud,
209 ProviderKind::WanjieArk => &self.wanjie_ark,
210 ProviderKind::Volcengine => &self.volcengine,
211 ProviderKind::Openrouter => &self.openrouter,
212 ProviderKind::XiaomiMimo => &self.xiaomi_mimo,
213 ProviderKind::Novita => &self.novita,
214 ProviderKind::Fireworks => &self.fireworks,
215 ProviderKind::Siliconflow => &self.siliconflow,
216 ProviderKind::Moonshot => &self.moonshot,
217 ProviderKind::Sglang => &self.sglang,
218 ProviderKind::Vllm => &self.vllm,
219 ProviderKind::Ollama => &self.ollama,
220 }
221 }
222
223 pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
224 match provider {
225 ProviderKind::Deepseek => &mut self.deepseek,
226 ProviderKind::NvidiaNim => &mut self.nvidia_nim,
227 ProviderKind::Openai => &mut self.openai,
228 ProviderKind::Atlascloud => &mut self.atlascloud,
229 ProviderKind::WanjieArk => &mut self.wanjie_ark,
230 ProviderKind::Volcengine => &mut self.volcengine,
231 ProviderKind::Openrouter => &mut self.openrouter,
232 ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo,
233 ProviderKind::Novita => &mut self.novita,
234 ProviderKind::Fireworks => &mut self.fireworks,
235 ProviderKind::Siliconflow => &mut self.siliconflow,
236 ProviderKind::Moonshot => &mut self.moonshot,
237 ProviderKind::Sglang => &mut self.sglang,
238 ProviderKind::Vllm => &mut self.vllm,
239 ProviderKind::Ollama => &mut self.ollama,
240 }
241 }
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, Default)]
245pub struct ConfigToml {
246 pub api_key: Option<String>,
249 pub base_url: Option<String>,
251 #[serde(default)]
253 pub http_headers: BTreeMap<String, String>,
254 pub default_text_model: Option<String>,
256 #[serde(default)]
257 pub provider: ProviderKind,
258 pub model: Option<String>,
259 pub auth_mode: Option<String>,
260 pub output_mode: Option<String>,
261 pub log_level: Option<String>,
262 pub telemetry: Option<bool>,
263 pub approval_policy: Option<String>,
264 pub sandbox_mode: Option<String>,
265 #[serde(default)]
267 pub tools: Option<ToolsToml>,
268 #[serde(default)]
269 pub providers: ProvidersToml,
270 #[serde(default)]
273 pub network: Option<NetworkPolicyToml>,
274 #[serde(default)]
278 pub skills: Option<SkillsToml>,
279 #[serde(default)]
282 pub snapshots: Option<SnapshotsToml>,
283 #[serde(default)]
286 pub lsp: Option<LspConfigToml>,
287 #[serde(default)]
290 pub hook_sinks: Option<HookSinksToml>,
291 #[serde(flatten)]
292 pub extras: BTreeMap<String, toml::Value>,
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize, Default)]
297pub struct HookSinksToml {
298 #[serde(default)]
303 pub unix_socket_path: Option<PathBuf>,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize, Default)]
309pub struct SkillsToml {
310 #[serde(default)]
313 pub registry_url: Option<String>,
314 #[serde(default)]
317 pub max_install_size_bytes: Option<u64>,
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize, Default)]
322pub struct ToolsToml {
323 #[serde(default)]
325 pub always_load: Vec<String>,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct SnapshotsToml {
332 #[serde(default = "default_snapshots_enabled")]
333 pub enabled: bool,
334 #[serde(default = "default_snapshot_max_age_days")]
335 pub max_age_days: u64,
336}
337
338fn default_snapshots_enabled() -> bool {
339 true
340}
341
342fn default_snapshot_max_age_days() -> u64 {
343 7
344}
345
346impl Default for SnapshotsToml {
347 fn default() -> Self {
348 Self {
349 enabled: default_snapshots_enabled(),
350 max_age_days: default_snapshot_max_age_days(),
351 }
352 }
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct NetworkPolicyToml {
359 #[serde(default = "default_network_decision")]
362 pub default: String,
363 #[serde(default)]
366 pub allow: Vec<String>,
367 #[serde(default)]
369 pub deny: Vec<String>,
370 #[serde(default)]
373 pub proxy: Vec<String>,
374 #[serde(default = "default_network_audit")]
376 pub audit: bool,
377}
378
379fn default_network_decision() -> String {
380 "prompt".to_string()
381}
382
383fn default_network_audit() -> bool {
384 true
385}
386
387impl Default for NetworkPolicyToml {
388 fn default() -> Self {
389 Self {
390 default: default_network_decision(),
391 allow: Vec::new(),
392 deny: Vec::new(),
393 proxy: Vec::new(),
394 audit: default_network_audit(),
395 }
396 }
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize, Default)]
403pub struct LspConfigToml {
404 pub enabled: Option<bool>,
406 pub poll_after_edit_ms: Option<u64>,
408 pub max_diagnostics_per_file: Option<usize>,
410 pub include_warnings: Option<bool>,
412 pub servers: Option<BTreeMap<String, Vec<String>>>,
414}
415
416impl ConfigToml {
417 pub fn merge_project_overrides(&mut self, project: ConfigToml) {
426 if project.default_text_model.is_some() {
427 self.default_text_model = project.default_text_model;
428 }
429 if project.model.is_some() {
430 self.model = project.model;
431 }
432 if project.output_mode.is_some() {
433 self.output_mode = project.output_mode;
434 }
435 if project.log_level.is_some() {
436 self.log_level = project.log_level;
437 }
438 if let Some(policy) = project.approval_policy
439 && project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy)
440 {
441 self.approval_policy = Some(policy);
442 }
443 if let Some(mode) = project.sandbox_mode
444 && project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode)
445 {
446 self.sandbox_mode = Some(mode);
447 }
448 if project.tools.is_some() {
449 self.tools = project.tools;
450 }
451 merge_project_provider_config(&mut self.providers.deepseek, &project.providers.deepseek);
452 merge_project_provider_config(
453 &mut self.providers.nvidia_nim,
454 &project.providers.nvidia_nim,
455 );
456 merge_project_provider_config(&mut self.providers.openai, &project.providers.openai);
457 merge_project_provider_config(
458 &mut self.providers.atlascloud,
459 &project.providers.atlascloud,
460 );
461 merge_project_provider_config(
462 &mut self.providers.wanjie_ark,
463 &project.providers.wanjie_ark,
464 );
465 merge_project_provider_config(
466 &mut self.providers.openrouter,
467 &project.providers.openrouter,
468 );
469 merge_project_provider_config(
470 &mut self.providers.xiaomi_mimo,
471 &project.providers.xiaomi_mimo,
472 );
473 merge_project_provider_config(&mut self.providers.novita, &project.providers.novita);
474 merge_project_provider_config(&mut self.providers.fireworks, &project.providers.fireworks);
475 merge_project_provider_config(
476 &mut self.providers.siliconflow,
477 &project.providers.siliconflow,
478 );
479 merge_project_provider_config(&mut self.providers.sglang, &project.providers.sglang);
480 merge_project_provider_config(&mut self.providers.vllm, &project.providers.vllm);
481 merge_project_provider_config(&mut self.providers.ollama, &project.providers.ollama);
482 }
483
484 #[must_use]
485 pub fn get_value(&self, key: &str) -> Option<String> {
486 match key {
487 "provider" => Some(self.provider.as_str().to_string()),
488 "api_key" => self.api_key.clone(),
489 "base_url" => self.base_url.clone(),
490 "http_headers" => serialize_http_headers(&self.http_headers),
491 "default_text_model" => self.default_text_model.clone(),
492 "model" => self.model.clone(),
493 "auth.mode" => self.auth_mode.clone(),
494 "output_mode" => self.output_mode.clone(),
495 "log_level" => self.log_level.clone(),
496 "telemetry" => self.telemetry.map(|v| v.to_string()),
497 "approval_policy" => self.approval_policy.clone(),
498 "sandbox_mode" => self.sandbox_mode.clone(),
499 "tools.always_load" => self.tools.as_ref().map(|tools| tools.always_load.join(",")),
500 "hook_sinks.unix_socket_path" => self
501 .hook_sinks
502 .as_ref()
503 .and_then(|sinks| sinks.unix_socket_path.as_ref())
504 .map(|path| path.display().to_string()),
505 "providers.deepseek.api_key" => self.providers.deepseek.api_key.clone(),
506 "providers.deepseek.base_url" => self.providers.deepseek.base_url.clone(),
507 "providers.deepseek.model" => self.providers.deepseek.model.clone(),
508 "providers.deepseek.http_headers" => {
509 serialize_http_headers(&self.providers.deepseek.http_headers)
510 }
511 "providers.nvidia_nim.api_key" => self.providers.nvidia_nim.api_key.clone(),
512 "providers.nvidia_nim.base_url" => self.providers.nvidia_nim.base_url.clone(),
513 "providers.nvidia_nim.model" => self.providers.nvidia_nim.model.clone(),
514 "providers.nvidia_nim.http_headers" => {
515 serialize_http_headers(&self.providers.nvidia_nim.http_headers)
516 }
517 "providers.openai.api_key" => self.providers.openai.api_key.clone(),
518 "providers.openai.base_url" => self.providers.openai.base_url.clone(),
519 "providers.openai.model" => self.providers.openai.model.clone(),
520 "providers.openai.http_headers" => {
521 serialize_http_headers(&self.providers.openai.http_headers)
522 }
523 "providers.atlascloud.api_key" => self.providers.atlascloud.api_key.clone(),
524 "providers.atlascloud.base_url" => self.providers.atlascloud.base_url.clone(),
525 "providers.atlascloud.model" => self.providers.atlascloud.model.clone(),
526 "providers.atlascloud.http_headers" => {
527 serialize_http_headers(&self.providers.atlascloud.http_headers)
528 }
529 "providers.wanjie_ark.api_key" => self.providers.wanjie_ark.api_key.clone(),
530 "providers.wanjie_ark.base_url" => self.providers.wanjie_ark.base_url.clone(),
531 "providers.wanjie_ark.model" => self.providers.wanjie_ark.model.clone(),
532 "providers.volcengine.api_key" => self.providers.volcengine.api_key.clone(),
533 "providers.volcengine.base_url" => self.providers.volcengine.base_url.clone(),
534 "providers.volcengine.model" => self.providers.volcengine.model.clone(),
535 "providers.wanjie_ark.http_headers" => {
536 serialize_http_headers(&self.providers.wanjie_ark.http_headers)
537 }
538 "providers.openrouter.api_key" => self.providers.openrouter.api_key.clone(),
539 "providers.openrouter.base_url" => self.providers.openrouter.base_url.clone(),
540 "providers.openrouter.model" => self.providers.openrouter.model.clone(),
541 "providers.openrouter.http_headers" => {
542 serialize_http_headers(&self.providers.openrouter.http_headers)
543 }
544 "providers.xiaomi_mimo.api_key" => self.providers.xiaomi_mimo.api_key.clone(),
545 "providers.xiaomi_mimo.base_url" => self.providers.xiaomi_mimo.base_url.clone(),
546 "providers.xiaomi_mimo.model" => self.providers.xiaomi_mimo.model.clone(),
547 "providers.xiaomi_mimo.http_headers" => {
548 serialize_http_headers(&self.providers.xiaomi_mimo.http_headers)
549 }
550 "providers.novita.api_key" => self.providers.novita.api_key.clone(),
551 "providers.novita.base_url" => self.providers.novita.base_url.clone(),
552 "providers.novita.model" => self.providers.novita.model.clone(),
553 "providers.novita.http_headers" => {
554 serialize_http_headers(&self.providers.novita.http_headers)
555 }
556 "providers.fireworks.api_key" => self.providers.fireworks.api_key.clone(),
557 "providers.fireworks.base_url" => self.providers.fireworks.base_url.clone(),
558 "providers.fireworks.model" => self.providers.fireworks.model.clone(),
559 "providers.fireworks.http_headers" => {
560 serialize_http_headers(&self.providers.fireworks.http_headers)
561 }
562 "providers.siliconflow.api_key" => self.providers.siliconflow.api_key.clone(),
563 "providers.siliconflow.base_url" => self.providers.siliconflow.base_url.clone(),
564 "providers.siliconflow.model" => self.providers.siliconflow.model.clone(),
565 "providers.siliconflow.http_headers" => {
566 serialize_http_headers(&self.providers.siliconflow.http_headers)
567 }
568 "providers.moonshot.api_key" => self.providers.moonshot.api_key.clone(),
569 "providers.moonshot.base_url" => self.providers.moonshot.base_url.clone(),
570 "providers.moonshot.model" => self.providers.moonshot.model.clone(),
571 "providers.moonshot.auth_mode" => self.providers.moonshot.auth_mode.clone(),
572 "providers.moonshot.http_headers" => {
573 serialize_http_headers(&self.providers.moonshot.http_headers)
574 }
575 "providers.sglang.api_key" => self.providers.sglang.api_key.clone(),
576 "providers.sglang.base_url" => self.providers.sglang.base_url.clone(),
577 "providers.sglang.model" => self.providers.sglang.model.clone(),
578 "providers.sglang.http_headers" => {
579 serialize_http_headers(&self.providers.sglang.http_headers)
580 }
581 "providers.vllm.api_key" => self.providers.vllm.api_key.clone(),
582 "providers.vllm.base_url" => self.providers.vllm.base_url.clone(),
583 "providers.vllm.model" => self.providers.vllm.model.clone(),
584 "providers.vllm.http_headers" => {
585 serialize_http_headers(&self.providers.vllm.http_headers)
586 }
587 "providers.ollama.api_key" => self.providers.ollama.api_key.clone(),
588 "providers.ollama.base_url" => self.providers.ollama.base_url.clone(),
589 "providers.ollama.model" => self.providers.ollama.model.clone(),
590 "providers.ollama.http_headers" => {
591 serialize_http_headers(&self.providers.ollama.http_headers)
592 }
593 _ => self.extras.get(key).map(toml::Value::to_string),
594 }
595 }
596
597 #[must_use]
598 pub fn get_display_value(&self, key: &str) -> Option<String> {
599 self.get_value(key).map(|value| {
600 if is_sensitive_config_key(key) {
601 redact_secret(&value)
602 } else {
603 value
604 }
605 })
606 }
607
608 pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
609 match key {
610 "provider" => {
611 self.provider = ProviderKind::parse(value)
612 .with_context(|| format!("unknown provider '{value}'"))?;
613 }
614 "api_key" => self.api_key = Some(value.to_string()),
615 "base_url" => self.base_url = Some(value.to_string()),
616 "http_headers" => self.http_headers = parse_http_headers(value)?,
617 "default_text_model" => self.default_text_model = Some(value.to_string()),
618 "model" => self.model = Some(value.to_string()),
619 "auth.mode" => self.auth_mode = Some(value.to_string()),
620 "output_mode" => self.output_mode = Some(value.to_string()),
621 "log_level" => self.log_level = Some(value.to_string()),
622 "telemetry" => {
623 self.telemetry = Some(parse_bool(value)?);
624 }
625 "approval_policy" => self.approval_policy = Some(value.to_string()),
626 "sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
627 "hook_sinks.unix_socket_path" => {
628 self.hook_sinks
629 .get_or_insert_with(HookSinksToml::default)
630 .unix_socket_path = Some(PathBuf::from(value));
631 }
632 "providers.deepseek.api_key" => {
633 let value = value.to_string();
634 self.providers.deepseek.api_key = Some(value.clone());
635 self.api_key = Some(value);
636 }
637 "providers.deepseek.base_url" => {
638 let value = value.to_string();
639 self.providers.deepseek.base_url = Some(value.clone());
640 self.base_url = Some(value);
641 }
642 "providers.deepseek.model" => {
643 let value = value.to_string();
644 self.providers.deepseek.model = Some(value.clone());
645 self.default_text_model = Some(value);
646 }
647 "providers.deepseek.http_headers" => {
648 let headers = parse_http_headers(value)?;
649 self.providers.deepseek.http_headers = headers.clone();
650 self.http_headers = headers;
651 }
652 "providers.openai.api_key" => self.providers.openai.api_key = Some(value.to_string()),
653 "providers.openai.base_url" => self.providers.openai.base_url = Some(value.to_string()),
654 "providers.openai.model" => self.providers.openai.model = Some(value.to_string()),
655 "providers.openai.http_headers" => {
656 self.providers.openai.http_headers = parse_http_headers(value)?;
657 }
658 "providers.atlascloud.api_key" => {
659 self.providers.atlascloud.api_key = Some(value.to_string());
660 }
661 "providers.atlascloud.base_url" => {
662 self.providers.atlascloud.base_url = Some(value.to_string());
663 }
664 "providers.atlascloud.model" => {
665 self.providers.atlascloud.model = Some(value.to_string());
666 }
667 "providers.atlascloud.http_headers" => {
668 self.providers.atlascloud.http_headers = parse_http_headers(value)?;
669 }
670 "providers.wanjie_ark.api_key" => {
671 self.providers.wanjie_ark.api_key = Some(value.to_string());
672 }
673 "providers.wanjie_ark.base_url" => {
674 self.providers.wanjie_ark.base_url = Some(value.to_string());
675 }
676 "providers.wanjie_ark.model" => {
677 self.providers.wanjie_ark.model = Some(value.to_string());
678 }
679 "providers.volcengine.api_key" => {
680 self.providers.volcengine.api_key = Some(value.to_string());
681 }
682 "providers.volcengine.base_url" => {
683 self.providers.volcengine.base_url = Some(value.to_string());
684 }
685 "providers.volcengine.model" => {
686 self.providers.volcengine.model = Some(value.to_string());
687 }
688 "providers.wanjie_ark.http_headers" => {
689 self.providers.wanjie_ark.http_headers = parse_http_headers(value)?;
690 }
691 "providers.nvidia_nim.api_key" => {
692 self.providers.nvidia_nim.api_key = Some(value.to_string());
693 }
694 "providers.nvidia_nim.base_url" => {
695 self.providers.nvidia_nim.base_url = Some(value.to_string());
696 }
697 "providers.nvidia_nim.model" => {
698 self.providers.nvidia_nim.model = Some(value.to_string());
699 }
700 "providers.nvidia_nim.http_headers" => {
701 self.providers.nvidia_nim.http_headers = parse_http_headers(value)?;
702 }
703 "providers.openrouter.api_key" => {
704 self.providers.openrouter.api_key = Some(value.to_string());
705 }
706 "providers.openrouter.base_url" => {
707 self.providers.openrouter.base_url = Some(value.to_string());
708 }
709 "providers.openrouter.model" => {
710 self.providers.openrouter.model = Some(value.to_string());
711 }
712 "providers.openrouter.http_headers" => {
713 self.providers.openrouter.http_headers = parse_http_headers(value)?;
714 }
715 "providers.xiaomi_mimo.api_key" => {
716 self.providers.xiaomi_mimo.api_key = Some(value.to_string());
717 }
718 "providers.xiaomi_mimo.base_url" => {
719 self.providers.xiaomi_mimo.base_url = Some(value.to_string());
720 }
721 "providers.xiaomi_mimo.model" => {
722 self.providers.xiaomi_mimo.model = Some(value.to_string());
723 }
724 "providers.xiaomi_mimo.http_headers" => {
725 self.providers.xiaomi_mimo.http_headers = parse_http_headers(value)?;
726 }
727 "providers.novita.api_key" => {
728 self.providers.novita.api_key = Some(value.to_string());
729 }
730 "providers.novita.base_url" => {
731 self.providers.novita.base_url = Some(value.to_string());
732 }
733 "providers.novita.model" => {
734 self.providers.novita.model = Some(value.to_string());
735 }
736 "providers.novita.http_headers" => {
737 self.providers.novita.http_headers = parse_http_headers(value)?;
738 }
739 "providers.fireworks.api_key" => {
740 self.providers.fireworks.api_key = Some(value.to_string());
741 }
742 "providers.fireworks.base_url" => {
743 self.providers.fireworks.base_url = Some(value.to_string());
744 }
745 "providers.fireworks.model" => {
746 self.providers.fireworks.model = Some(value.to_string());
747 }
748 "providers.fireworks.http_headers" => {
749 self.providers.fireworks.http_headers = parse_http_headers(value)?;
750 }
751 "providers.siliconflow.api_key" => {
752 self.providers.siliconflow.api_key = Some(value.to_string());
753 }
754 "providers.siliconflow.base_url" => {
755 self.providers.siliconflow.base_url = Some(value.to_string());
756 }
757 "providers.siliconflow.model" => {
758 self.providers.siliconflow.model = Some(value.to_string());
759 }
760 "providers.siliconflow.http_headers" => {
761 self.providers.siliconflow.http_headers = parse_http_headers(value)?;
762 }
763 "providers.moonshot.api_key" => {
764 self.providers.moonshot.api_key = Some(value.to_string());
765 }
766 "providers.moonshot.base_url" => {
767 self.providers.moonshot.base_url = Some(value.to_string());
768 }
769 "providers.moonshot.model" => {
770 self.providers.moonshot.model = Some(value.to_string());
771 }
772 "providers.moonshot.auth_mode" => {
773 self.providers.moonshot.auth_mode = Some(value.to_string());
774 }
775 "providers.moonshot.http_headers" => {
776 self.providers.moonshot.http_headers = parse_http_headers(value)?;
777 }
778 "providers.sglang.api_key" => {
779 self.providers.sglang.api_key = Some(value.to_string());
780 }
781 "providers.sglang.base_url" => {
782 self.providers.sglang.base_url = Some(value.to_string());
783 }
784 "providers.sglang.model" => {
785 self.providers.sglang.model = Some(value.to_string());
786 }
787 "providers.sglang.http_headers" => {
788 self.providers.sglang.http_headers = parse_http_headers(value)?;
789 }
790 "providers.vllm.api_key" => {
791 self.providers.vllm.api_key = Some(value.to_string());
792 }
793 "providers.vllm.base_url" => {
794 self.providers.vllm.base_url = Some(value.to_string());
795 }
796 "providers.vllm.model" => {
797 self.providers.vllm.model = Some(value.to_string());
798 }
799 "providers.vllm.http_headers" => {
800 self.providers.vllm.http_headers = parse_http_headers(value)?;
801 }
802 "providers.ollama.api_key" => {
803 self.providers.ollama.api_key = Some(value.to_string());
804 }
805 "providers.ollama.base_url" => {
806 self.providers.ollama.base_url = Some(value.to_string());
807 }
808 "providers.ollama.model" => {
809 self.providers.ollama.model = Some(value.to_string());
810 }
811 "providers.ollama.http_headers" => {
812 self.providers.ollama.http_headers = parse_http_headers(value)?;
813 }
814 _ => {
815 self.extras
816 .insert(key.to_string(), toml::Value::String(value.to_string()));
817 }
818 }
819 Ok(())
820 }
821
822 pub fn unset_value(&mut self, key: &str) -> Result<()> {
823 match key {
824 "provider" => self.provider = ProviderKind::Deepseek,
825 "api_key" => self.api_key = None,
826 "base_url" => self.base_url = None,
827 "http_headers" => self.http_headers.clear(),
828 "default_text_model" => self.default_text_model = None,
829 "model" => self.model = None,
830 "auth.mode" => self.auth_mode = None,
831 "output_mode" => self.output_mode = None,
832 "log_level" => self.log_level = None,
833 "telemetry" => self.telemetry = None,
834 "approval_policy" => self.approval_policy = None,
835 "sandbox_mode" => self.sandbox_mode = None,
836 "hook_sinks.unix_socket_path" => {
837 if let Some(sinks) = self.hook_sinks.as_mut() {
838 sinks.unix_socket_path = None;
839 }
840 }
841 "providers.deepseek.api_key" => {
842 self.providers.deepseek.api_key = None;
843 self.api_key = None;
844 }
845 "providers.deepseek.base_url" => {
846 self.providers.deepseek.base_url = None;
847 self.base_url = None;
848 }
849 "providers.deepseek.model" => {
850 self.providers.deepseek.model = None;
851 self.default_text_model = None;
852 }
853 "providers.deepseek.http_headers" => {
854 self.providers.deepseek.http_headers.clear();
855 self.http_headers.clear();
856 }
857 "providers.openai.api_key" => self.providers.openai.api_key = None,
858 "providers.openai.base_url" => self.providers.openai.base_url = None,
859 "providers.openai.model" => self.providers.openai.model = None,
860 "providers.openai.http_headers" => self.providers.openai.http_headers.clear(),
861 "providers.atlascloud.api_key" => self.providers.atlascloud.api_key = None,
862 "providers.atlascloud.base_url" => self.providers.atlascloud.base_url = None,
863 "providers.atlascloud.model" => self.providers.atlascloud.model = None,
864 "providers.atlascloud.http_headers" => self.providers.atlascloud.http_headers.clear(),
865 "providers.wanjie_ark.api_key" => self.providers.wanjie_ark.api_key = None,
866 "providers.wanjie_ark.base_url" => self.providers.wanjie_ark.base_url = None,
867 "providers.wanjie_ark.model" => self.providers.wanjie_ark.model = None,
868 "providers.volcengine.api_key" => self.providers.volcengine.api_key = None,
869 "providers.volcengine.base_url" => self.providers.volcengine.base_url = None,
870 "providers.volcengine.model" => self.providers.volcengine.model = None,
871 "providers.wanjie_ark.http_headers" => {
872 self.providers.wanjie_ark.http_headers.clear();
873 }
874 "providers.nvidia_nim.api_key" => self.providers.nvidia_nim.api_key = None,
875 "providers.nvidia_nim.base_url" => self.providers.nvidia_nim.base_url = None,
876 "providers.nvidia_nim.model" => self.providers.nvidia_nim.model = None,
877 "providers.nvidia_nim.http_headers" => self.providers.nvidia_nim.http_headers.clear(),
878 "providers.openrouter.api_key" => self.providers.openrouter.api_key = None,
879 "providers.openrouter.base_url" => self.providers.openrouter.base_url = None,
880 "providers.openrouter.model" => self.providers.openrouter.model = None,
881 "providers.openrouter.http_headers" => self.providers.openrouter.http_headers.clear(),
882 "providers.xiaomi_mimo.api_key" => self.providers.xiaomi_mimo.api_key = None,
883 "providers.xiaomi_mimo.base_url" => self.providers.xiaomi_mimo.base_url = None,
884 "providers.xiaomi_mimo.model" => self.providers.xiaomi_mimo.model = None,
885 "providers.xiaomi_mimo.http_headers" => {
886 self.providers.xiaomi_mimo.http_headers.clear();
887 }
888 "providers.novita.api_key" => self.providers.novita.api_key = None,
889 "providers.novita.base_url" => self.providers.novita.base_url = None,
890 "providers.novita.model" => self.providers.novita.model = None,
891 "providers.novita.http_headers" => self.providers.novita.http_headers.clear(),
892 "providers.fireworks.api_key" => self.providers.fireworks.api_key = None,
893 "providers.fireworks.base_url" => self.providers.fireworks.base_url = None,
894 "providers.fireworks.model" => self.providers.fireworks.model = None,
895 "providers.fireworks.http_headers" => self.providers.fireworks.http_headers.clear(),
896 "providers.siliconflow.api_key" => self.providers.siliconflow.api_key = None,
897 "providers.siliconflow.base_url" => self.providers.siliconflow.base_url = None,
898 "providers.siliconflow.model" => self.providers.siliconflow.model = None,
899 "providers.siliconflow.http_headers" => {
900 self.providers.siliconflow.http_headers.clear();
901 }
902 "providers.moonshot.api_key" => self.providers.moonshot.api_key = None,
903 "providers.moonshot.base_url" => self.providers.moonshot.base_url = None,
904 "providers.moonshot.model" => self.providers.moonshot.model = None,
905 "providers.moonshot.auth_mode" => self.providers.moonshot.auth_mode = None,
906 "providers.moonshot.http_headers" => self.providers.moonshot.http_headers.clear(),
907 "providers.sglang.api_key" => self.providers.sglang.api_key = None,
908 "providers.sglang.base_url" => self.providers.sglang.base_url = None,
909 "providers.sglang.model" => self.providers.sglang.model = None,
910 "providers.sglang.http_headers" => self.providers.sglang.http_headers.clear(),
911 "providers.vllm.api_key" => self.providers.vllm.api_key = None,
912 "providers.vllm.base_url" => self.providers.vllm.base_url = None,
913 "providers.vllm.model" => self.providers.vllm.model = None,
914 "providers.vllm.http_headers" => self.providers.vllm.http_headers.clear(),
915 "providers.ollama.api_key" => self.providers.ollama.api_key = None,
916 "providers.ollama.base_url" => self.providers.ollama.base_url = None,
917 "providers.ollama.model" => self.providers.ollama.model = None,
918 "providers.ollama.http_headers" => self.providers.ollama.http_headers.clear(),
919 _ => {
920 self.extras.remove(key);
921 }
922 }
923 Ok(())
924 }
925
926 #[must_use]
927 pub fn list_values(&self) -> BTreeMap<String, String> {
928 let mut out = BTreeMap::new();
929 out.insert("provider".to_string(), self.provider.as_str().to_string());
930
931 if let Some(v) = self.api_key.as_ref() {
932 out.insert("api_key".to_string(), redact_secret(v));
933 }
934 if let Some(v) = self.base_url.as_ref() {
935 out.insert("base_url".to_string(), v.clone());
936 }
937 if let Some(v) = serialize_http_headers(&self.http_headers) {
938 out.insert("http_headers".to_string(), v);
939 }
940 if let Some(v) = self.default_text_model.as_ref() {
941 out.insert("default_text_model".to_string(), v.clone());
942 }
943 if let Some(v) = self.model.as_ref() {
944 out.insert("model".to_string(), v.clone());
945 }
946 if let Some(v) = self.auth_mode.as_ref() {
947 out.insert("auth.mode".to_string(), v.clone());
948 }
949 if let Some(v) = self.output_mode.as_ref() {
950 out.insert("output_mode".to_string(), v.clone());
951 }
952 if let Some(v) = self.log_level.as_ref() {
953 out.insert("log_level".to_string(), v.clone());
954 }
955 if let Some(v) = self.telemetry {
956 out.insert("telemetry".to_string(), v.to_string());
957 }
958 if let Some(v) = self.approval_policy.as_ref() {
959 out.insert("approval_policy".to_string(), v.clone());
960 }
961 if let Some(v) = self.sandbox_mode.as_ref() {
962 out.insert("sandbox_mode".to_string(), v.clone());
963 }
964 if let Some(v) = self
965 .hook_sinks
966 .as_ref()
967 .and_then(|sinks| sinks.unix_socket_path.as_ref())
968 {
969 out.insert(
970 "hook_sinks.unix_socket_path".to_string(),
971 v.display().to_string(),
972 );
973 }
974 if let Some(v) = self.providers.deepseek.api_key.as_ref() {
975 out.insert("providers.deepseek.api_key".to_string(), redact_secret(v));
976 }
977 if let Some(v) = self.providers.deepseek.base_url.as_ref() {
978 out.insert("providers.deepseek.base_url".to_string(), v.clone());
979 }
980 if let Some(v) = self.providers.deepseek.model.as_ref() {
981 out.insert("providers.deepseek.model".to_string(), v.clone());
982 }
983 if let Some(v) = serialize_http_headers(&self.providers.deepseek.http_headers) {
984 out.insert("providers.deepseek.http_headers".to_string(), v);
985 }
986 if let Some(v) = self.providers.openai.api_key.as_ref() {
987 out.insert("providers.openai.api_key".to_string(), redact_secret(v));
988 }
989 if let Some(v) = self.providers.openai.base_url.as_ref() {
990 out.insert("providers.openai.base_url".to_string(), v.clone());
991 }
992 if let Some(v) = self.providers.openai.model.as_ref() {
993 out.insert("providers.openai.model".to_string(), v.clone());
994 }
995 if let Some(v) = serialize_http_headers(&self.providers.openai.http_headers) {
996 out.insert("providers.openai.http_headers".to_string(), v);
997 }
998 if let Some(v) = self.providers.atlascloud.api_key.as_ref() {
999 out.insert("providers.atlascloud.api_key".to_string(), redact_secret(v));
1000 }
1001 if let Some(v) = self.providers.atlascloud.base_url.as_ref() {
1002 out.insert("providers.atlascloud.base_url".to_string(), v.clone());
1003 }
1004 if let Some(v) = self.providers.atlascloud.model.as_ref() {
1005 out.insert("providers.atlascloud.model".to_string(), v.clone());
1006 }
1007 if let Some(v) = serialize_http_headers(&self.providers.atlascloud.http_headers) {
1008 out.insert("providers.atlascloud.http_headers".to_string(), v);
1009 }
1010 if let Some(v) = self.providers.volcengine.api_key.as_ref() {
1011 out.insert("providers.volcengine.api_key".to_string(), redact_secret(v));
1012 }
1013 if let Some(v) = self.providers.volcengine.base_url.as_ref() {
1014 out.insert("providers.volcengine.base_url".to_string(), v.clone());
1015 }
1016 if let Some(v) = self.providers.volcengine.model.as_ref() {
1017 out.insert("providers.volcengine.model".to_string(), v.clone());
1018 }
1019 if let Some(v) = self.providers.wanjie_ark.api_key.as_ref() {
1020 out.insert("providers.wanjie_ark.api_key".to_string(), redact_secret(v));
1021 }
1022 if let Some(v) = self.providers.wanjie_ark.base_url.as_ref() {
1023 out.insert("providers.wanjie_ark.base_url".to_string(), v.clone());
1024 }
1025 if let Some(v) = self.providers.wanjie_ark.model.as_ref() {
1026 out.insert("providers.wanjie_ark.model".to_string(), v.clone());
1027 }
1028 if let Some(v) = serialize_http_headers(&self.providers.volcengine.http_headers) {
1029 out.insert("providers.volcengine.http_headers".to_string(), v);
1030 }
1031 if let Some(v) = serialize_http_headers(&self.providers.wanjie_ark.http_headers) {
1032 out.insert("providers.wanjie_ark.http_headers".to_string(), v);
1033 }
1034 if let Some(v) = self.providers.nvidia_nim.api_key.as_ref() {
1035 out.insert("providers.nvidia_nim.api_key".to_string(), redact_secret(v));
1036 }
1037 if let Some(v) = self.providers.nvidia_nim.base_url.as_ref() {
1038 out.insert("providers.nvidia_nim.base_url".to_string(), v.clone());
1039 }
1040 if let Some(v) = self.providers.nvidia_nim.model.as_ref() {
1041 out.insert("providers.nvidia_nim.model".to_string(), v.clone());
1042 }
1043 if let Some(v) = serialize_http_headers(&self.providers.nvidia_nim.http_headers) {
1044 out.insert("providers.nvidia_nim.http_headers".to_string(), v);
1045 }
1046 if let Some(v) = self.providers.openrouter.api_key.as_ref() {
1047 out.insert("providers.openrouter.api_key".to_string(), redact_secret(v));
1048 }
1049 if let Some(v) = self.providers.openrouter.base_url.as_ref() {
1050 out.insert("providers.openrouter.base_url".to_string(), v.clone());
1051 }
1052 if let Some(v) = self.providers.openrouter.model.as_ref() {
1053 out.insert("providers.openrouter.model".to_string(), v.clone());
1054 }
1055 if let Some(v) = serialize_http_headers(&self.providers.openrouter.http_headers) {
1056 out.insert("providers.openrouter.http_headers".to_string(), v);
1057 }
1058 if let Some(v) = self.providers.xiaomi_mimo.api_key.as_ref() {
1059 out.insert(
1060 "providers.xiaomi_mimo.api_key".to_string(),
1061 redact_secret(v),
1062 );
1063 }
1064 if let Some(v) = self.providers.xiaomi_mimo.base_url.as_ref() {
1065 out.insert("providers.xiaomi_mimo.base_url".to_string(), v.clone());
1066 }
1067 if let Some(v) = self.providers.xiaomi_mimo.model.as_ref() {
1068 out.insert("providers.xiaomi_mimo.model".to_string(), v.clone());
1069 }
1070 if let Some(v) = serialize_http_headers(&self.providers.xiaomi_mimo.http_headers) {
1071 out.insert("providers.xiaomi_mimo.http_headers".to_string(), v);
1072 }
1073 if let Some(v) = self.providers.novita.api_key.as_ref() {
1074 out.insert("providers.novita.api_key".to_string(), redact_secret(v));
1075 }
1076 if let Some(v) = self.providers.novita.base_url.as_ref() {
1077 out.insert("providers.novita.base_url".to_string(), v.clone());
1078 }
1079 if let Some(v) = self.providers.novita.model.as_ref() {
1080 out.insert("providers.novita.model".to_string(), v.clone());
1081 }
1082 if let Some(v) = serialize_http_headers(&self.providers.novita.http_headers) {
1083 out.insert("providers.novita.http_headers".to_string(), v);
1084 }
1085 if let Some(v) = self.providers.fireworks.api_key.as_ref() {
1086 out.insert("providers.fireworks.api_key".to_string(), redact_secret(v));
1087 }
1088 if let Some(v) = self.providers.fireworks.base_url.as_ref() {
1089 out.insert("providers.fireworks.base_url".to_string(), v.clone());
1090 }
1091 if let Some(v) = self.providers.fireworks.model.as_ref() {
1092 out.insert("providers.fireworks.model".to_string(), v.clone());
1093 }
1094 if let Some(v) = serialize_http_headers(&self.providers.fireworks.http_headers) {
1095 out.insert("providers.fireworks.http_headers".to_string(), v);
1096 }
1097 if let Some(v) = self.providers.siliconflow.api_key.as_ref() {
1098 out.insert(
1099 "providers.siliconflow.api_key".to_string(),
1100 redact_secret(v),
1101 );
1102 }
1103 if let Some(v) = self.providers.siliconflow.base_url.as_ref() {
1104 out.insert("providers.siliconflow.base_url".to_string(), v.clone());
1105 }
1106 if let Some(v) = self.providers.siliconflow.model.as_ref() {
1107 out.insert("providers.siliconflow.model".to_string(), v.clone());
1108 }
1109 if let Some(v) = serialize_http_headers(&self.providers.siliconflow.http_headers) {
1110 out.insert("providers.siliconflow.http_headers".to_string(), v);
1111 }
1112 if let Some(v) = self.providers.moonshot.api_key.as_ref() {
1113 out.insert("providers.moonshot.api_key".to_string(), redact_secret(v));
1114 }
1115 if let Some(v) = self.providers.moonshot.base_url.as_ref() {
1116 out.insert("providers.moonshot.base_url".to_string(), v.clone());
1117 }
1118 if let Some(v) = self.providers.moonshot.model.as_ref() {
1119 out.insert("providers.moonshot.model".to_string(), v.clone());
1120 }
1121 if let Some(v) = self.providers.moonshot.auth_mode.as_ref() {
1122 out.insert("providers.moonshot.auth_mode".to_string(), v.clone());
1123 }
1124 if let Some(v) = serialize_http_headers(&self.providers.moonshot.http_headers) {
1125 out.insert("providers.moonshot.http_headers".to_string(), v);
1126 }
1127 if let Some(v) = self.providers.sglang.api_key.as_ref() {
1128 out.insert("providers.sglang.api_key".to_string(), redact_secret(v));
1129 }
1130 if let Some(v) = self.providers.sglang.base_url.as_ref() {
1131 out.insert("providers.sglang.base_url".to_string(), v.clone());
1132 }
1133 if let Some(v) = self.providers.sglang.model.as_ref() {
1134 out.insert("providers.sglang.model".to_string(), v.clone());
1135 }
1136 if let Some(v) = serialize_http_headers(&self.providers.sglang.http_headers) {
1137 out.insert("providers.sglang.http_headers".to_string(), v);
1138 }
1139 if let Some(v) = self.providers.vllm.api_key.as_ref() {
1140 out.insert("providers.vllm.api_key".to_string(), redact_secret(v));
1141 }
1142 if let Some(v) = self.providers.vllm.base_url.as_ref() {
1143 out.insert("providers.vllm.base_url".to_string(), v.clone());
1144 }
1145 if let Some(v) = self.providers.vllm.model.as_ref() {
1146 out.insert("providers.vllm.model".to_string(), v.clone());
1147 }
1148 if let Some(v) = serialize_http_headers(&self.providers.vllm.http_headers) {
1149 out.insert("providers.vllm.http_headers".to_string(), v);
1150 }
1151 if let Some(v) = self.providers.ollama.api_key.as_ref() {
1152 out.insert("providers.ollama.api_key".to_string(), redact_secret(v));
1153 }
1154 if let Some(v) = self.providers.ollama.base_url.as_ref() {
1155 out.insert("providers.ollama.base_url".to_string(), v.clone());
1156 }
1157 if let Some(v) = self.providers.ollama.model.as_ref() {
1158 out.insert("providers.ollama.model".to_string(), v.clone());
1159 }
1160 if let Some(v) = serialize_http_headers(&self.providers.ollama.http_headers) {
1161 out.insert("providers.ollama.http_headers".to_string(), v);
1162 }
1163
1164 for (k, v) in &self.extras {
1165 out.insert(k.clone(), v.to_string());
1166 }
1167 out
1168 }
1169
1170 #[must_use]
1177 pub fn resolve_runtime_options(&self, cli: &CliRuntimeOverrides) -> ResolvedRuntimeOptions {
1178 let no_keyring = Secrets::new(std::sync::Arc::new(
1179 codewhale_secrets::InMemoryKeyringStore::new(),
1180 ));
1181 self.resolve_runtime_options_with_secrets(cli, &no_keyring)
1182 }
1183
1184 #[must_use]
1188 pub fn resolve_runtime_options_with_secrets(
1189 &self,
1190 cli: &CliRuntimeOverrides,
1191 secrets: &Secrets,
1192 ) -> ResolvedRuntimeOptions {
1193 let env = EnvRuntimeOverrides::load();
1194 let provider = cli.provider.or(env.provider).unwrap_or(self.provider);
1195
1196 let provider_cfg = self.providers.for_provider(provider);
1197 let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
1198 .then(|| self.api_key.clone())
1199 .flatten();
1200 let root_deepseek_base_url = (provider == ProviderKind::Deepseek)
1201 .then(|| self.base_url.clone())
1202 .flatten();
1203 let root_deepseek_model = (provider == ProviderKind::Deepseek)
1204 .then(|| self.default_text_model.clone())
1205 .flatten();
1206 let auth_mode = cli
1207 .auth_mode
1208 .clone()
1209 .or_else(|| env.auth_mode.clone())
1210 .or_else(|| provider_cfg.auth_mode.clone())
1211 .or_else(|| self.auth_mode.clone());
1212 let base_url = cli
1213 .base_url
1214 .clone()
1215 .or_else(|| env.base_url_for(provider))
1216 .or_else(|| provider_cfg.base_url.clone())
1217 .or(root_deepseek_base_url)
1218 .unwrap_or_else(|| match provider {
1219 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
1220 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
1221 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
1222 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
1223 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
1224 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
1225 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
1226 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
1227 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
1228 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
1229 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
1230 ProviderKind::Moonshot => {
1231 if auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) {
1232 DEFAULT_KIMI_CODE_BASE_URL.to_string()
1233 } else {
1234 DEFAULT_MOONSHOT_BASE_URL.to_string()
1235 }
1236 }
1237 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
1238 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
1239 ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
1240 });
1241 let uses_kimi_oauth = provider == ProviderKind::Moonshot
1247 && auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth);
1248 let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key);
1249 let (api_key, api_key_source) = if let Some(value) = cli.api_key.clone() {
1250 (Some(value), Some(RuntimeApiKeySource::Cli))
1251 } else if uses_kimi_oauth {
1252 (None, None)
1253 } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) {
1254 (Some(value), Some(RuntimeApiKeySource::ConfigFile))
1255 } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) {
1256 match codewhale_secrets::env_for(provider.as_str()) {
1257 Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)),
1258 None => (None, None),
1259 }
1260 } else {
1261 match secrets.resolve_with_source(provider.as_str()) {
1262 Some((value, source)) => {
1263 let source = match source {
1264 SecretSource::Keyring => RuntimeApiKeySource::Keyring,
1265 SecretSource::Env => RuntimeApiKeySource::Env,
1266 };
1267 (Some(value), Some(source))
1268 }
1269 None => (None, None),
1270 }
1271 };
1272
1273 let env_provider_model = env.model_for(provider, &base_url);
1274 let explicit_model = cli.model.is_some()
1275 || env.model.is_some()
1276 || env_provider_model.is_some()
1277 || provider_cfg.model.is_some()
1278 || root_deepseek_model.is_some()
1279 || self.model.is_some();
1280 let model = cli
1281 .model
1282 .clone()
1283 .or_else(|| env.model.clone())
1284 .or(env_provider_model)
1285 .or_else(|| provider_cfg.model.clone())
1286 .or(root_deepseek_model)
1287 .or_else(|| self.model.clone())
1288 .unwrap_or_else(|| {
1289 if provider == ProviderKind::Moonshot
1290 && (auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth)
1291 || moonshot_base_url_uses_kimi_code(&base_url))
1292 {
1293 DEFAULT_KIMI_CODE_MODEL.to_string()
1294 } else {
1295 default_model_for_provider(provider).to_string()
1296 }
1297 });
1298 let model =
1299 if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) {
1300 model.trim().to_string()
1301 } else {
1302 normalize_model_for_provider(provider, &model)
1303 };
1304
1305 let mut http_headers = self.http_headers.clone();
1306 http_headers.extend(provider_cfg.http_headers.clone());
1307 if let Some(env_headers) = env.http_headers {
1308 http_headers.extend(env_headers);
1309 }
1310 http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
1311
1312 let output_mode = cli
1313 .output_mode
1314 .clone()
1315 .or_else(|| env.output_mode.clone())
1316 .or_else(|| self.output_mode.clone());
1317 let log_level = cli
1318 .log_level
1319 .clone()
1320 .or_else(|| env.log_level.clone())
1321 .or_else(|| self.log_level.clone());
1322 let telemetry = cli
1323 .telemetry
1324 .or(env.telemetry)
1325 .or(self.telemetry)
1326 .unwrap_or(false);
1327 let approval_policy = cli
1328 .approval_policy
1329 .clone()
1330 .or_else(|| env.approval_policy.clone())
1331 .or_else(|| self.approval_policy.clone());
1332 let sandbox_mode = cli
1333 .sandbox_mode
1334 .clone()
1335 .or_else(|| env.sandbox_mode.clone())
1336 .or_else(|| self.sandbox_mode.clone());
1337 let yolo = cli.yolo.or(env.yolo);
1338
1339 ResolvedRuntimeOptions {
1340 provider,
1341 model,
1342 api_key,
1343 api_key_source,
1344 base_url,
1345 auth_mode,
1346 output_mode,
1347 log_level,
1348 telemetry,
1349 approval_policy,
1350 sandbox_mode,
1351 yolo,
1352 http_headers,
1353 }
1354 }
1355}
1356
1357fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
1358 if source.model.is_some() {
1359 target.model = source.model.clone();
1360 }
1361}
1362
1363#[must_use]
1364pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
1365 let Some(project_rank) = approval_policy_rank(project) else {
1366 return false;
1367 };
1368 match current.and_then(approval_policy_rank) {
1369 Some(current_rank) => project_rank >= current_rank,
1370 None => project_rank >= 2,
1371 }
1372}
1373
1374#[must_use]
1375pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
1376 let normalized_project = project.trim().to_ascii_lowercase();
1377 if normalized_project == "external-sandbox" {
1378 return current
1379 .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
1380 .unwrap_or(false);
1381 }
1382
1383 let Some(project_rank) = sandbox_mode_rank(project) else {
1384 return false;
1385 };
1386 match current.and_then(sandbox_mode_rank) {
1387 Some(current_rank) => project_rank >= current_rank,
1388 None => project_rank >= 2,
1389 }
1390}
1391
1392fn approval_policy_rank(value: &str) -> Option<u8> {
1393 match value.trim().to_ascii_lowercase().as_str() {
1394 "auto" => Some(0),
1395 "suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
1396 "never" | "deny" | "denied" => Some(2),
1397 _ => None,
1398 }
1399}
1400
1401fn sandbox_mode_rank(value: &str) -> Option<u8> {
1402 match value.trim().to_ascii_lowercase().as_str() {
1403 "danger-full-access" => Some(0),
1404 "external-sandbox" => Some(0),
1405 "workspace-write" => Some(1),
1406 "read-only" => Some(2),
1407 _ => None,
1408 }
1409}
1410
1411pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
1417 for dir in [CODEWHALE_APP_DIR, LEGACY_APP_DIR] {
1418 let path = workspace.join(dir).join(CONFIG_FILE_NAME);
1419 if path.exists()
1420 && let Ok(raw) = fs::read_to_string(&path)
1421 {
1422 return toml::from_str(&raw).ok();
1423 }
1424 }
1425 None
1426}
1427
1428fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
1429 if matches!(
1430 provider,
1431 ProviderKind::Atlascloud
1432 | ProviderKind::WanjieArk
1433 | ProviderKind::Volcengine
1434 | ProviderKind::XiaomiMimo
1435 | ProviderKind::Ollama
1436 ) {
1437 return model.to_string();
1438 }
1439
1440 let normalized = model.trim().to_ascii_lowercase();
1441 if provider == ProviderKind::Openrouter
1442 && let Some(canonical) = canonical_openrouter_recent_model_id(&normalized)
1443 {
1444 return canonical.to_string();
1445 }
1446 match (provider, normalized.as_str()) {
1447 (ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
1448 DEFAULT_NVIDIA_NIM_MODEL.to_string()
1449 }
1450 (
1451 ProviderKind::NvidiaNim,
1452 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
1453 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
1454 ) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
1455 (ProviderKind::Openrouter, "deepseek-v4-pro" | "deepseek-v4pro") => {
1456 DEFAULT_OPENROUTER_MODEL.to_string()
1457 }
1458 (
1459 ProviderKind::Openrouter,
1460 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
1461 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
1462 ) => DEFAULT_OPENROUTER_FLASH_MODEL.to_string(),
1463 (ProviderKind::Novita, "deepseek-v4-pro" | "deepseek-v4pro") => {
1464 DEFAULT_NOVITA_MODEL.to_string()
1465 }
1466 (
1467 ProviderKind::Novita,
1468 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
1469 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
1470 ) => DEFAULT_NOVITA_FLASH_MODEL.to_string(),
1471 (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => {
1472 DEFAULT_FIREWORKS_MODEL.to_string()
1473 }
1474 (
1475 ProviderKind::Siliconflow,
1476 "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1",
1477 ) => DEFAULT_SILICONFLOW_MODEL.to_string(),
1478 (
1479 ProviderKind::Siliconflow,
1480 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3",
1481 ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(),
1482 (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2") => DEFAULT_MOONSHOT_MODEL.to_string(),
1483 (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => {
1484 DEFAULT_SGLANG_MODEL.to_string()
1485 }
1486 (
1487 ProviderKind::Sglang,
1488 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
1489 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
1490 ) => DEFAULT_SGLANG_FLASH_MODEL.to_string(),
1491 (ProviderKind::Vllm, "deepseek-v4-pro" | "deepseek-v4pro") => {
1492 DEFAULT_VLLM_MODEL.to_string()
1493 }
1494 (
1495 ProviderKind::Vllm,
1496 "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
1497 | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
1498 ) => DEFAULT_VLLM_FLASH_MODEL.to_string(),
1499 _ => model.to_string(),
1500 }
1501}
1502
1503fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
1504 let normalized = model.trim().to_ascii_lowercase();
1505 let normalized = normalized.replace(['_', ' '], "-");
1506 match normalized.as_str() {
1507 OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL
1508 | "trinity"
1509 | "trinity-large-thinking"
1510 | "arcee-trinity"
1511 | "arcee-trinity-large-thinking" => Some(OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL),
1512 OPENROUTER_GEMMA_4_31B_MODEL | "gemma-4-31b" | "gemma-4-31b-it" => {
1513 Some(OPENROUTER_GEMMA_4_31B_MODEL)
1514 }
1515 OPENROUTER_GEMMA_4_26B_A4B_MODEL | "gemma-4-26b-a4b" | "gemma-4-26b-a4b-it" => {
1516 Some(OPENROUTER_GEMMA_4_26B_A4B_MODEL)
1517 }
1518 OPENROUTER_GLM_5_1_MODEL | "glm-5.1" | "glm-5-1" | "zai-glm-5.1" | "zai-glm-5-1" => {
1519 Some(OPENROUTER_GLM_5_1_MODEL)
1520 }
1521 OPENROUTER_KIMI_K2_6_MODEL | "kimi-k2.6" | "kimi-k2-6" | "moonshot-kimi-k2.6" => {
1522 Some(OPENROUTER_KIMI_K2_6_MODEL)
1523 }
1524 OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL
1525 | "nemotron-3-nano-omni"
1526 | "nemotron-3-nano-omni-reasoning" => Some(OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL),
1527 OPENROUTER_QWEN_3_6_35B_A3B_MODEL
1528 | "qwen3.6-35b-a3b"
1529 | "qwen-3.6-35b-a3b"
1530 | "qwen3-6-35b-a3b" => Some(OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
1531 OPENROUTER_QWEN_3_6_27B_MODEL | "qwen3.6-27b" | "qwen-3.6-27b" | "qwen3-6-27b" => {
1532 Some(OPENROUTER_QWEN_3_6_27B_MODEL)
1533 }
1534 OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
1535 Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
1536 }
1537 OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
1538 | "mimo-v2.5-pro"
1539 | "mimo-v2-5-pro"
1540 | "xiaomi-mimo-v2.5-pro"
1541 | "xiaomi-mimo-v2-5-pro" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
1542 OPENROUTER_XIAOMI_MIMO_V2_5_MODEL
1543 | "mimo-v2.5"
1544 | "mimo-v2-5"
1545 | "xiaomi-mimo-v2.5"
1546 | "xiaomi-mimo-v2-5" => Some(OPENROUTER_XIAOMI_MIMO_V2_5_MODEL),
1547 _ => None,
1548 }
1549}
1550
1551fn default_model_for_provider(provider: ProviderKind) -> &'static str {
1552 match provider {
1553 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
1554 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
1555 ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
1556 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
1557 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
1558 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
1559 ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
1560 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
1561 ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
1562 ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
1563 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_MODEL,
1564 ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
1565 ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
1566 ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
1567 ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
1568 }
1569}
1570
1571fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
1572 match provider {
1573 ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
1574 ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
1575 ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
1576 ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
1577 ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
1578 ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
1579 ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
1580 ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
1581 ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
1582 ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
1583 ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
1584 ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
1585 ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
1586 ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
1587 ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
1588 }
1589}
1590
1591fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
1592 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
1593 normalized == DEFAULT_KIMI_CODE_BASE_URL
1594 || normalized == "https://api.kimi.com/coding"
1595 || normalized.starts_with("https://api.kimi.com/coding/")
1596}
1597
1598fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool {
1599 if provider == ProviderKind::Siliconflow && siliconflow_base_url_is_official(base_url) {
1600 return false;
1601 }
1602 let actual = base_url.trim_end_matches('/');
1603 let default = default_base_url_for_provider(provider).trim_end_matches('/');
1604 actual != default
1605}
1606
1607fn siliconflow_base_url_is_official(base_url: &str) -> bool {
1608 matches!(
1609 base_url.trim_end_matches('/').to_ascii_lowercase().as_str(),
1610 "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1"
1611 )
1612}
1613
1614fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool {
1615 base_url_is_custom_for_provider(provider, base_url)
1616}
1617
1618fn should_skip_secret_store_for_provider(
1619 provider: ProviderKind,
1620 base_url: &str,
1621 auth_mode: Option<&str>,
1622) -> bool {
1623 if auth_mode_requires_api_key(auth_mode) {
1624 return false;
1625 }
1626 if auth_mode_disables_api_key(auth_mode) {
1627 return true;
1628 }
1629
1630 matches!(
1631 provider,
1632 ProviderKind::Sglang | ProviderKind::Vllm | ProviderKind::Ollama
1633 ) || base_url_uses_local_host(base_url)
1634}
1635
1636fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool {
1637 matches!(
1638 auth_mode
1639 .map(str::trim)
1640 .filter(|value| !value.is_empty())
1641 .map(|value| value.to_ascii_lowercase()),
1642 Some(value)
1643 if matches!(
1644 value.as_str(),
1645 "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token"
1646 )
1647 )
1648}
1649
1650fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool {
1651 matches!(
1652 auth_mode
1653 .map(str::trim)
1654 .filter(|value| !value.is_empty())
1655 .map(|value| value.to_ascii_lowercase()),
1656 Some(value)
1657 if matches!(
1658 value.as_str(),
1659 "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous"
1660 )
1661 )
1662}
1663
1664fn auth_mode_uses_kimi_oauth(auth_mode: &str) -> bool {
1665 matches!(
1666 auth_mode
1667 .trim()
1668 .to_ascii_lowercase()
1669 .replace('-', "_")
1670 .as_str(),
1671 "kimi" | "kimi_oauth" | "kimi_cli" | "oauth"
1672 )
1673}
1674
1675fn base_url_uses_local_host(base_url: &str) -> bool {
1676 let Some(host) = base_url_host(base_url) else {
1677 return false;
1678 };
1679 let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
1680 if matches!(host.as_str(), "localhost" | "0.0.0.0") {
1681 return true;
1682 }
1683 host.parse::<std::net::IpAddr>()
1684 .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
1685}
1686
1687fn base_url_host(base_url: &str) -> Option<&str> {
1688 let without_scheme = base_url
1689 .split_once("://")
1690 .map_or(base_url, |(_, rest)| rest);
1691 let authority = without_scheme.split('/').next()?.rsplit('@').next()?;
1692 if let Some(rest) = authority.strip_prefix('[') {
1693 return rest.split_once(']').map(|(host, _)| host);
1694 }
1695 authority.split(':').next().filter(|host| !host.is_empty())
1696}
1697
1698#[derive(Debug, Clone, Default)]
1699pub struct CliRuntimeOverrides {
1700 pub provider: Option<ProviderKind>,
1701 pub model: Option<String>,
1702 pub api_key: Option<String>,
1703 pub base_url: Option<String>,
1704 pub auth_mode: Option<String>,
1705 pub output_mode: Option<String>,
1706 pub log_level: Option<String>,
1707 pub telemetry: Option<bool>,
1708 pub approval_policy: Option<String>,
1709 pub sandbox_mode: Option<String>,
1710 pub yolo: Option<bool>,
1711}
1712
1713#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1714pub enum RuntimeApiKeySource {
1715 Cli,
1716 ConfigFile,
1717 Keyring,
1718 Env,
1719}
1720
1721impl RuntimeApiKeySource {
1722 #[must_use]
1723 pub fn as_env_value(self) -> &'static str {
1724 match self {
1725 Self::Cli => "cli",
1726 Self::ConfigFile => "config",
1727 Self::Keyring => "keyring",
1728 Self::Env => "env",
1729 }
1730 }
1731}
1732
1733#[derive(Debug, Clone)]
1734pub struct ResolvedRuntimeOptions {
1735 pub provider: ProviderKind,
1736 pub model: String,
1737 pub api_key: Option<String>,
1738 pub api_key_source: Option<RuntimeApiKeySource>,
1739 pub base_url: String,
1740 pub auth_mode: Option<String>,
1741 pub output_mode: Option<String>,
1742 pub log_level: Option<String>,
1743 pub telemetry: bool,
1744 pub approval_policy: Option<String>,
1745 pub sandbox_mode: Option<String>,
1746 pub yolo: Option<bool>,
1747 pub http_headers: BTreeMap<String, String>,
1748}
1749
1750#[derive(Debug, Clone)]
1751pub struct ConfigStore {
1752 path: PathBuf,
1753 pub config: ConfigToml,
1754}
1755
1756impl ConfigStore {
1757 pub fn load(path: Option<PathBuf>) -> Result<Self> {
1758 let path = resolve_config_path(path)?;
1759 if !path.exists() {
1760 return Ok(Self {
1761 path,
1762 config: ConfigToml::default(),
1763 });
1764 }
1765
1766 let raw = fs::read_to_string(&path)
1767 .with_context(|| format!("failed to read config at {}", path.display()))?;
1768 let parsed: ConfigToml = toml::from_str(&raw)
1769 .with_context(|| format!("failed to parse config at {}", path.display()))?;
1770
1771 Ok(Self {
1772 path,
1773 config: parsed,
1774 })
1775 }
1776
1777 pub fn save(&self) -> Result<()> {
1778 if let Some(parent) = self.path.parent() {
1779 fs::create_dir_all(parent).with_context(|| {
1780 format!("failed to create config directory {}", parent.display())
1781 })?;
1782 }
1783 let body = toml::to_string_pretty(&self.config).context("failed to serialize config")?;
1784 #[cfg(unix)]
1785 {
1786 let mut file = fs::OpenOptions::new()
1787 .write(true)
1788 .create(true)
1789 .truncate(true)
1790 .mode(0o600)
1791 .open(&self.path)
1792 .with_context(|| format!("failed to write config at {}", self.path.display()))?;
1793 file.write_all(body.as_bytes())
1794 .with_context(|| format!("failed to write config at {}", self.path.display()))?;
1795 file.set_permissions(fs::Permissions::from_mode(0o600))
1796 .with_context(|| {
1797 format!(
1798 "failed to set config permissions at {}",
1799 self.path.display()
1800 )
1801 })?;
1802 }
1803 #[cfg(not(unix))]
1804 {
1805 fs::write(&self.path, body)
1806 .with_context(|| format!("failed to write config at {}", self.path.display()))?;
1807 }
1808 Ok(())
1809 }
1810
1811 #[must_use]
1812 pub fn path(&self) -> &Path {
1813 &self.path
1814 }
1815}
1816
1817pub fn default_secrets() -> &'static Secrets {
1822 static SECRETS: OnceLock<Secrets> = OnceLock::new();
1823 SECRETS.get_or_init(|| {
1824 #[cfg(test)]
1829 {
1830 Secrets::new(std::sync::Arc::new(
1831 codewhale_secrets::InMemoryKeyringStore::new(),
1832 ))
1833 }
1834 #[cfg(not(test))]
1835 {
1836 Secrets::auto_detect()
1837 }
1838 })
1839}
1840
1841pub const CODEWHALE_APP_DIR: &str = ".codewhale";
1850
1851pub const LEGACY_APP_DIR: &str = ".deepseek";
1853
1854pub fn codewhale_home() -> Result<PathBuf> {
1859 if let Ok(val) = std::env::var("CODEWHALE_HOME") {
1860 let trimmed = val.trim();
1861 if !trimmed.is_empty() {
1862 return Ok(PathBuf::from(trimmed));
1863 }
1864 }
1865 let home = dirs::home_dir().context("failed to resolve home directory")?;
1866 Ok(home.join(CODEWHALE_APP_DIR))
1867}
1868
1869pub fn legacy_deepseek_home() -> Result<PathBuf> {
1873 let home = dirs::home_dir().context("failed to resolve home directory")?;
1874 Ok(home.join(LEGACY_APP_DIR))
1875}
1876
1877pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
1884 let primary = codewhale_home()?.join(subdir);
1885 if primary.exists() {
1886 return Ok(primary);
1887 }
1888 let legacy = legacy_deepseek_home()?.join(subdir);
1889 if legacy.exists() {
1890 return Ok(legacy);
1891 }
1892 Ok(primary)
1894}
1895
1896pub fn ensure_state_dir(subdir: &str) -> Result<PathBuf> {
1899 let dir = codewhale_home()?.join(subdir);
1900 std::fs::create_dir_all(&dir)
1901 .with_context(|| format!("failed to create {}/", dir.display()))?;
1902 Ok(dir)
1903}
1904
1905pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> (bool, PathBuf) {
1912 let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir);
1913 if primary.exists() {
1914 return (true, primary);
1915 }
1916 let legacy = workspace.join(LEGACY_APP_DIR).join(subdir);
1917 (false, legacy)
1918}
1919
1920pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result<PathBuf> {
1923 let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir);
1924 std::fs::create_dir_all(&dir)
1925 .with_context(|| format!("failed to create {}/", dir.display()))?;
1926 Ok(dir)
1927}
1928
1929pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
1930 let path = if let Some(path) = explicit {
1931 path
1932 } else if let Ok(path) = std::env::var("CODEWHALE_CONFIG_PATH") {
1933 let trimmed = path.trim();
1934 if !trimmed.is_empty() {
1935 PathBuf::from(trimmed)
1936 } else {
1937 return default_config_path();
1938 }
1939 } else if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
1940 let trimmed = path.trim();
1941 if !trimmed.is_empty() {
1942 PathBuf::from(trimmed)
1943 } else {
1944 return default_config_path();
1945 }
1946 } else {
1947 return default_config_path();
1948 };
1949 normalize_config_file_path(path)
1950}
1951
1952pub fn default_config_path() -> Result<PathBuf> {
1953 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
1956 if primary.exists() {
1957 return Ok(primary);
1958 }
1959 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
1960 if legacy.exists() {
1961 return Ok(legacy);
1962 }
1963 Ok(primary)
1965}
1966
1967pub fn migrate_config_if_needed() -> Result<()> {
1972 let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
1973 if primary.exists() {
1974 return Ok(());
1975 }
1976 let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
1977 if !legacy.exists() {
1978 return Ok(());
1979 }
1980 if let Some(parent) = primary.parent() {
1982 std::fs::create_dir_all(parent).context("failed to create codewhale config directory")?;
1983 }
1984 std::fs::copy(&legacy, &primary)
1985 .context("failed to migrate config from deepseek to codewhale home")?;
1986 tracing::info!(
1987 "Migrated config from {} to {}",
1988 legacy.display(),
1989 primary.display()
1990 );
1991 Ok(())
1992}
1993
1994fn parse_bool(raw: &str) -> Result<bool> {
1995 match raw.trim().to_ascii_lowercase().as_str() {
1996 "1" | "true" | "yes" | "on" | "enabled" => Ok(true),
1997 "0" | "false" | "no" | "off" | "disabled" => Ok(false),
1998 _ => bail!("invalid boolean '{raw}'"),
1999 }
2000}
2001
2002fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
2003 let mut headers = BTreeMap::new();
2004 for pair in raw.trim().split(',') {
2005 let pair = pair.trim();
2006 if pair.is_empty() {
2007 continue;
2008 }
2009 let Some((name, value)) = pair.split_once('=') else {
2010 bail!("invalid header pair '{pair}', expected name=value");
2011 };
2012 let name = name.trim();
2013 let value = value.trim();
2014 if name.is_empty() {
2015 bail!("header name cannot be empty");
2016 }
2017 if value.is_empty() {
2018 continue;
2019 }
2020 headers.insert(name.to_string(), value.to_string());
2021 }
2022 Ok(headers)
2023}
2024
2025fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
2026 if headers.is_empty() {
2027 return None;
2028 }
2029 Some(
2030 headers
2031 .iter()
2032 .map(|(name, value)| format!("{name}={value}"))
2033 .collect::<Vec<_>>()
2034 .join(","),
2035 )
2036}
2037
2038fn redact_secret(secret: &str) -> String {
2039 let chars: Vec<char> = secret.chars().collect();
2040 if chars.len() <= 16 {
2041 return "********".to_string();
2042 }
2043 let prefix: String = chars.iter().take(4).collect();
2044 let suffix: String = chars
2045 .iter()
2046 .rev()
2047 .take(4)
2048 .collect::<Vec<_>>()
2049 .into_iter()
2050 .rev()
2051 .collect();
2052 format!("{prefix}***{suffix}")
2053}
2054
2055#[must_use]
2056pub fn is_sensitive_config_key(key: &str) -> bool {
2057 key == "api_key" || key.ends_with(".api_key")
2058}
2059
2060fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
2061 if path.as_os_str().is_empty() {
2062 bail!("config path cannot be empty");
2063 }
2064 if path
2065 .components()
2066 .any(|component| matches!(component, Component::ParentDir))
2067 {
2068 bail!("config path cannot contain '..' components");
2069 }
2070 if path.file_name().is_none() {
2071 bail!("config path must include a file name");
2072 }
2073 if path.is_absolute() {
2074 return Ok(path);
2075 }
2076 Ok(std::env::current_dir()
2077 .context("failed to resolve current directory for config path")?
2078 .join(path))
2079}
2080
2081#[derive(Debug, Clone, Default)]
2082struct EnvRuntimeOverrides {
2083 provider: Option<ProviderKind>,
2084 model: Option<String>,
2085 volcengine_model: Option<String>,
2086 wanjie_ark_model: Option<String>,
2087 moonshot_model: Option<String>,
2088 xiaomi_mimo_model: Option<String>,
2089 output_mode: Option<String>,
2090 auth_mode: Option<String>,
2091 log_level: Option<String>,
2092 telemetry: Option<bool>,
2093 approval_policy: Option<String>,
2094 sandbox_mode: Option<String>,
2095 yolo: Option<bool>,
2096 http_headers: Option<BTreeMap<String, String>>,
2097 deepseek_base_url: Option<String>,
2098 nvidia_base_url: Option<String>,
2099 openai_base_url: Option<String>,
2100 atlascloud_base_url: Option<String>,
2101 volcengine_base_url: Option<String>,
2102 wanjie_ark_base_url: Option<String>,
2103 openrouter_base_url: Option<String>,
2104 xiaomi_mimo_base_url: Option<String>,
2105 novita_base_url: Option<String>,
2106 fireworks_base_url: Option<String>,
2107 siliconflow_base_url: Option<String>,
2108 siliconflow_model: Option<String>,
2109 moonshot_base_url: Option<String>,
2110 sglang_base_url: Option<String>,
2111 vllm_base_url: Option<String>,
2112 ollama_base_url: Option<String>,
2113}
2114
2115impl EnvRuntimeOverrides {
2116 fn load() -> Self {
2117 Self {
2118 provider: std::env::var("CODEWHALE_PROVIDER")
2119 .or_else(|_| std::env::var("DEEPSEEK_PROVIDER"))
2120 .ok()
2121 .and_then(|v| ProviderKind::parse(&v)),
2122 model: std::env::var("CODEWHALE_MODEL")
2123 .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
2124 .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
2125 .ok()
2126 .filter(|v| !v.trim().is_empty()),
2127 volcengine_model: std::env::var("VOLCENGINE_MODEL")
2128 .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
2129 .ok()
2130 .filter(|v| !v.trim().is_empty()),
2131 wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
2132 .or_else(|_| std::env::var("WANJIE_MODEL"))
2133 .or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
2134 .ok()
2135 .filter(|v| !v.trim().is_empty()),
2136 moonshot_model: std::env::var("MOONSHOT_MODEL")
2137 .or_else(|_| std::env::var("KIMI_MODEL_NAME"))
2138 .or_else(|_| std::env::var("KIMI_MODEL"))
2139 .ok()
2140 .filter(|v| !v.trim().is_empty()),
2141 xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
2142 .or_else(|_| std::env::var("MIMO_MODEL"))
2143 .ok()
2144 .filter(|v| !v.trim().is_empty()),
2145 output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(),
2146 auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(),
2147 log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(),
2148 telemetry: std::env::var("DEEPSEEK_TELEMETRY")
2149 .ok()
2150 .and_then(|v| parse_bool(&v).ok()),
2151 approval_policy: std::env::var("DEEPSEEK_APPROVAL_POLICY").ok(),
2152 sandbox_mode: std::env::var("DEEPSEEK_SANDBOX_MODE").ok(),
2153 yolo: std::env::var("DEEPSEEK_YOLO")
2154 .ok()
2155 .and_then(|v| parse_bool(&v).ok()),
2156 http_headers: std::env::var("DEEPSEEK_HTTP_HEADERS")
2157 .ok()
2158 .and_then(|value| parse_http_headers(&value).ok())
2159 .filter(|headers| !headers.is_empty()),
2160 deepseek_base_url: std::env::var("CODEWHALE_BASE_URL")
2161 .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
2162 .ok()
2163 .filter(|v| !v.trim().is_empty()),
2164 nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
2165 .or_else(|_| std::env::var("NIM_BASE_URL"))
2166 .or_else(|_| std::env::var("NVIDIA_BASE_URL"))
2167 .ok()
2168 .filter(|v| !v.trim().is_empty()),
2169 openai_base_url: std::env::var("OPENAI_BASE_URL")
2170 .ok()
2171 .filter(|v| !v.trim().is_empty()),
2172 atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
2173 .ok()
2174 .filter(|v| !v.trim().is_empty()),
2175 volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
2176 .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
2177 .or_else(|_| std::env::var("ARK_BASE_URL"))
2178 .ok()
2179 .filter(|v| !v.trim().is_empty()),
2180 wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
2181 .or_else(|_| std::env::var("WANJIE_BASE_URL"))
2182 .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
2183 .ok()
2184 .filter(|v| !v.trim().is_empty()),
2185 openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
2186 .ok()
2187 .filter(|v| !v.trim().is_empty()),
2188 xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
2189 .or_else(|_| std::env::var("MIMO_BASE_URL"))
2190 .ok()
2191 .filter(|v| !v.trim().is_empty()),
2192 novita_base_url: std::env::var("NOVITA_BASE_URL")
2193 .ok()
2194 .filter(|v| !v.trim().is_empty()),
2195 fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
2196 .ok()
2197 .filter(|v| !v.trim().is_empty()),
2198 siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
2199 .ok()
2200 .filter(|v| !v.trim().is_empty()),
2201 siliconflow_model: std::env::var("SILICONFLOW_MODEL")
2202 .ok()
2203 .filter(|v| !v.trim().is_empty()),
2204 moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
2205 .or_else(|_| std::env::var("KIMI_BASE_URL"))
2206 .ok()
2207 .filter(|v| !v.trim().is_empty()),
2208 sglang_base_url: std::env::var("SGLANG_BASE_URL")
2209 .ok()
2210 .filter(|v| !v.trim().is_empty()),
2211 vllm_base_url: std::env::var("VLLM_BASE_URL")
2212 .ok()
2213 .filter(|v| !v.trim().is_empty()),
2214 ollama_base_url: std::env::var("OLLAMA_BASE_URL")
2215 .ok()
2216 .filter(|v| !v.trim().is_empty()),
2217 }
2218 }
2219
2220 fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
2221 match provider {
2224 ProviderKind::Deepseek => self.deepseek_base_url.clone(),
2225 ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
2226 ProviderKind::Openai => self.openai_base_url.clone(),
2227 ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
2228 ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
2229 ProviderKind::Volcengine => self.volcengine_base_url.clone(),
2230 ProviderKind::Openrouter => self.openrouter_base_url.clone(),
2231 ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
2232 ProviderKind::Novita => self.novita_base_url.clone(),
2233 ProviderKind::Fireworks => self.fireworks_base_url.clone(),
2234 ProviderKind::Siliconflow => self.siliconflow_base_url.clone(),
2235 ProviderKind::Moonshot => self.moonshot_base_url.clone(),
2236 ProviderKind::Sglang => self.sglang_base_url.clone(),
2237 ProviderKind::Vllm => self.vllm_base_url.clone(),
2238 ProviderKind::Ollama => self.ollama_base_url.clone(),
2239 }
2240 }
2241
2242 fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option<String> {
2243 let model = match provider {
2244 ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
2245 ProviderKind::Volcengine => self.volcengine_model.clone(),
2246 ProviderKind::Siliconflow => self.siliconflow_model.clone(),
2247 ProviderKind::Moonshot => self.moonshot_model.clone(),
2248 ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
2249 _ => None,
2250 }?;
2251
2252 if provider_preserves_custom_base_url_model(provider, base_url) {
2253 Some(model.trim().to_string())
2254 } else {
2255 Some(normalize_model_for_provider(provider, &model))
2256 }
2257 }
2258}
2259
2260#[cfg(test)]
2261mod tests {
2262 use super::*;
2263 use std::env;
2264 use std::ffi::OsString;
2265 use std::sync::Arc;
2266 use std::sync::{Mutex, OnceLock};
2267
2268 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
2269 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2270 LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
2271 }
2272
2273 #[test]
2274 fn network_policy_toml_deserializes_proxy_hosts() {
2275 let policy: NetworkPolicyToml = toml::from_str(
2276 r#"
2277 default = "allow"
2278 proxy = ["github.com", ".githubusercontent.com"]
2279 "#,
2280 )
2281 .expect("network policy toml");
2282
2283 assert_eq!(policy.default, "allow");
2284 assert_eq!(policy.proxy, ["github.com", ".githubusercontent.com"]);
2285 assert!(policy.audit);
2286 }
2287
2288 struct EnvGuard {
2289 deepseek_api_key: Option<OsString>,
2290 deepseek_base_url: Option<OsString>,
2291 deepseek_http_headers: Option<OsString>,
2292 deepseek_model: Option<OsString>,
2293 deepseek_default_text_model: Option<OsString>,
2294 deepseek_provider: Option<OsString>,
2295 deepseek_auth_mode: Option<OsString>,
2296 nvidia_api_key: Option<OsString>,
2297 nvidia_nim_api_key: Option<OsString>,
2298 nim_base_url: Option<OsString>,
2299 nvidia_base_url: Option<OsString>,
2300 nvidia_nim_base_url: Option<OsString>,
2301 openrouter_api_key: Option<OsString>,
2302 openrouter_base_url: Option<OsString>,
2303 xiaomi_mimo_api_key: Option<OsString>,
2304 xiaomi_api_key: Option<OsString>,
2305 mimo_api_key: Option<OsString>,
2306 xiaomi_mimo_base_url: Option<OsString>,
2307 mimo_base_url: Option<OsString>,
2308 xiaomi_mimo_model: Option<OsString>,
2309 mimo_model: Option<OsString>,
2310 wanjie_ark_api_key: Option<OsString>,
2311 wanjie_ark_base_url: Option<OsString>,
2312 wanjie_base_url: Option<OsString>,
2313 wanjie_maas_base_url: Option<OsString>,
2314 volcengine_model: Option<OsString>,
2315 wanjie_ark_model: Option<OsString>,
2316 wanjie_model: Option<OsString>,
2317 wanjie_maas_model: Option<OsString>,
2318 novita_api_key: Option<OsString>,
2319 novita_base_url: Option<OsString>,
2320 fireworks_api_key: Option<OsString>,
2321 fireworks_base_url: Option<OsString>,
2322 siliconflow_api_key: Option<OsString>,
2323 siliconflow_base_url: Option<OsString>,
2324 siliconflow_model: Option<OsString>,
2325 moonshot_api_key: Option<OsString>,
2326 moonshot_base_url: Option<OsString>,
2327 moonshot_model: Option<OsString>,
2328 kimi_api_key: Option<OsString>,
2329 kimi_base_url: Option<OsString>,
2330 kimi_model: Option<OsString>,
2331 kimi_model_name: Option<OsString>,
2332 sglang_api_key: Option<OsString>,
2333 sglang_base_url: Option<OsString>,
2334 vllm_api_key: Option<OsString>,
2335 vllm_base_url: Option<OsString>,
2336 ollama_api_key: Option<OsString>,
2337 ollama_base_url: Option<OsString>,
2338 codewhale_provider: Option<OsString>,
2339 codewhale_model: Option<OsString>,
2340 codewhale_base_url: Option<OsString>,
2341 }
2342
2343 impl EnvGuard {
2344 fn without_deepseek_runtime_overrides() -> Self {
2345 let guard = Self {
2346 deepseek_api_key: env::var_os("DEEPSEEK_API_KEY"),
2347 deepseek_base_url: env::var_os("DEEPSEEK_BASE_URL"),
2348 deepseek_http_headers: env::var_os("DEEPSEEK_HTTP_HEADERS"),
2349 deepseek_model: env::var_os("DEEPSEEK_MODEL"),
2350 deepseek_default_text_model: env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL"),
2351 deepseek_provider: env::var_os("DEEPSEEK_PROVIDER"),
2352 deepseek_auth_mode: env::var_os("DEEPSEEK_AUTH_MODE"),
2353 codewhale_provider: env::var_os("CODEWHALE_PROVIDER"),
2354 codewhale_model: env::var_os("CODEWHALE_MODEL"),
2355 codewhale_base_url: env::var_os("CODEWHALE_BASE_URL"),
2356 nvidia_api_key: env::var_os("NVIDIA_API_KEY"),
2357 nvidia_nim_api_key: env::var_os("NVIDIA_NIM_API_KEY"),
2358 nim_base_url: env::var_os("NIM_BASE_URL"),
2359 nvidia_base_url: env::var_os("NVIDIA_BASE_URL"),
2360 nvidia_nim_base_url: env::var_os("NVIDIA_NIM_BASE_URL"),
2361 openrouter_api_key: env::var_os("OPENROUTER_API_KEY"),
2362 openrouter_base_url: env::var_os("OPENROUTER_BASE_URL"),
2363 xiaomi_mimo_api_key: env::var_os("XIAOMI_MIMO_API_KEY"),
2364 xiaomi_api_key: env::var_os("XIAOMI_API_KEY"),
2365 mimo_api_key: env::var_os("MIMO_API_KEY"),
2366 xiaomi_mimo_base_url: env::var_os("XIAOMI_MIMO_BASE_URL"),
2367 mimo_base_url: env::var_os("MIMO_BASE_URL"),
2368 xiaomi_mimo_model: env::var_os("XIAOMI_MIMO_MODEL"),
2369 mimo_model: env::var_os("MIMO_MODEL"),
2370 wanjie_ark_api_key: env::var_os("WANJIE_ARK_API_KEY"),
2371 wanjie_ark_base_url: env::var_os("WANJIE_ARK_BASE_URL"),
2372 wanjie_base_url: env::var_os("WANJIE_BASE_URL"),
2373 wanjie_maas_base_url: env::var_os("WANJIE_MAAS_BASE_URL"),
2374 volcengine_model: env::var_os("VOLCENGINE_MODEL"),
2375 wanjie_ark_model: env::var_os("WANJIE_ARK_MODEL"),
2376 wanjie_model: env::var_os("WANJIE_MODEL"),
2377 wanjie_maas_model: env::var_os("WANJIE_MAAS_MODEL"),
2378 novita_api_key: env::var_os("NOVITA_API_KEY"),
2379 novita_base_url: env::var_os("NOVITA_BASE_URL"),
2380 fireworks_api_key: env::var_os("FIREWORKS_API_KEY"),
2381 fireworks_base_url: env::var_os("FIREWORKS_BASE_URL"),
2382 siliconflow_api_key: env::var_os("SILICONFLOW_API_KEY"),
2383 siliconflow_base_url: env::var_os("SILICONFLOW_BASE_URL"),
2384 siliconflow_model: env::var_os("SILICONFLOW_MODEL"),
2385 moonshot_api_key: env::var_os("MOONSHOT_API_KEY"),
2386 moonshot_base_url: env::var_os("MOONSHOT_BASE_URL"),
2387 moonshot_model: env::var_os("MOONSHOT_MODEL"),
2388 kimi_api_key: env::var_os("KIMI_API_KEY"),
2389 kimi_base_url: env::var_os("KIMI_BASE_URL"),
2390 kimi_model: env::var_os("KIMI_MODEL"),
2391 kimi_model_name: env::var_os("KIMI_MODEL_NAME"),
2392 sglang_api_key: env::var_os("SGLANG_API_KEY"),
2393 sglang_base_url: env::var_os("SGLANG_BASE_URL"),
2394 vllm_api_key: env::var_os("VLLM_API_KEY"),
2395 vllm_base_url: env::var_os("VLLM_BASE_URL"),
2396 ollama_api_key: env::var_os("OLLAMA_API_KEY"),
2397 ollama_base_url: env::var_os("OLLAMA_BASE_URL"),
2398 };
2399 unsafe {
2401 env::remove_var("DEEPSEEK_API_KEY");
2402 env::remove_var("DEEPSEEK_BASE_URL");
2403 env::remove_var("DEEPSEEK_HTTP_HEADERS");
2404 env::remove_var("DEEPSEEK_MODEL");
2405 env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL");
2406 env::remove_var("DEEPSEEK_PROVIDER");
2407 env::remove_var("DEEPSEEK_AUTH_MODE");
2408 env::remove_var("CODEWHALE_PROVIDER");
2409 env::remove_var("CODEWHALE_MODEL");
2410 env::remove_var("CODEWHALE_BASE_URL");
2411 env::remove_var("NVIDIA_API_KEY");
2412 env::remove_var("NVIDIA_NIM_API_KEY");
2413 env::remove_var("NIM_BASE_URL");
2414 env::remove_var("NVIDIA_BASE_URL");
2415 env::remove_var("NVIDIA_NIM_BASE_URL");
2416 env::remove_var("OPENROUTER_API_KEY");
2417 env::remove_var("OPENROUTER_BASE_URL");
2418 env::remove_var("XIAOMI_MIMO_API_KEY");
2419 env::remove_var("XIAOMI_API_KEY");
2420 env::remove_var("MIMO_API_KEY");
2421 env::remove_var("XIAOMI_MIMO_BASE_URL");
2422 env::remove_var("MIMO_BASE_URL");
2423 env::remove_var("XIAOMI_MIMO_MODEL");
2424 env::remove_var("MIMO_MODEL");
2425 env::remove_var("WANJIE_ARK_API_KEY");
2426 env::remove_var("WANJIE_ARK_BASE_URL");
2427 env::remove_var("WANJIE_BASE_URL");
2428 env::remove_var("WANJIE_MAAS_BASE_URL");
2429 env::remove_var("WANJIE_ARK_MODEL");
2430 env::remove_var("WANJIE_MODEL");
2431 env::remove_var("WANJIE_MAAS_MODEL");
2432 env::remove_var("NOVITA_API_KEY");
2433 env::remove_var("NOVITA_BASE_URL");
2434 env::remove_var("FIREWORKS_API_KEY");
2435 env::remove_var("FIREWORKS_BASE_URL");
2436 env::remove_var("SILICONFLOW_API_KEY");
2437 env::remove_var("SILICONFLOW_BASE_URL");
2438 env::remove_var("SILICONFLOW_MODEL");
2439 env::remove_var("MOONSHOT_API_KEY");
2440 env::remove_var("MOONSHOT_BASE_URL");
2441 env::remove_var("MOONSHOT_MODEL");
2442 env::remove_var("KIMI_API_KEY");
2443 env::remove_var("KIMI_BASE_URL");
2444 env::remove_var("KIMI_MODEL");
2445 env::remove_var("KIMI_MODEL_NAME");
2446 env::remove_var("SGLANG_API_KEY");
2447 env::remove_var("SGLANG_BASE_URL");
2448 env::remove_var("VLLM_API_KEY");
2449 env::remove_var("VLLM_BASE_URL");
2450 env::remove_var("OLLAMA_API_KEY");
2451 env::remove_var("OLLAMA_BASE_URL");
2452 }
2453 guard
2454 }
2455
2456 unsafe fn restore_var(key: &str, value: Option<OsString>) {
2457 if let Some(value) = value {
2458 unsafe { env::set_var(key, value) };
2459 } else {
2460 unsafe { env::remove_var(key) };
2461 }
2462 }
2463 }
2464
2465 impl Drop for EnvGuard {
2466 fn drop(&mut self) {
2467 unsafe {
2469 Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take());
2470 Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take());
2471 Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take());
2472 Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take());
2473 Self::restore_var(
2474 "DEEPSEEK_DEFAULT_TEXT_MODEL",
2475 self.deepseek_default_text_model.take(),
2476 );
2477 Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take());
2478 Self::restore_var("DEEPSEEK_AUTH_MODE", self.deepseek_auth_mode.take());
2479 Self::restore_var("CODEWHALE_PROVIDER", self.codewhale_provider.take());
2480 Self::restore_var("CODEWHALE_MODEL", self.codewhale_model.take());
2481 Self::restore_var("CODEWHALE_BASE_URL", self.codewhale_base_url.take());
2482 Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take());
2483 Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take());
2484 Self::restore_var("NIM_BASE_URL", self.nim_base_url.take());
2485 Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take());
2486 Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take());
2487 Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take());
2488 Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take());
2489 Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take());
2490 Self::restore_var("XIAOMI_API_KEY", self.xiaomi_api_key.take());
2491 Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take());
2492 Self::restore_var("XIAOMI_MIMO_BASE_URL", self.xiaomi_mimo_base_url.take());
2493 Self::restore_var("MIMO_BASE_URL", self.mimo_base_url.take());
2494 Self::restore_var("XIAOMI_MIMO_MODEL", self.xiaomi_mimo_model.take());
2495 Self::restore_var("MIMO_MODEL", self.mimo_model.take());
2496 Self::restore_var("WANJIE_ARK_API_KEY", self.wanjie_ark_api_key.take());
2497 Self::restore_var("WANJIE_ARK_BASE_URL", self.wanjie_ark_base_url.take());
2498 Self::restore_var("WANJIE_BASE_URL", self.wanjie_base_url.take());
2499 Self::restore_var("WANJIE_MAAS_BASE_URL", self.wanjie_maas_base_url.take());
2500 Self::restore_var("VOLCENGINE_MODEL", self.volcengine_model.take());
2501 Self::restore_var("WANJIE_ARK_MODEL", self.wanjie_ark_model.take());
2502 Self::restore_var("WANJIE_MODEL", self.wanjie_model.take());
2503 Self::restore_var("WANJIE_MAAS_MODEL", self.wanjie_maas_model.take());
2504 Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take());
2505 Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take());
2506 Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take());
2507 Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take());
2508 Self::restore_var("SILICONFLOW_API_KEY", self.siliconflow_api_key.take());
2509 Self::restore_var("SILICONFLOW_BASE_URL", self.siliconflow_base_url.take());
2510 Self::restore_var("SILICONFLOW_MODEL", self.siliconflow_model.take());
2511 Self::restore_var("MOONSHOT_API_KEY", self.moonshot_api_key.take());
2512 Self::restore_var("MOONSHOT_BASE_URL", self.moonshot_base_url.take());
2513 Self::restore_var("MOONSHOT_MODEL", self.moonshot_model.take());
2514 Self::restore_var("KIMI_API_KEY", self.kimi_api_key.take());
2515 Self::restore_var("KIMI_BASE_URL", self.kimi_base_url.take());
2516 Self::restore_var("KIMI_MODEL", self.kimi_model.take());
2517 Self::restore_var("KIMI_MODEL_NAME", self.kimi_model_name.take());
2518 Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take());
2519 Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take());
2520 Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take());
2521 Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take());
2522 Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take());
2523 Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take());
2524 }
2525 }
2526 }
2527
2528 struct RecordingSecretsStore {
2529 gets: Mutex<Vec<String>>,
2530 value: Option<String>,
2531 }
2532
2533 impl RecordingSecretsStore {
2534 fn with_value(value: &str) -> Self {
2535 Self {
2536 gets: Mutex::new(Vec::new()),
2537 value: Some(value.to_string()),
2538 }
2539 }
2540 }
2541
2542 impl codewhale_secrets::KeyringStore for RecordingSecretsStore {
2543 fn get(&self, key: &str) -> Result<Option<String>, codewhale_secrets::SecretsError> {
2544 self.gets.lock().unwrap().push(key.to_string());
2545 Ok(self.value.clone())
2546 }
2547
2548 fn set(&self, _key: &str, _value: &str) -> Result<(), codewhale_secrets::SecretsError> {
2549 Ok(())
2550 }
2551
2552 fn delete(&self, _key: &str) -> Result<(), codewhale_secrets::SecretsError> {
2553 Ok(())
2554 }
2555
2556 fn backend_name(&self) -> &'static str {
2557 "recording"
2558 }
2559 }
2560
2561 #[test]
2562 fn root_deepseek_fields_are_runtime_fallbacks() {
2563 let _lock = env_lock();
2564 let _env = EnvGuard::without_deepseek_runtime_overrides();
2565 let config = ConfigToml {
2566 api_key: Some("root-key".to_string()),
2567 base_url: Some("https://api.deepseek.com".to_string()),
2568 default_text_model: Some("deepseek-v4-pro".to_string()),
2569 ..ConfigToml::default()
2570 };
2571
2572 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2573
2574 assert_eq!(resolved.provider, ProviderKind::Deepseek);
2575 assert_eq!(resolved.api_key.as_deref(), Some("root-key"));
2576 assert_eq!(resolved.base_url, "https://api.deepseek.com");
2577 assert_eq!(resolved.model, "deepseek-v4-pro");
2578 }
2579
2580 #[test]
2581 fn deepseek_runtime_defaults_to_beta_endpoint() {
2582 let _lock = env_lock();
2583 let _env = EnvGuard::without_deepseek_runtime_overrides();
2584 let config = ConfigToml::default();
2585
2586 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2587
2588 assert_eq!(resolved.provider, ProviderKind::Deepseek);
2589 assert_eq!(resolved.base_url, DEFAULT_DEEPSEEK_BASE_URL);
2590 assert_eq!(resolved.model, DEFAULT_DEEPSEEK_MODEL);
2591 }
2592
2593 #[test]
2594 fn provider_specific_deepseek_fields_override_tui_compat_fields() {
2595 let _lock = env_lock();
2596 let _env = EnvGuard::without_deepseek_runtime_overrides();
2597 let mut config = ConfigToml {
2598 api_key: Some("root-key".to_string()),
2599 base_url: Some("https://api.deepseek.com".to_string()),
2600 default_text_model: Some("deepseek-v4-pro".to_string()),
2601 ..ConfigToml::default()
2602 };
2603 config.providers.deepseek.api_key = Some("provider-key".to_string());
2604 config.providers.deepseek.base_url = Some("https://gateway.example/v1".to_string());
2605 config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
2606
2607 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2608
2609 assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
2610 assert_eq!(resolved.base_url, "https://gateway.example/v1");
2611 assert_eq!(resolved.model, "deepseek-v4-flash");
2612 }
2613
2614 #[test]
2615 fn provider_http_headers_override_root_headers() {
2616 let _lock = env_lock();
2617 let _env = EnvGuard::without_deepseek_runtime_overrides();
2618 let mut config = ConfigToml {
2619 api_key: Some("root-key".to_string()),
2620 base_url: Some("https://api.deepseek.com".to_string()),
2621 default_text_model: Some("deepseek-v4-pro".to_string()),
2622 ..ConfigToml::default()
2623 };
2624 config.providers.deepseek.api_key = Some("provider-key".to_string());
2625 config.providers.deepseek.base_url = Some("https://gateway.example/v1".to_string());
2626 config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
2627 config
2628 .http_headers
2629 .insert("X-Shared".to_string(), "root".to_string());
2630 config
2631 .providers
2632 .deepseek
2633 .http_headers
2634 .insert("X-Model-Provider-Id".to_string(), "tongyi".to_string());
2635 config
2636 .providers
2637 .deepseek
2638 .http_headers
2639 .insert("X-Shared".to_string(), "provider".to_string());
2640
2641 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2642
2643 assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
2644 assert_eq!(resolved.base_url, "https://gateway.example/v1");
2645 assert_eq!(resolved.model, "deepseek-v4-flash");
2646 assert_eq!(
2647 resolved
2648 .http_headers
2649 .get("X-Model-Provider-Id")
2650 .map(String::as_str),
2651 Some("tongyi")
2652 );
2653 assert_eq!(
2654 resolved.http_headers.get("X-Shared").map(String::as_str),
2655 Some("provider")
2656 );
2657 }
2658
2659 #[test]
2660 fn http_headers_env_overrides_config() {
2661 let _lock = env_lock();
2662 let _env = EnvGuard::without_deepseek_runtime_overrides();
2663 let mut config = ConfigToml::default();
2664 config
2665 .http_headers
2666 .insert("X-Model-Provider-Id".to_string(), "from-file".to_string());
2667 unsafe {
2669 env::set_var("DEEPSEEK_HTTP_HEADERS", "X-Model-Provider-Id=from-env");
2670 }
2671
2672 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2673
2674 assert_eq!(
2675 resolved
2676 .http_headers
2677 .get("X-Model-Provider-Id")
2678 .map(String::as_str),
2679 Some("from-env")
2680 );
2681 }
2682
2683 #[test]
2684 fn nvidia_nim_provider_defaults_to_catalog_endpoint_and_model() {
2685 let _lock = env_lock();
2686 let _env = EnvGuard::without_deepseek_runtime_overrides();
2687 let config = ConfigToml {
2688 provider: ProviderKind::NvidiaNim,
2689 ..ConfigToml::default()
2690 };
2691
2692 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2693
2694 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
2695 assert_eq!(resolved.base_url, DEFAULT_NVIDIA_NIM_BASE_URL);
2696 assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
2697 }
2698
2699 #[test]
2700 fn nvidia_nim_provider_uses_provider_specific_credentials() {
2701 let _lock = env_lock();
2702 let _env = EnvGuard::without_deepseek_runtime_overrides();
2703 let mut config = ConfigToml {
2704 provider: ProviderKind::NvidiaNim,
2705 ..ConfigToml::default()
2706 };
2707 config.providers.nvidia_nim.api_key = Some("nim-key".to_string());
2708 config.providers.nvidia_nim.base_url = Some("https://nim.example/v1".to_string());
2709 config.providers.nvidia_nim.model = Some("deepseek-ai/deepseek-v4-pro".to_string());
2710
2711 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2712
2713 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
2714 assert_eq!(resolved.api_key.as_deref(), Some("nim-key"));
2715 assert_eq!(resolved.base_url, "https://nim.example/v1");
2716 assert_eq!(resolved.model, "deepseek-ai/deepseek-v4-pro");
2717 }
2718
2719 #[test]
2720 fn nvidia_nim_provider_normalizes_flash_aliases() {
2721 let _lock = env_lock();
2722 let _env = EnvGuard::without_deepseek_runtime_overrides();
2723 let cli = CliRuntimeOverrides {
2724 provider: Some(ProviderKind::NvidiaNim),
2725 model: Some("deepseek-v4-flash".to_string()),
2726 ..CliRuntimeOverrides::default()
2727 };
2728
2729 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
2730
2731 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
2732 assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_FLASH_MODEL);
2733 }
2734
2735 #[test]
2736 fn nvidia_nim_provider_uses_nvidia_env_credentials() {
2737 let _lock = env_lock();
2738 let _env = EnvGuard::without_deepseek_runtime_overrides();
2739 unsafe {
2741 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
2742 env::set_var("NVIDIA_API_KEY", "nim-env-key");
2743 env::set_var("NVIDIA_NIM_BASE_URL", "https://nim-env.example/v1");
2744 }
2745
2746 let config = ConfigToml::default();
2747 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2748
2749 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
2750 assert_eq!(resolved.api_key.as_deref(), Some("nim-env-key"));
2751 assert_eq!(resolved.base_url, "https://nim-env.example/v1");
2752 assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
2753 }
2754
2755 #[test]
2756 fn nvidia_nim_provider_accepts_short_nim_base_url_alias() {
2757 let _lock = env_lock();
2758 let _env = EnvGuard::without_deepseek_runtime_overrides();
2759 unsafe {
2761 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
2762 env::set_var("NVIDIA_API_KEY", "nim-env-key");
2763 env::set_var("NIM_BASE_URL", "https://short-nim.example/v1");
2764 }
2765
2766 let config = ConfigToml::default();
2767 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2768
2769 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
2770 assert_eq!(resolved.base_url, "https://short-nim.example/v1");
2771 }
2772
2773 #[test]
2774 fn nvidia_nim_provider_can_fallback_to_deepseek_api_key_env() {
2775 let _lock = env_lock();
2776 let _env = EnvGuard::without_deepseek_runtime_overrides();
2777 unsafe {
2779 env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
2780 env::set_var("DEEPSEEK_API_KEY", "deepseek-compat-key");
2781 }
2782
2783 let config = ConfigToml::default();
2784 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2785
2786 assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
2787 assert_eq!(resolved.api_key.as_deref(), Some("deepseek-compat-key"));
2788 }
2789
2790 #[test]
2791 fn list_values_redacts_root_api_key() {
2792 let config = ConfigToml {
2793 api_key: Some("sk-deepseek-secret".to_string()),
2794 ..ConfigToml::default()
2795 };
2796
2797 let values = config.list_values();
2798
2799 assert_eq!(
2800 values.get("api_key").map(String::as_str),
2801 Some("sk-d***cret")
2802 );
2803 }
2804
2805 #[test]
2806 fn list_values_fully_redacts_short_api_key() {
2807 let config = ConfigToml {
2808 api_key: Some("short-key".to_string()),
2809 ..ConfigToml::default()
2810 };
2811
2812 let values = config.list_values();
2813
2814 assert_eq!(values.get("api_key").map(String::as_str), Some("********"));
2815 }
2816
2817 #[test]
2818 fn get_display_value_redacts_sensitive_keys() {
2819 let mut config = ConfigToml {
2820 api_key: Some("sk-deepseek-secret".to_string()),
2821 ..ConfigToml::default()
2822 };
2823 config.providers.openrouter.api_key = Some("openrouter-secret-value".to_string());
2824 config.model = Some("deepseek-v4-pro".to_string());
2825
2826 assert_eq!(
2827 config.get_display_value("api_key").as_deref(),
2828 Some("sk-d***cret")
2829 );
2830 assert_eq!(
2831 config
2832 .get_display_value("providers.openrouter.api_key")
2833 .as_deref(),
2834 Some("open***alue")
2835 );
2836 assert_eq!(
2837 config.get_display_value("model").as_deref(),
2838 Some("deepseek-v4-pro")
2839 );
2840 }
2841
2842 #[test]
2843 fn hook_sinks_config_uses_separate_table_from_lifecycle_hooks() -> Result<()> {
2844 let raw = r#"
2845[hooks]
2846enabled = true
2847default_timeout_secs = 20
2848
2849[[hooks.hooks]]
2850event = "message_submit"
2851command = "echo ok"
2852
2853[hook_sinks]
2854unix_socket_path = "/tmp/cw-hooks.sock"
2855"#;
2856
2857 let config: ConfigToml = toml::from_str(raw)?;
2858
2859 assert_eq!(
2860 config.get_value("hook_sinks.unix_socket_path").as_deref(),
2861 Some("/tmp/cw-hooks.sock")
2862 );
2863 assert!(
2864 config.extras.contains_key("hooks"),
2865 "legacy lifecycle hooks table must remain an opaque extra"
2866 );
2867
2868 let serialized = toml::to_string_pretty(&config)?;
2869 let round_tripped: ConfigToml = toml::from_str(&serialized)?;
2870 let hooks = round_tripped
2871 .extras
2872 .get("hooks")
2873 .and_then(toml::Value::as_table)
2874 .expect("hooks table preserved");
2875
2876 assert_eq!(
2877 hooks.get("enabled").and_then(toml::Value::as_bool),
2878 Some(true)
2879 );
2880 assert_eq!(
2881 hooks
2882 .get("default_timeout_secs")
2883 .and_then(toml::Value::as_integer),
2884 Some(20)
2885 );
2886 assert!(
2887 hooks.get("hooks").and_then(toml::Value::as_array).is_some(),
2888 "nested lifecycle hooks array must survive config rewrites"
2889 );
2890 assert_eq!(
2891 round_tripped
2892 .get_value("hook_sinks.unix_socket_path")
2893 .as_deref(),
2894 Some("/tmp/cw-hooks.sock")
2895 );
2896
2897 Ok(())
2898 }
2899
2900 #[test]
2901 fn hook_sinks_unix_socket_path_round_trips_through_key_value_api() -> Result<()> {
2902 let mut config = ConfigToml::default();
2903
2904 config.set_value("hook_sinks.unix_socket_path", "/tmp/cw-events.sock")?;
2905
2906 assert_eq!(
2907 config.get_value("hook_sinks.unix_socket_path").as_deref(),
2908 Some("/tmp/cw-events.sock")
2909 );
2910 assert_eq!(
2911 config
2912 .list_values()
2913 .get("hook_sinks.unix_socket_path")
2914 .map(String::as_str),
2915 Some("/tmp/cw-events.sock")
2916 );
2917
2918 config.unset_value("hook_sinks.unix_socket_path")?;
2919 assert_eq!(config.get_value("hook_sinks.unix_socket_path"), None);
2920
2921 Ok(())
2922 }
2923
2924 #[test]
2936 fn moonshot_kimi_code_smoke_config_set_then_resolve() -> Result<()> {
2937 let _lock = env_lock();
2938 let _env = EnvGuard::without_deepseek_runtime_overrides();
2939
2940 let mut config = ConfigToml {
2941 provider: ProviderKind::Deepseek,
2942 default_text_model: Some("deepseek-v4-pro".to_string()),
2943 ..ConfigToml::default()
2944 };
2945
2946 config.set_value("providers.moonshot.api_key", "kimi-code-key-placeholder")?;
2948 config.set_value("providers.moonshot.auth_mode", "api_key")?;
2949 config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?;
2950 config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?;
2951
2952 unsafe { env::set_var("CODEWHALE_PROVIDER", "moonshot") };
2955
2956 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
2957
2958 assert_eq!(resolved.provider, ProviderKind::Moonshot);
2959 assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
2960 assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
2961 assert_eq!(resolved.auth_mode.as_deref(), Some("api_key"));
2962 assert_eq!(
2963 resolved.api_key.as_deref(),
2964 Some("kimi-code-key-placeholder")
2965 );
2966 assert_eq!(
2967 resolved.api_key_source,
2968 Some(RuntimeApiKeySource::ConfigFile)
2969 );
2970 Ok(())
2971 }
2972
2973 #[test]
2974 fn moonshot_provider_config_values_round_trip() -> Result<()> {
2975 let mut config = ConfigToml::default();
2976
2977 config.set_value("providers.moonshot.api_key", "moonshot-secret-value")?;
2978 config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?;
2979 config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?;
2980 config.set_value("providers.moonshot.auth_mode", "api_key")?;
2981 config.set_value("providers.moonshot.http_headers", "X-Test=ok")?;
2982
2983 assert_eq!(
2984 config
2985 .get_display_value("providers.moonshot.api_key")
2986 .as_deref(),
2987 Some("moon***alue")
2988 );
2989 assert_eq!(
2990 config.get_value("providers.moonshot.base_url").as_deref(),
2991 Some(DEFAULT_KIMI_CODE_BASE_URL)
2992 );
2993 assert_eq!(
2994 config.get_value("providers.moonshot.model").as_deref(),
2995 Some(DEFAULT_KIMI_CODE_MODEL)
2996 );
2997 assert_eq!(
2998 config.get_value("providers.moonshot.auth_mode").as_deref(),
2999 Some("api_key")
3000 );
3001 assert_eq!(
3002 config
3003 .list_values()
3004 .get("providers.moonshot.api_key")
3005 .map(String::as_str),
3006 Some("moon***alue")
3007 );
3008
3009 config.unset_value("providers.moonshot.auth_mode")?;
3010 config.unset_value("providers.moonshot.base_url")?;
3011 config.unset_value("providers.moonshot.model")?;
3012
3013 assert_eq!(config.get_value("providers.moonshot.auth_mode"), None);
3014 assert_eq!(config.get_value("providers.moonshot.base_url"), None);
3015 assert_eq!(config.get_value("providers.moonshot.model"), None);
3016 Ok(())
3017 }
3018
3019 #[test]
3020 fn project_merge_denies_credentials_endpoints_and_provider_selection() {
3021 let mut base = ConfigToml {
3022 provider: ProviderKind::Deepseek,
3023 api_key: Some("user-key".to_string()),
3024 base_url: Some("https://api.deepseek.com".to_string()),
3025 default_text_model: Some("deepseek-v4-flash".to_string()),
3026 ..ConfigToml::default()
3027 };
3028 base.providers.openrouter.api_key = Some("user-openrouter-key".to_string());
3029
3030 let mut project = ConfigToml {
3031 provider: ProviderKind::Openrouter,
3032 api_key: Some("attacker-key".to_string()),
3033 base_url: Some("https://evil.example/v1".to_string()),
3034 default_text_model: Some("deepseek-v4-pro".to_string()),
3035 auth_mode: Some("oauth".to_string()),
3036 telemetry: Some(true),
3037 ..ConfigToml::default()
3038 };
3039 project.providers.openrouter.api_key = Some("attacker-openrouter-key".to_string());
3040 project.providers.openrouter.base_url = Some("https://evil.example/openrouter".to_string());
3041 project.providers.openrouter.model = Some("deepseek/deepseek-v4-pro".to_string());
3042
3043 base.merge_project_overrides(project);
3044
3045 assert_eq!(base.provider, ProviderKind::Deepseek);
3046 assert_eq!(base.api_key.as_deref(), Some("user-key"));
3047 assert_eq!(base.base_url.as_deref(), Some("https://api.deepseek.com"));
3048 assert_eq!(base.auth_mode, None);
3049 assert_eq!(base.telemetry, None);
3050 assert_eq!(
3051 base.providers.openrouter.api_key.as_deref(),
3052 Some("user-openrouter-key")
3053 );
3054 assert_eq!(base.providers.openrouter.base_url, None);
3055 assert_eq!(base.default_text_model.as_deref(), Some("deepseek-v4-pro"));
3056 assert_eq!(
3057 base.providers.openrouter.model.as_deref(),
3058 Some("deepseek/deepseek-v4-pro")
3059 );
3060 }
3061
3062 #[test]
3063 fn project_merge_only_tightens_approval_and_sandbox_policy() {
3064 let mut strict = ConfigToml {
3065 approval_policy: Some("never".to_string()),
3066 sandbox_mode: Some("read-only".to_string()),
3067 ..ConfigToml::default()
3068 };
3069 strict.merge_project_overrides(ConfigToml {
3070 approval_policy: Some("on-request".to_string()),
3071 sandbox_mode: Some("workspace-write".to_string()),
3072 ..ConfigToml::default()
3073 });
3074 assert_eq!(strict.approval_policy.as_deref(), Some("never"));
3075 assert_eq!(strict.sandbox_mode.as_deref(), Some("read-only"));
3076
3077 let mut permissive = ConfigToml {
3078 approval_policy: Some("auto".to_string()),
3079 sandbox_mode: Some("workspace-write".to_string()),
3080 ..ConfigToml::default()
3081 };
3082 permissive.merge_project_overrides(ConfigToml {
3083 approval_policy: Some("never".to_string()),
3084 sandbox_mode: Some("read-only".to_string()),
3085 ..ConfigToml::default()
3086 });
3087 assert_eq!(permissive.approval_policy.as_deref(), Some("never"));
3088 assert_eq!(permissive.sandbox_mode.as_deref(), Some("read-only"));
3089
3090 let mut unset = ConfigToml::default();
3091 unset.merge_project_overrides(ConfigToml {
3092 approval_policy: Some("on-request".to_string()),
3093 sandbox_mode: Some("workspace-write".to_string()),
3094 ..ConfigToml::default()
3095 });
3096 assert_eq!(unset.approval_policy, None);
3097 assert_eq!(unset.sandbox_mode, None);
3098 }
3099
3100 #[test]
3101 fn list_values_redacts_unicode_api_key_without_byte_slicing() {
3102 let config = ConfigToml {
3103 api_key: Some("密钥密钥密钥密钥123456789".to_string()),
3104 ..ConfigToml::default()
3105 };
3106
3107 let values = config.list_values();
3108
3109 assert_eq!(
3110 values.get("api_key").map(String::as_str),
3111 Some("密钥密钥***6789")
3112 );
3113 }
3114
3115 #[test]
3116 fn normalize_config_file_path_rejects_traversal() {
3117 let err = normalize_config_file_path(PathBuf::from("../config.toml"))
3118 .expect_err("traversal path should fail");
3119 assert!(format!("{err:#}").contains("cannot contain '..'"));
3120 }
3121
3122 #[cfg(unix)]
3123 #[test]
3124 fn save_clamps_existing_config_permissions() {
3125 use std::time::{SystemTime, UNIX_EPOCH};
3126
3127 let unique = SystemTime::now()
3128 .duration_since(UNIX_EPOCH)
3129 .expect("clock")
3130 .as_nanos();
3131 let dir = std::env::temp_dir().join(format!(
3132 "deepseek-config-perms-{}-{unique}",
3133 std::process::id()
3134 ));
3135 fs::create_dir_all(&dir).expect("mkdir");
3136 let path = dir.join(CONFIG_FILE_NAME);
3137 fs::write(&path, "api_key = \"old\"\n").expect("seed config");
3138 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod seed");
3139
3140 let store = ConfigStore {
3141 path: path.clone(),
3142 config: ConfigToml {
3143 api_key: Some("new-secret".to_string()),
3144 ..ConfigToml::default()
3145 },
3146 };
3147 store.save().expect("save");
3148
3149 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
3150 assert_eq!(mode, 0o600);
3151
3152 let _ = fs::remove_dir_all(dir);
3153 }
3154
3155 #[test]
3156 fn provider_kind_parses_openrouter_and_novita_aliases() {
3157 assert_eq!(
3158 ProviderKind::parse("openrouter"),
3159 Some(ProviderKind::Openrouter)
3160 );
3161 assert_eq!(
3162 ProviderKind::parse("OPEN_ROUTER"),
3163 Some(ProviderKind::Openrouter)
3164 );
3165 assert_eq!(
3166 ProviderKind::parse("xiaomi-mimo"),
3167 Some(ProviderKind::XiaomiMimo)
3168 );
3169 assert_eq!(
3170 ProviderKind::parse("xiaomi"),
3171 Some(ProviderKind::XiaomiMimo)
3172 );
3173 assert_eq!(ProviderKind::parse("novita"), Some(ProviderKind::Novita));
3174 assert_eq!(ProviderKind::parse("Novita"), Some(ProviderKind::Novita));
3175 assert_eq!(
3176 ProviderKind::parse("fireworks-ai"),
3177 Some(ProviderKind::Fireworks)
3178 );
3179 assert_eq!(
3180 ProviderKind::parse("silicon-flow"),
3181 Some(ProviderKind::Siliconflow)
3182 );
3183 assert_eq!(
3184 ProviderKind::parse("silicon_flow"),
3185 Some(ProviderKind::Siliconflow)
3186 );
3187 assert_eq!(ProviderKind::parse("kimi"), Some(ProviderKind::Moonshot));
3188 assert_eq!(
3189 ProviderKind::parse("moonshot-ai"),
3190 Some(ProviderKind::Moonshot)
3191 );
3192 assert_eq!(ProviderKind::parse("sg-lang"), Some(ProviderKind::Sglang));
3193 assert_eq!(ProviderKind::parse("v-llm"), Some(ProviderKind::Vllm));
3194 assert_eq!(ProviderKind::parse("vllm"), Some(ProviderKind::Vllm));
3195 assert_eq!(ProviderKind::parse("ollama"), Some(ProviderKind::Ollama));
3196 assert_eq!(
3197 ProviderKind::parse("ollama-local"),
3198 Some(ProviderKind::Ollama)
3199 );
3200 assert_eq!(
3201 ProviderKind::parse("wanjie-ark"),
3202 Some(ProviderKind::WanjieArk)
3203 );
3204 assert_eq!(
3205 ProviderKind::parse("ark_wanjie"),
3206 Some(ProviderKind::WanjieArk)
3207 );
3208
3209 let parsed: ConfigToml =
3210 toml::from_str("provider = \"ark-wanjie\"").expect("wanjie provider alias");
3211 assert_eq!(parsed.provider, ProviderKind::WanjieArk);
3212
3213 let parsed: ConfigToml =
3214 toml::from_str("provider = \"silicon-flow\"").expect("siliconflow provider alias");
3215 assert_eq!(parsed.provider, ProviderKind::Siliconflow);
3216 }
3217
3218 #[test]
3219 fn provider_kind_accepts_legacy_deepseek_cn_aliases() {
3220 for alias in [
3221 "deepseek-cn",
3222 "deepseek_china",
3223 "deepseekcn",
3224 "deepseek-china",
3225 ] {
3226 assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Deepseek));
3227
3228 let parsed: ConfigToml =
3229 toml::from_str(&format!("provider = \"{alias}\"")).expect("legacy provider alias");
3230 assert_eq!(parsed.provider, ProviderKind::Deepseek);
3231 }
3232 }
3233
3234 #[test]
3235 fn openrouter_provider_defaults_to_canonical_endpoint_and_model() {
3236 let _lock = env_lock();
3237 let _env = EnvGuard::without_deepseek_runtime_overrides();
3238 let config = ConfigToml {
3239 provider: ProviderKind::Openrouter,
3240 ..ConfigToml::default()
3241 };
3242
3243 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3244
3245 assert_eq!(resolved.provider, ProviderKind::Openrouter);
3246 assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL);
3247 assert_eq!(resolved.model, DEFAULT_OPENROUTER_MODEL);
3248 }
3249
3250 #[test]
3251 fn xiaomi_mimo_provider_defaults_to_canonical_endpoint_and_model() {
3252 let _lock = env_lock();
3253 let _env = EnvGuard::without_deepseek_runtime_overrides();
3254 let config = ConfigToml {
3255 provider: ProviderKind::XiaomiMimo,
3256 ..ConfigToml::default()
3257 };
3258
3259 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3260
3261 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
3262 assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
3263 assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL);
3264 }
3265
3266 #[test]
3267 fn novita_provider_defaults_to_canonical_endpoint_and_model() {
3268 let _lock = env_lock();
3269 let _env = EnvGuard::without_deepseek_runtime_overrides();
3270 let config = ConfigToml {
3271 provider: ProviderKind::Novita,
3272 ..ConfigToml::default()
3273 };
3274
3275 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3276
3277 assert_eq!(resolved.provider, ProviderKind::Novita);
3278 assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL);
3279 assert_eq!(resolved.model, DEFAULT_NOVITA_MODEL);
3280 }
3281
3282 #[test]
3283 fn fireworks_provider_defaults_to_canonical_endpoint_and_model() {
3284 let _lock = env_lock();
3285 let _env = EnvGuard::without_deepseek_runtime_overrides();
3286 let config = ConfigToml {
3287 provider: ProviderKind::Fireworks,
3288 ..ConfigToml::default()
3289 };
3290
3291 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3292
3293 assert_eq!(resolved.provider, ProviderKind::Fireworks);
3294 assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL);
3295 assert_eq!(resolved.model, DEFAULT_FIREWORKS_MODEL);
3296 }
3297
3298 #[test]
3299 fn siliconflow_provider_defaults_to_canonical_endpoint_and_model() {
3300 let _lock = env_lock();
3301 let _env = EnvGuard::without_deepseek_runtime_overrides();
3302 let config = ConfigToml {
3303 provider: ProviderKind::Siliconflow,
3304 ..ConfigToml::default()
3305 };
3306
3307 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3308
3309 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
3310 assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_BASE_URL);
3311 assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL);
3312 }
3313
3314 #[test]
3315 fn moonshot_provider_defaults_to_kimi_k2() {
3316 let _lock = env_lock();
3317 let _env = EnvGuard::without_deepseek_runtime_overrides();
3318 let config = ConfigToml {
3319 provider: ProviderKind::Moonshot,
3320 ..ConfigToml::default()
3321 };
3322
3323 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3324
3325 assert_eq!(resolved.provider, ProviderKind::Moonshot);
3326 assert_eq!(resolved.base_url, DEFAULT_MOONSHOT_BASE_URL);
3327 assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL);
3328 }
3329
3330 #[test]
3331 fn moonshot_kimi_oauth_uses_kimi_code_endpoint_and_model() {
3332 let _lock = env_lock();
3333 let _env = EnvGuard::without_deepseek_runtime_overrides();
3334 let mut config = ConfigToml {
3335 provider: ProviderKind::Moonshot,
3336 ..ConfigToml::default()
3337 };
3338 config.providers.moonshot.auth_mode = Some("kimi_oauth".to_string());
3339
3340 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3341
3342 assert_eq!(resolved.provider, ProviderKind::Moonshot);
3343 assert_eq!(resolved.auth_mode.as_deref(), Some("kimi_oauth"));
3344 assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
3345 assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
3346 assert_eq!(resolved.api_key, None);
3347 assert_eq!(resolved.api_key_source, None);
3348 }
3349
3350 #[test]
3351 fn moonshot_kimi_code_api_key_endpoint_defaults_to_kimi_for_coding() {
3352 let _lock = env_lock();
3353 let _env = EnvGuard::without_deepseek_runtime_overrides();
3354 let mut config = ConfigToml {
3355 provider: ProviderKind::Moonshot,
3356 ..ConfigToml::default()
3357 };
3358 config.providers.moonshot.api_key = Some("kimi-code-key".to_string());
3359 config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string());
3360
3361 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3362
3363 assert_eq!(resolved.provider, ProviderKind::Moonshot);
3364 assert_eq!(resolved.auth_mode, None);
3365 assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
3366 assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
3367 assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key"));
3368 assert_eq!(
3369 resolved.api_key_source,
3370 Some(RuntimeApiKeySource::ConfigFile)
3371 );
3372 }
3373
3374 #[test]
3378 fn codewhale_provider_env_switches_active_provider() {
3379 let _lock = env_lock();
3380 let _env = EnvGuard::without_deepseek_runtime_overrides();
3381 unsafe {
3383 env::set_var("CODEWHALE_PROVIDER", "moonshot");
3384 }
3385 let mut config = ConfigToml {
3386 provider: ProviderKind::Deepseek,
3387 ..ConfigToml::default()
3388 };
3389 config.providers.moonshot.api_key = Some("kimi-code-key".to_string());
3390 config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string());
3391
3392 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3393
3394 assert_eq!(resolved.provider, ProviderKind::Moonshot);
3395 assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL);
3396 assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL);
3397 assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key"));
3398 }
3399
3400 #[test]
3405 fn codewhale_provider_env_wins_over_deepseek_provider_env() {
3406 let _lock = env_lock();
3407 let _env = EnvGuard::without_deepseek_runtime_overrides();
3408 unsafe {
3410 env::set_var("CODEWHALE_PROVIDER", "moonshot");
3411 env::set_var("DEEPSEEK_PROVIDER", "openrouter");
3412 }
3413 let config = ConfigToml {
3414 provider: ProviderKind::Deepseek,
3415 ..ConfigToml::default()
3416 };
3417
3418 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3419
3420 assert_eq!(resolved.provider, ProviderKind::Moonshot);
3421 }
3422
3423 #[test]
3427 fn codewhale_model_env_alias_overrides_default_for_active_provider() {
3428 let _lock = env_lock();
3429 let _env = EnvGuard::without_deepseek_runtime_overrides();
3430 unsafe {
3432 env::set_var("CODEWHALE_PROVIDER", "moonshot");
3433 env::set_var("CODEWHALE_MODEL", "custom-kimi-test-model");
3434 }
3435 let config = ConfigToml::default();
3436
3437 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3438
3439 assert_eq!(resolved.provider, ProviderKind::Moonshot);
3440 assert_eq!(resolved.model, "custom-kimi-test-model");
3441 }
3442
3443 #[test]
3444 fn blank_codewhale_model_env_alias_does_not_override_default_for_active_provider() {
3445 let _lock = env_lock();
3446 let _env = EnvGuard::without_deepseek_runtime_overrides();
3447 unsafe {
3449 env::set_var("CODEWHALE_PROVIDER", "moonshot");
3450 env::set_var("CODEWHALE_MODEL", " ");
3451 }
3452 let config = ConfigToml::default();
3453
3454 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3455
3456 assert_eq!(resolved.provider, ProviderKind::Moonshot);
3457 assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL);
3458 }
3459
3460 #[test]
3461 fn deepseek_default_text_model_legacy_alias_still_overrides_active_provider_model() {
3462 let _lock = env_lock();
3463 let _env = EnvGuard::without_deepseek_runtime_overrides();
3464 unsafe {
3466 env::set_var("CODEWHALE_PROVIDER", "moonshot");
3467 env::set_var("DEEPSEEK_DEFAULT_TEXT_MODEL", "legacy-env-model");
3468 }
3469 let config = ConfigToml::default();
3470
3471 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3472
3473 assert_eq!(resolved.provider, ProviderKind::Moonshot);
3474 assert_eq!(resolved.model, "legacy-env-model");
3475 }
3476
3477 #[test]
3478 fn wanjie_ark_provider_defaults_to_openai_compatible_endpoint_and_model() {
3479 let _lock = env_lock();
3480 let _env = EnvGuard::without_deepseek_runtime_overrides();
3481 let config = ConfigToml {
3482 provider: ProviderKind::WanjieArk,
3483 ..ConfigToml::default()
3484 };
3485
3486 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3487
3488 assert_eq!(resolved.provider, ProviderKind::WanjieArk);
3489 assert_eq!(resolved.base_url, DEFAULT_WANJIE_ARK_BASE_URL);
3490 assert_eq!(resolved.model, DEFAULT_WANJIE_ARK_MODEL);
3491 }
3492
3493 #[test]
3494 fn sglang_provider_defaults_to_local_endpoint_and_model() {
3495 let _lock = env_lock();
3496 let _env = EnvGuard::without_deepseek_runtime_overrides();
3497 let config = ConfigToml {
3498 provider: ProviderKind::Sglang,
3499 ..ConfigToml::default()
3500 };
3501
3502 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3503
3504 assert_eq!(resolved.provider, ProviderKind::Sglang);
3505 assert_eq!(resolved.base_url, DEFAULT_SGLANG_BASE_URL);
3506 assert_eq!(resolved.model, DEFAULT_SGLANG_MODEL);
3507 }
3508
3509 #[test]
3510 fn vllm_provider_defaults_to_local_endpoint_and_model() {
3511 let _lock = env_lock();
3512 let _env = EnvGuard::without_deepseek_runtime_overrides();
3513 let config = ConfigToml {
3514 provider: ProviderKind::Vllm,
3515 ..ConfigToml::default()
3516 };
3517
3518 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3519
3520 assert_eq!(resolved.provider, ProviderKind::Vllm);
3521 assert_eq!(resolved.base_url, DEFAULT_VLLM_BASE_URL);
3522 assert_eq!(resolved.model, DEFAULT_VLLM_MODEL);
3523 }
3524
3525 #[test]
3526 fn ollama_provider_defaults_to_local_endpoint_and_small_model() {
3527 let _lock = env_lock();
3528 let _env = EnvGuard::without_deepseek_runtime_overrides();
3529 let config = ConfigToml {
3530 provider: ProviderKind::Ollama,
3531 ..ConfigToml::default()
3532 };
3533
3534 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3535
3536 assert_eq!(resolved.provider, ProviderKind::Ollama);
3537 assert_eq!(resolved.base_url, DEFAULT_OLLAMA_BASE_URL);
3538 assert_eq!(resolved.model, DEFAULT_OLLAMA_MODEL);
3539 assert_eq!(resolved.api_key, None);
3540 }
3541
3542 #[test]
3543 fn self_hosted_providers_do_not_probe_secret_store_by_default() {
3544 let _lock = env_lock();
3545 let _env = EnvGuard::without_deepseek_runtime_overrides();
3546 let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
3547 let secrets = Secrets::new(store.clone());
3548
3549 for provider in [
3550 ProviderKind::Sglang,
3551 ProviderKind::Vllm,
3552 ProviderKind::Ollama,
3553 ] {
3554 let config = ConfigToml {
3555 provider,
3556 ..ConfigToml::default()
3557 };
3558
3559 let resolved = config
3560 .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
3561
3562 assert_eq!(resolved.provider, provider);
3563 assert_eq!(resolved.api_key, None);
3564 }
3565
3566 assert!(
3567 store.gets.lock().unwrap().is_empty(),
3568 "self-hosted providers should not read the secret store by default"
3569 );
3570 }
3571
3572 #[test]
3573 fn self_hosted_api_key_auth_can_use_secret_store_when_requested() {
3574 let _lock = env_lock();
3575 let _env = EnvGuard::without_deepseek_runtime_overrides();
3576 let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
3577 let secrets = Secrets::new(store.clone());
3578 let config = ConfigToml {
3579 provider: ProviderKind::Ollama,
3580 auth_mode: Some("api_key".to_string()),
3581 ..ConfigToml::default()
3582 };
3583
3584 let resolved =
3585 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
3586
3587 assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key"));
3588 assert_eq!(store.gets.lock().unwrap().as_slice(), ["ollama"]);
3589 }
3590
3591 #[test]
3592 fn moonshot_api_key_mode_can_use_secret_store_by_default() {
3593 let _lock = env_lock();
3594 let _env = EnvGuard::without_deepseek_runtime_overrides();
3595 let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key"));
3596 let secrets = Secrets::new(store.clone());
3597 let config = ConfigToml {
3598 provider: ProviderKind::Moonshot,
3599 ..ConfigToml::default()
3600 };
3601
3602 let resolved =
3603 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
3604
3605 assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key"));
3606 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
3607 assert_eq!(store.gets.lock().unwrap().as_slice(), ["moonshot"]);
3608 }
3609
3610 #[test]
3611 fn loopback_custom_deepseek_base_url_does_not_probe_secret_store_by_default() {
3612 let _lock = env_lock();
3613 let _env = EnvGuard::without_deepseek_runtime_overrides();
3614 let store = Arc::new(RecordingSecretsStore::with_value("stale-deepseek-key"));
3615 let secrets = Secrets::new(store.clone());
3616 let config = ConfigToml {
3617 base_url: Some("http://127.0.0.1:8000/v1".to_string()),
3618 ..ConfigToml::default()
3619 };
3620
3621 let resolved =
3622 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
3623
3624 assert_eq!(resolved.provider, ProviderKind::Deepseek);
3625 assert_eq!(resolved.base_url, "http://127.0.0.1:8000/v1");
3626 assert_eq!(resolved.api_key, None);
3627 assert!(
3628 store.gets.lock().unwrap().is_empty(),
3629 "loopback custom endpoints should not read macOS Keychain or any secret store"
3630 );
3631 }
3632
3633 #[test]
3634 fn ollama_provider_preserves_model_tags() {
3635 let _lock = env_lock();
3636 let _env = EnvGuard::without_deepseek_runtime_overrides();
3637 let cli = CliRuntimeOverrides {
3638 provider: Some(ProviderKind::Ollama),
3639 model: Some("deepseek-coder-v2:16b".to_string()),
3640 ..CliRuntimeOverrides::default()
3641 };
3642
3643 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
3644
3645 assert_eq!(resolved.provider, ProviderKind::Ollama);
3646 assert_eq!(resolved.model, "deepseek-coder-v2:16b");
3647 }
3648
3649 #[test]
3650 fn ollama_env_overrides_provider_base_url_and_optional_key() {
3651 let _lock = env_lock();
3652 let _env = EnvGuard::without_deepseek_runtime_overrides();
3653 unsafe {
3655 env::set_var("DEEPSEEK_PROVIDER", "ollama-local");
3656 env::set_var("OLLAMA_BASE_URL", "http://ollama.example/v1");
3657 env::set_var("OLLAMA_API_KEY", "ollama-env-key");
3658 }
3659
3660 let resolved =
3661 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
3662
3663 assert_eq!(resolved.provider, ProviderKind::Ollama);
3664 assert_eq!(resolved.base_url, "http://ollama.example/v1");
3665 assert_eq!(resolved.api_key.as_deref(), Some("ollama-env-key"));
3666 }
3667
3668 #[test]
3669 fn openrouter_env_api_key_falls_back_when_config_missing() {
3670 let _lock = env_lock();
3671 let _env = EnvGuard::without_deepseek_runtime_overrides();
3672 unsafe {
3674 env::set_var("DEEPSEEK_PROVIDER", "openrouter");
3675 env::set_var("OPENROUTER_API_KEY", "or-env-key");
3676 }
3677
3678 let resolved =
3679 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
3680
3681 assert_eq!(resolved.provider, ProviderKind::Openrouter);
3682 assert_eq!(resolved.api_key.as_deref(), Some("or-env-key"));
3683 assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL);
3684 }
3685
3686 #[test]
3687 fn xiaomi_mimo_env_overrides_provider_key_base_url_and_model() {
3688 let _lock = env_lock();
3689 let _env = EnvGuard::without_deepseek_runtime_overrides();
3690 unsafe {
3692 env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
3693 env::set_var("MIMO_API_KEY", "mimo-env-key");
3694 env::set_var("MIMO_BASE_URL", "https://mimo-gateway.example/v1");
3695 env::set_var("MIMO_MODEL", "mimo-v2.5");
3696 }
3697
3698 let resolved =
3699 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
3700
3701 assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
3702 assert_eq!(resolved.api_key.as_deref(), Some("mimo-env-key"));
3703 assert_eq!(resolved.base_url, "https://mimo-gateway.example/v1");
3704 assert_eq!(resolved.model, "mimo-v2.5");
3705 }
3706
3707 #[test]
3708 fn novita_env_api_key_falls_back_when_config_missing() {
3709 let _lock = env_lock();
3710 let _env = EnvGuard::without_deepseek_runtime_overrides();
3711 unsafe {
3713 env::set_var("DEEPSEEK_PROVIDER", "novita");
3714 env::set_var("NOVITA_API_KEY", "novita-env-key");
3715 }
3716
3717 let resolved =
3718 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
3719
3720 assert_eq!(resolved.provider, ProviderKind::Novita);
3721 assert_eq!(resolved.api_key.as_deref(), Some("novita-env-key"));
3722 assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL);
3723 }
3724
3725 #[test]
3726 fn fireworks_env_api_key_falls_back_when_config_missing() {
3727 let _lock = env_lock();
3728 let _env = EnvGuard::without_deepseek_runtime_overrides();
3729 unsafe {
3731 env::set_var("DEEPSEEK_PROVIDER", "fireworks");
3732 env::set_var("FIREWORKS_API_KEY", "fw-env-key");
3733 }
3734
3735 let resolved =
3736 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
3737
3738 assert_eq!(resolved.provider, ProviderKind::Fireworks);
3739 assert_eq!(resolved.api_key.as_deref(), Some("fw-env-key"));
3740 assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL);
3741 }
3742
3743 #[test]
3744 fn siliconflow_env_overrides_key_base_url_and_model() {
3745 let _lock = env_lock();
3746 let _env = EnvGuard::without_deepseek_runtime_overrides();
3747 unsafe {
3749 env::set_var("CODEWHALE_PROVIDER", "siliconflow");
3750 env::set_var("SILICONFLOW_API_KEY", "sf-env-key");
3751 env::set_var("SILICONFLOW_BASE_URL", "https://sf-mirror.example/v1");
3752 env::set_var("SILICONFLOW_MODEL", "deepseek-v4-flash");
3753 }
3754
3755 let resolved =
3756 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
3757
3758 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
3759 assert_eq!(resolved.api_key.as_deref(), Some("sf-env-key"));
3760 assert_eq!(resolved.base_url, "https://sf-mirror.example/v1");
3761 assert_eq!(resolved.model, "deepseek-v4-flash");
3762 }
3763
3764 #[test]
3765 fn siliconflow_cn_base_url_env_normalizes_model_aliases() {
3766 let _lock = env_lock();
3767 let _env = EnvGuard::without_deepseek_runtime_overrides();
3768 unsafe {
3770 env::set_var("CODEWHALE_PROVIDER", "siliconflow");
3771 env::set_var("SILICONFLOW_API_KEY", "sf-env-key");
3772 env::set_var("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1");
3773 }
3774
3775 for (alias, expected) in [
3776 ("deepseek-v4-flash", DEFAULT_SILICONFLOW_FLASH_MODEL),
3777 ("deepseek-reasoner", DEFAULT_SILICONFLOW_MODEL),
3778 ] {
3779 unsafe {
3781 env::set_var("SILICONFLOW_MODEL", alias);
3782 }
3783
3784 let resolved =
3785 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
3786
3787 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
3788 assert_eq!(resolved.base_url, "https://api.siliconflow.cn/v1");
3789 assert_eq!(resolved.model, expected);
3790 }
3791 }
3792
3793 #[test]
3794 fn wanjie_ark_env_api_key_and_base_url_fall_back_when_config_missing() {
3795 let _lock = env_lock();
3796 let _env = EnvGuard::without_deepseek_runtime_overrides();
3797 unsafe {
3799 env::set_var("DEEPSEEK_PROVIDER", "wanjie-ark");
3800 env::set_var("WANJIE_ARK_API_KEY", "wanjie-env-key");
3801 env::set_var("WANJIE_ARK_BASE_URL", "https://wanjie.example/api/v1");
3802 env::set_var("WANJIE_ARK_MODEL", "account-model-id");
3803 }
3804
3805 let resolved =
3806 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
3807
3808 assert_eq!(resolved.provider, ProviderKind::WanjieArk);
3809 assert_eq!(resolved.api_key.as_deref(), Some("wanjie-env-key"));
3810 assert_eq!(resolved.base_url, "https://wanjie.example/api/v1");
3811 assert_eq!(resolved.model, "account-model-id");
3812 }
3813
3814 #[test]
3815 fn openrouter_provider_normalizes_flash_aliases() {
3816 let _lock = env_lock();
3817 let _env = EnvGuard::without_deepseek_runtime_overrides();
3818 let cli = CliRuntimeOverrides {
3819 provider: Some(ProviderKind::Openrouter),
3820 model: Some("deepseek-v4-flash".to_string()),
3821 ..CliRuntimeOverrides::default()
3822 };
3823
3824 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
3825
3826 assert_eq!(resolved.provider, ProviderKind::Openrouter);
3827 assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL);
3828 }
3829
3830 #[test]
3831 fn openrouter_provider_normalizes_recent_large_model_aliases() {
3832 let _lock = env_lock();
3833 let _env = EnvGuard::without_deepseek_runtime_overrides();
3834
3835 for (alias, expected) in [
3836 (
3837 "trinity-large-thinking",
3838 OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL,
3839 ),
3840 ("qwen3.6-35b-a3b", OPENROUTER_QWEN_3_6_35B_A3B_MODEL),
3841 ("mimo-v2.5-pro", OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL),
3842 ("kimi-k2.6", OPENROUTER_KIMI_K2_6_MODEL),
3843 ("gemma-4-31b-it", OPENROUTER_GEMMA_4_31B_MODEL),
3844 ("glm-5.1", OPENROUTER_GLM_5_1_MODEL),
3845 ] {
3846 let cli = CliRuntimeOverrides {
3847 provider: Some(ProviderKind::Openrouter),
3848 model: Some(alias.to_string()),
3849 ..CliRuntimeOverrides::default()
3850 };
3851
3852 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
3853
3854 assert_eq!(resolved.provider, ProviderKind::Openrouter);
3855 assert_eq!(resolved.model, expected);
3856 }
3857 }
3858
3859 #[test]
3860 fn novita_provider_normalizes_flash_aliases() {
3861 let _lock = env_lock();
3862 let _env = EnvGuard::without_deepseek_runtime_overrides();
3863 let cli = CliRuntimeOverrides {
3864 provider: Some(ProviderKind::Novita),
3865 model: Some("deepseek-v4-flash".to_string()),
3866 ..CliRuntimeOverrides::default()
3867 };
3868
3869 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
3870
3871 assert_eq!(resolved.provider, ProviderKind::Novita);
3872 assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL);
3873 }
3874
3875 #[test]
3876 fn siliconflow_provider_normalizes_flash_aliases() {
3877 let _lock = env_lock();
3878 let _env = EnvGuard::without_deepseek_runtime_overrides();
3879 let cli = CliRuntimeOverrides {
3880 provider: Some(ProviderKind::Siliconflow),
3881 model: Some("deepseek-v4-flash".to_string()),
3882 ..CliRuntimeOverrides::default()
3883 };
3884
3885 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
3886
3887 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
3888 assert_eq!(resolved.model, DEFAULT_SILICONFLOW_FLASH_MODEL);
3889 }
3890
3891 #[test]
3892 fn siliconflow_provider_normalizes_reasoning_aliases_to_pro() {
3893 let _lock = env_lock();
3894 let _env = EnvGuard::without_deepseek_runtime_overrides();
3895
3896 for alias in ["deepseek-reasoner", "deepseek-r1"] {
3897 let cli = CliRuntimeOverrides {
3898 provider: Some(ProviderKind::Siliconflow),
3899 model: Some(alias.to_string()),
3900 ..CliRuntimeOverrides::default()
3901 };
3902
3903 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
3904
3905 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
3906 assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL);
3907 }
3908 }
3909
3910 #[test]
3911 fn siliconflow_provider_preserves_deepseek_v3_2_alias() {
3912 let _lock = env_lock();
3913 let _env = EnvGuard::without_deepseek_runtime_overrides();
3914 let cli = CliRuntimeOverrides {
3915 provider: Some(ProviderKind::Siliconflow),
3916 model: Some("deepseek-v3.2".to_string()),
3917 ..CliRuntimeOverrides::default()
3918 };
3919
3920 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
3921
3922 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
3923 assert_eq!(resolved.model, "deepseek-v3.2");
3924 }
3925
3926 #[test]
3927 fn sglang_provider_normalizes_flash_aliases() {
3928 let _lock = env_lock();
3929 let _env = EnvGuard::without_deepseek_runtime_overrides();
3930 let cli = CliRuntimeOverrides {
3931 provider: Some(ProviderKind::Sglang),
3932 model: Some("deepseek-v4-flash".to_string()),
3933 ..CliRuntimeOverrides::default()
3934 };
3935
3936 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
3937
3938 assert_eq!(resolved.provider, ProviderKind::Sglang);
3939 assert_eq!(resolved.model, DEFAULT_SGLANG_FLASH_MODEL);
3940 }
3941
3942 #[test]
3943 fn vllm_provider_normalizes_flash_aliases() {
3944 let _lock = env_lock();
3945 let _env = EnvGuard::without_deepseek_runtime_overrides();
3946 let cli = CliRuntimeOverrides {
3947 provider: Some(ProviderKind::Vllm),
3948 model: Some("deepseek-v4-flash".to_string()),
3949 ..CliRuntimeOverrides::default()
3950 };
3951
3952 let resolved = ConfigToml::default().resolve_runtime_options(&cli);
3953
3954 assert_eq!(resolved.provider, ProviderKind::Vllm);
3955 assert_eq!(resolved.model, DEFAULT_VLLM_FLASH_MODEL);
3956 }
3957
3958 #[test]
3959 fn openrouter_provider_specific_config_overrides_env() {
3960 let _lock = env_lock();
3961 let _env = EnvGuard::without_deepseek_runtime_overrides();
3962 let mut config = ConfigToml {
3963 provider: ProviderKind::Openrouter,
3964 ..ConfigToml::default()
3965 };
3966 config.providers.openrouter.api_key = Some("file-key".to_string());
3967 config.providers.openrouter.base_url = Some("https://or-mirror.example/v1".to_string());
3968
3969 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3970
3971 assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
3972 assert_eq!(resolved.base_url, "https://or-mirror.example/v1");
3973 }
3974
3975 #[test]
3976 fn openrouter_custom_base_url_preserves_provider_model() {
3977 let _lock = env_lock();
3978 let _env = EnvGuard::without_deepseek_runtime_overrides();
3979 let mut config = ConfigToml {
3980 provider: ProviderKind::Openrouter,
3981 ..ConfigToml::default()
3982 };
3983 config.providers.openrouter.base_url = Some("https://gateway.example.com/v1".to_string());
3984 config.providers.openrouter.model = Some("DeepSeek-V4-Pro".to_string());
3985
3986 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
3987
3988 assert_eq!(resolved.provider, ProviderKind::Openrouter);
3989 assert_eq!(resolved.base_url, "https://gateway.example.com/v1");
3990 assert_eq!(resolved.model, "DeepSeek-V4-Pro");
3991 }
3992
3993 #[test]
3994 fn fireworks_custom_base_url_preserves_provider_model() {
3995 let _lock = env_lock();
3996 let _env = EnvGuard::without_deepseek_runtime_overrides();
3997 let mut config = ConfigToml {
3998 provider: ProviderKind::Fireworks,
3999 ..ConfigToml::default()
4000 };
4001 config.providers.fireworks.base_url = Some("https://my-gateway.example/v1".to_string());
4002 config.providers.fireworks.model = Some("DeepSeek-V4-Pro".to_string());
4003
4004 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4005
4006 assert_eq!(resolved.provider, ProviderKind::Fireworks);
4007 assert_eq!(resolved.base_url, "https://my-gateway.example/v1");
4008 assert_eq!(resolved.model, "DeepSeek-V4-Pro");
4010 }
4011
4012 #[test]
4013 fn siliconflow_custom_base_url_preserves_provider_model() {
4014 let _lock = env_lock();
4015 let _env = EnvGuard::without_deepseek_runtime_overrides();
4016 let mut config = ConfigToml {
4017 provider: ProviderKind::Siliconflow,
4018 ..ConfigToml::default()
4019 };
4020 config.providers.siliconflow.base_url = Some("https://my-gateway.example/v1".to_string());
4021 config.providers.siliconflow.model = Some("DeepSeek-V4-Pro".to_string());
4022
4023 let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
4024
4025 assert_eq!(resolved.provider, ProviderKind::Siliconflow);
4026 assert_eq!(resolved.base_url, "https://my-gateway.example/v1");
4027 assert_eq!(resolved.model, "DeepSeek-V4-Pro");
4028 }
4029
4030 #[test]
4031 fn config_file_resolves_above_env_and_keyring() {
4032 use codewhale_secrets::KeyringStore;
4033 let _lock = env_lock();
4034 let _env = EnvGuard::without_deepseek_runtime_overrides();
4035 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
4037
4038 let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
4039 store.set("deepseek", "ring-key").unwrap();
4040 let secrets = Secrets::new(store);
4041
4042 let mut config = ConfigToml::default();
4043 config.providers.deepseek.api_key = Some("file-key".to_string());
4044
4045 let resolved =
4046 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
4047 assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
4048 assert_eq!(
4049 resolved.api_key_source,
4050 Some(RuntimeApiKeySource::ConfigFile)
4051 );
4052
4053 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
4055 }
4056
4057 #[test]
4058 fn env_resolves_when_config_file_and_keyring_empty() {
4059 let _lock = env_lock();
4060 let _env = EnvGuard::without_deepseek_runtime_overrides();
4061 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
4063
4064 let secrets = Secrets::new(std::sync::Arc::new(
4065 codewhale_secrets::InMemoryKeyringStore::new(),
4066 ));
4067 let config = ConfigToml::default();
4068
4069 let resolved =
4070 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
4071 assert_eq!(resolved.api_key.as_deref(), Some("env-key"));
4072 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
4073
4074 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
4076 }
4077
4078 #[test]
4079 fn config_file_resolves_when_keyring_and_env_empty() {
4080 let _lock = env_lock();
4081 let _env = EnvGuard::without_deepseek_runtime_overrides();
4082
4083 let secrets = Secrets::new(std::sync::Arc::new(
4084 codewhale_secrets::InMemoryKeyringStore::new(),
4085 ));
4086 let mut config = ConfigToml::default();
4087 config.providers.deepseek.api_key = Some("file-key".to_string());
4088
4089 let resolved =
4090 config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
4091 assert_eq!(resolved.api_key.as_deref(), Some("file-key"));
4092 assert_eq!(
4093 resolved.api_key_source,
4094 Some(RuntimeApiKeySource::ConfigFile)
4095 );
4096 }
4097
4098 #[test]
4099 fn keyring_resolves_when_config_file_empty_even_if_env_is_set() {
4100 use codewhale_secrets::KeyringStore;
4101 let _lock = env_lock();
4102 let _env = EnvGuard::without_deepseek_runtime_overrides();
4103 unsafe { std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key") };
4105
4106 let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
4107 store.set("deepseek", "ring-key").unwrap();
4108 let secrets = Secrets::new(store);
4109
4110 let resolved = ConfigToml::default()
4111 .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
4112 assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
4113 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
4114
4115 unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
4117 }
4118
4119 #[test]
4120 fn cli_flag_still_overrides_keyring() {
4121 use codewhale_secrets::KeyringStore;
4122 let _lock = env_lock();
4123 let _env = EnvGuard::without_deepseek_runtime_overrides();
4124
4125 let store = std::sync::Arc::new(codewhale_secrets::InMemoryKeyringStore::new());
4126 store.set("deepseek", "ring-key").unwrap();
4127 let secrets = Secrets::new(store);
4128
4129 let cli = CliRuntimeOverrides {
4130 api_key: Some("cli-key".to_string()),
4131 ..CliRuntimeOverrides::default()
4132 };
4133 let resolved = ConfigToml::default().resolve_runtime_options_with_secrets(&cli, &secrets);
4134 assert_eq!(resolved.api_key.as_deref(), Some("cli-key"));
4135 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Cli));
4136 }
4137}