anveesa 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.rs ADDED
@@ -0,0 +1,690 @@
1
+ use std::{
2
+ collections::BTreeMap,
3
+ env, fs,
4
+ path::{Path, PathBuf},
5
+ };
6
+
7
+ use anyhow::{Context, Result, bail};
8
+ use serde::{Deserialize, Serialize};
9
+
10
+ pub const SAMPLE_CONFIG: &str = r#"# Anveesa config.
11
+ # Path can be overridden with ANVEESA_CONFIG.
12
+
13
+ default_provider = "sumopod"
14
+
15
+ [providers.openai]
16
+ kind = "openai-compatible"
17
+ base_url = "https://api.openai.com/v1"
18
+ api_key_env = "OPENAI_API_KEY"
19
+
20
+ [providers.openrouter]
21
+ kind = "openai-compatible"
22
+ base_url = "https://openrouter.ai/api/v1"
23
+ api_key_env = "OPENROUTER_API_KEY"
24
+ # default_model = "openai/gpt-4.1-mini"
25
+
26
+ [providers.sumopod]
27
+ kind = "openai-compatible"
28
+ base_url = "https://ai.sumopod.com/v1"
29
+ api_key_env = "SUMOPOD_API_KEY"
30
+ # default_model = "your-sumopod-model"
31
+
32
+ [providers.glm]
33
+ kind = "openai-compatible"
34
+ base_url = "https://api.z.ai/api/paas/v4"
35
+ api_key_env = "ZAI_API_KEY"
36
+ # default_model = "glm-5.1"
37
+
38
+ [providers.glm-coding]
39
+ kind = "openai-compatible"
40
+ base_url = "https://api.z.ai/api/coding/paas/v4"
41
+ api_key_env = "ZAI_API_KEY"
42
+
43
+ [providers.deepseek]
44
+ kind = "openai-compatible"
45
+ base_url = "https://api.deepseek.com"
46
+ api_key_env = "DEEPSEEK_API_KEY"
47
+
48
+ [providers.gemini]
49
+ kind = "openai-compatible"
50
+ base_url = "https://generativelanguage.googleapis.com/v1beta/openai"
51
+ api_key_env = "GEMINI_API_KEY"
52
+
53
+ [providers.github-models]
54
+ kind = "openai-compatible"
55
+ base_url = "https://models.github.ai/inference"
56
+ api_key_env = "GITHUB_TOKEN"
57
+
58
+ [providers.github-models.headers]
59
+ Accept = "application/vnd.github+json"
60
+ X-GitHub-Api-Version = "2026-03-10"
61
+
62
+ [providers.groq]
63
+ kind = "openai-compatible"
64
+ base_url = "https://api.groq.com/openai/v1"
65
+ api_key_env = "GROQ_API_KEY"
66
+
67
+ [providers.mistral]
68
+ kind = "openai-compatible"
69
+ base_url = "https://api.mistral.ai/v1"
70
+ api_key_env = "MISTRAL_API_KEY"
71
+
72
+ [providers.xai]
73
+ kind = "openai-compatible"
74
+ base_url = "https://api.x.ai/v1"
75
+ api_key_env = "XAI_API_KEY"
76
+
77
+ [providers.together]
78
+ kind = "openai-compatible"
79
+ base_url = "https://api.together.ai/v1"
80
+ api_key_env = "TOGETHER_API_KEY"
81
+
82
+ [providers.fireworks]
83
+ kind = "openai-compatible"
84
+ base_url = "https://api.fireworks.ai/inference/v1"
85
+ api_key_env = "FIREWORKS_API_KEY"
86
+
87
+ [providers.cerebras]
88
+ kind = "openai-compatible"
89
+ base_url = "https://api.cerebras.ai/v1"
90
+ api_key_env = "CEREBRAS_API_KEY"
91
+
92
+ [providers.sambanova]
93
+ kind = "openai-compatible"
94
+ base_url = "https://api.sambanova.ai/v1"
95
+ api_key_env = "SAMBANOVA_API_KEY"
96
+
97
+ [providers.nvidia]
98
+ kind = "openai-compatible"
99
+ base_url = "https://integrate.api.nvidia.com/v1"
100
+ api_key_env = "NVIDIA_API_KEY"
101
+
102
+ [providers.dashscope]
103
+ kind = "openai-compatible"
104
+ base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
105
+ api_key_env = "DASHSCOPE_API_KEY"
106
+
107
+ [providers.qwen]
108
+ kind = "openai-compatible"
109
+ base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
110
+ api_key_env = "DASHSCOPE_API_KEY"
111
+
112
+ [providers.huggingface]
113
+ kind = "openai-compatible"
114
+ base_url = "https://router.huggingface.co/v1"
115
+ api_key_env = "HF_TOKEN"
116
+
117
+ [providers.vercel-ai-gateway]
118
+ kind = "openai-compatible"
119
+ base_url = "https://ai-gateway.vercel.sh/v1"
120
+ api_key_env = "AI_GATEWAY_API_KEY"
121
+
122
+ [providers.perplexity]
123
+ kind = "openai-compatible"
124
+ base_url = "https://api.perplexity.ai"
125
+ api_key_env = "PPLX_API_KEY"
126
+
127
+ [providers.ollama]
128
+ kind = "openai-compatible"
129
+ base_url = "http://localhost:11434/v1"
130
+
131
+ [providers.lm-studio]
132
+ kind = "openai-compatible"
133
+ base_url = "http://localhost:1234/v1"
134
+
135
+ [providers.vllm]
136
+ kind = "openai-compatible"
137
+ base_url = "http://localhost:8000/v1"
138
+
139
+ [providers.litellm]
140
+ kind = "openai-compatible"
141
+ base_url = "http://localhost:4000/v1"
142
+
143
+ [providers.localai]
144
+ kind = "openai-compatible"
145
+ base_url = "http://localhost:8080/v1"
146
+
147
+ [providers.claude-code]
148
+ kind = "command"
149
+ command = "claude"
150
+ args = ["-p", "{system_args}", "{model_args}", "{prompt}"]
151
+ model_args = ["--model", "{model}"]
152
+ system_args = ["--system-prompt", "{system}"]
153
+
154
+ [providers.codex]
155
+ kind = "command"
156
+ command = "codex"
157
+ args = ["exec", "{model_args}", "{prompt}"]
158
+ model_args = ["--model", "{model}"]
159
+
160
+ [providers.copilot]
161
+ kind = "command"
162
+ command = "copilot"
163
+ args = ["-p", "{prompt}", "{model_args}"]
164
+ model_args = ["--model", "{model}"]
165
+ "#;
166
+
167
+ #[derive(Debug, Clone, Serialize, Deserialize)]
168
+ pub struct AppConfig {
169
+ #[serde(default, skip_serializing_if = "Option::is_none")]
170
+ pub default_provider: Option<String>,
171
+
172
+ #[serde(default)]
173
+ pub providers: BTreeMap<String, ProviderConfig>,
174
+ }
175
+
176
+ impl AppConfig {
177
+ pub fn built_in() -> Self {
178
+ let mut providers = BTreeMap::new();
179
+ insert_openai_provider(
180
+ &mut providers,
181
+ "openai",
182
+ "https://api.openai.com/v1",
183
+ Some("OPENAI_API_KEY"),
184
+ );
185
+ insert_openai_provider(
186
+ &mut providers,
187
+ "openrouter",
188
+ "https://openrouter.ai/api/v1",
189
+ Some("OPENROUTER_API_KEY"),
190
+ );
191
+ insert_openai_provider(
192
+ &mut providers,
193
+ "sumopod",
194
+ "https://ai.sumopod.com/v1",
195
+ Some("SUMOPOD_API_KEY"),
196
+ );
197
+ insert_openai_provider(
198
+ &mut providers,
199
+ "glm",
200
+ "https://api.z.ai/api/paas/v4",
201
+ Some("ZAI_API_KEY"),
202
+ );
203
+ insert_openai_provider(
204
+ &mut providers,
205
+ "glm-coding",
206
+ "https://api.z.ai/api/coding/paas/v4",
207
+ Some("ZAI_API_KEY"),
208
+ );
209
+ insert_openai_provider(
210
+ &mut providers,
211
+ "deepseek",
212
+ "https://api.deepseek.com",
213
+ Some("DEEPSEEK_API_KEY"),
214
+ );
215
+ insert_openai_provider(
216
+ &mut providers,
217
+ "gemini",
218
+ "https://generativelanguage.googleapis.com/v1beta/openai",
219
+ Some("GEMINI_API_KEY"),
220
+ );
221
+ insert_openai_provider(
222
+ &mut providers,
223
+ "github-models",
224
+ "https://models.github.ai/inference",
225
+ Some("GITHUB_TOKEN"),
226
+ );
227
+ insert_headers(
228
+ &mut providers,
229
+ "github-models",
230
+ [
231
+ ("Accept", "application/vnd.github+json"),
232
+ ("X-GitHub-Api-Version", "2026-03-10"),
233
+ ],
234
+ );
235
+ insert_openai_provider(
236
+ &mut providers,
237
+ "groq",
238
+ "https://api.groq.com/openai/v1",
239
+ Some("GROQ_API_KEY"),
240
+ );
241
+ insert_openai_provider(
242
+ &mut providers,
243
+ "mistral",
244
+ "https://api.mistral.ai/v1",
245
+ Some("MISTRAL_API_KEY"),
246
+ );
247
+ insert_openai_provider(
248
+ &mut providers,
249
+ "xai",
250
+ "https://api.x.ai/v1",
251
+ Some("XAI_API_KEY"),
252
+ );
253
+ insert_openai_provider(
254
+ &mut providers,
255
+ "together",
256
+ "https://api.together.ai/v1",
257
+ Some("TOGETHER_API_KEY"),
258
+ );
259
+ insert_openai_provider(
260
+ &mut providers,
261
+ "fireworks",
262
+ "https://api.fireworks.ai/inference/v1",
263
+ Some("FIREWORKS_API_KEY"),
264
+ );
265
+ insert_openai_provider(
266
+ &mut providers,
267
+ "cerebras",
268
+ "https://api.cerebras.ai/v1",
269
+ Some("CEREBRAS_API_KEY"),
270
+ );
271
+ insert_openai_provider(
272
+ &mut providers,
273
+ "sambanova",
274
+ "https://api.sambanova.ai/v1",
275
+ Some("SAMBANOVA_API_KEY"),
276
+ );
277
+ insert_openai_provider(
278
+ &mut providers,
279
+ "nvidia",
280
+ "https://integrate.api.nvidia.com/v1",
281
+ Some("NVIDIA_API_KEY"),
282
+ );
283
+ insert_openai_provider(
284
+ &mut providers,
285
+ "dashscope",
286
+ "https://dashscope.aliyuncs.com/compatible-mode/v1",
287
+ Some("DASHSCOPE_API_KEY"),
288
+ );
289
+ insert_openai_provider(
290
+ &mut providers,
291
+ "qwen",
292
+ "https://dashscope.aliyuncs.com/compatible-mode/v1",
293
+ Some("DASHSCOPE_API_KEY"),
294
+ );
295
+ insert_openai_provider(
296
+ &mut providers,
297
+ "huggingface",
298
+ "https://router.huggingface.co/v1",
299
+ Some("HF_TOKEN"),
300
+ );
301
+ insert_openai_provider(
302
+ &mut providers,
303
+ "vercel-ai-gateway",
304
+ "https://ai-gateway.vercel.sh/v1",
305
+ Some("AI_GATEWAY_API_KEY"),
306
+ );
307
+ insert_openai_provider(
308
+ &mut providers,
309
+ "perplexity",
310
+ "https://api.perplexity.ai",
311
+ Some("PPLX_API_KEY"),
312
+ );
313
+ insert_openai_provider(&mut providers, "ollama", "http://localhost:11434/v1", None);
314
+ insert_openai_provider(
315
+ &mut providers,
316
+ "lm-studio",
317
+ "http://localhost:1234/v1",
318
+ None,
319
+ );
320
+ insert_openai_provider(&mut providers, "vllm", "http://localhost:8000/v1", None);
321
+ insert_openai_provider(&mut providers, "litellm", "http://localhost:4000/v1", None);
322
+ insert_openai_provider(&mut providers, "localai", "http://localhost:8080/v1", None);
323
+ insert_command_provider(
324
+ &mut providers,
325
+ "claude-code",
326
+ "claude",
327
+ ["-p", "{system_args}", "{model_args}", "{prompt}"],
328
+ ["--model", "{model}"],
329
+ ["--system-prompt", "{system}"],
330
+ );
331
+ insert_command_provider(
332
+ &mut providers,
333
+ "codex",
334
+ "codex",
335
+ ["exec", "{model_args}", "{prompt}"],
336
+ ["--model", "{model}"],
337
+ [],
338
+ );
339
+ insert_command_provider(
340
+ &mut providers,
341
+ "copilot",
342
+ "copilot",
343
+ ["-p", "{prompt}", "{model_args}"],
344
+ ["--model", "{model}"],
345
+ [],
346
+ );
347
+
348
+ Self {
349
+ default_provider: Some("sumopod".to_string()),
350
+ providers,
351
+ }
352
+ }
353
+
354
+ pub fn load() -> Result<Self> {
355
+ let path = config_path()?;
356
+ if !path.exists() {
357
+ return Ok(Self::built_in());
358
+ }
359
+
360
+ let raw = fs::read_to_string(&path)
361
+ .with_context(|| format!("failed to read config {}", path.display()))?;
362
+ let user_config: AppConfig = toml::from_str(&raw)
363
+ .with_context(|| format!("failed to parse config {}", path.display()))?;
364
+
365
+ let mut config = Self::built_in();
366
+ config.merge_user(user_config);
367
+ Ok(config)
368
+ }
369
+
370
+ fn merge_user(&mut self, user_config: AppConfig) {
371
+ if user_config.default_provider.is_some() {
372
+ self.default_provider = user_config.default_provider;
373
+ }
374
+ self.providers.extend(user_config.providers);
375
+ }
376
+
377
+ pub fn provider_name<'a>(&'a self, requested: Option<&'a str>) -> Result<&'a str> {
378
+ if let Some(provider) = requested {
379
+ return Ok(provider);
380
+ }
381
+
382
+ self.default_provider
383
+ .as_deref()
384
+ .context("no provider passed and no default_provider configured")
385
+ }
386
+ }
387
+
388
+ #[derive(Debug, Clone, Serialize, Deserialize)]
389
+ #[serde(tag = "kind")]
390
+ pub enum ProviderConfig {
391
+ #[serde(rename = "openai-compatible")]
392
+ OpenAiCompatible(OpenAiCompatibleProviderConfig),
393
+ #[serde(rename = "command")]
394
+ Command(CommandProviderConfig),
395
+ }
396
+
397
+ impl ProviderConfig {
398
+ pub fn kind(&self) -> &'static str {
399
+ match self {
400
+ Self::OpenAiCompatible(_) => "openai-compatible",
401
+ Self::Command(_) => "command",
402
+ }
403
+ }
404
+
405
+ pub fn default_model(&self) -> Option<&str> {
406
+ match self {
407
+ Self::OpenAiCompatible(config) => config.default_model.as_deref(),
408
+ Self::Command(config) => config.default_model.as_deref(),
409
+ }
410
+ }
411
+
412
+ pub fn set_default_model(&mut self, model: String) {
413
+ match self {
414
+ Self::OpenAiCompatible(config) => config.default_model = Some(model),
415
+ Self::Command(config) => config.default_model = Some(model),
416
+ }
417
+ }
418
+ }
419
+
420
+ #[derive(Debug, Clone, Serialize, Deserialize)]
421
+ pub struct OpenAiCompatibleProviderConfig {
422
+ pub base_url: String,
423
+
424
+ #[serde(default, skip_serializing_if = "Option::is_none")]
425
+ pub api_key_env: Option<String>,
426
+
427
+ #[serde(default, skip_serializing_if = "Option::is_none")]
428
+ pub default_model: Option<String>,
429
+
430
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
431
+ pub headers: BTreeMap<String, String>,
432
+
433
+ /// Enable prompt caching. When true, adds `cache_control` markers to the
434
+ /// last static system message and the last history message so the provider
435
+ /// can cache those prefixes across turns.
436
+ /// For Anthropic models this also sends the `anthropic-beta: prompt-caching-2024-07-31` header.
437
+ #[serde(default, skip_serializing_if = "Option::is_none")]
438
+ pub prompt_cache: Option<bool>,
439
+ }
440
+
441
+ #[derive(Debug, Clone, Serialize, Deserialize)]
442
+ pub struct CommandProviderConfig {
443
+ pub command: String,
444
+
445
+ #[serde(default, skip_serializing_if = "Option::is_none")]
446
+ pub default_model: Option<String>,
447
+
448
+ #[serde(default)]
449
+ pub args: Vec<String>,
450
+
451
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
452
+ pub model_args: Vec<String>,
453
+
454
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
455
+ pub system_args: Vec<String>,
456
+
457
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
458
+ pub env: BTreeMap<String, String>,
459
+ }
460
+
461
+ fn insert_openai_provider(
462
+ providers: &mut BTreeMap<String, ProviderConfig>,
463
+ name: &str,
464
+ base_url: &str,
465
+ api_key_env: Option<&str>,
466
+ ) {
467
+ providers.insert(
468
+ name.to_string(),
469
+ ProviderConfig::OpenAiCompatible(OpenAiCompatibleProviderConfig {
470
+ base_url: base_url.to_string(),
471
+ api_key_env: api_key_env.map(str::to_string),
472
+ default_model: None,
473
+ headers: BTreeMap::new(),
474
+ prompt_cache: None,
475
+ }),
476
+ );
477
+ }
478
+
479
+ fn insert_headers<const N: usize>(
480
+ providers: &mut BTreeMap<String, ProviderConfig>,
481
+ name: &str,
482
+ headers: [(&str, &str); N],
483
+ ) {
484
+ let Some(ProviderConfig::OpenAiCompatible(provider)) = providers.get_mut(name) else {
485
+ return;
486
+ };
487
+
488
+ provider.headers.extend(
489
+ headers
490
+ .into_iter()
491
+ .map(|(name, value)| (name.to_string(), value.to_string())),
492
+ );
493
+ }
494
+
495
+ fn insert_command_provider<const A: usize, const M: usize, const S: usize>(
496
+ providers: &mut BTreeMap<String, ProviderConfig>,
497
+ name: &str,
498
+ command: &str,
499
+ args: [&str; A],
500
+ model_args: [&str; M],
501
+ system_args: [&str; S],
502
+ ) {
503
+ providers.insert(
504
+ name.to_string(),
505
+ ProviderConfig::Command(CommandProviderConfig {
506
+ command: command.to_string(),
507
+ default_model: None,
508
+ args: args.into_iter().map(str::to_string).collect(),
509
+ model_args: model_args.into_iter().map(str::to_string).collect(),
510
+ system_args: system_args.into_iter().map(str::to_string).collect(),
511
+ env: BTreeMap::new(),
512
+ }),
513
+ );
514
+ }
515
+
516
+ pub fn config_path() -> Result<PathBuf> {
517
+ if let Some(path) = env::var_os("ANVEESA_CONFIG") {
518
+ return Ok(PathBuf::from(path));
519
+ }
520
+
521
+ if let Some(path) = env::var_os("XDG_CONFIG_HOME") {
522
+ return Ok(PathBuf::from(path).join("anveesa").join("config.toml"));
523
+ }
524
+
525
+ if let Some(home) = env::var_os("HOME") {
526
+ return Ok(PathBuf::from(home)
527
+ .join(".config")
528
+ .join("anveesa")
529
+ .join("config.toml"));
530
+ }
531
+
532
+ bail!("cannot resolve config path; set ANVEESA_CONFIG")
533
+ }
534
+
535
+ pub fn init_config(force: bool) -> Result<PathBuf> {
536
+ let path = config_path()?;
537
+ if path.exists() && !force {
538
+ bail!(
539
+ "config already exists at {}; pass --force to overwrite",
540
+ path.display()
541
+ );
542
+ }
543
+
544
+ if let Some(parent) = path.parent() {
545
+ fs::create_dir_all(parent)
546
+ .with_context(|| format!("failed to create config directory {}", parent.display()))?;
547
+ }
548
+
549
+ fs::write(&path, SAMPLE_CONFIG)
550
+ .with_context(|| format!("failed to write config {}", path.display()))?;
551
+ Ok(path)
552
+ }
553
+
554
+ pub fn set_default_model(provider: Option<&str>, model: String) -> Result<(PathBuf, String)> {
555
+ let path = config_path()?;
556
+ let mut user_config = load_user_config_for_write(&path)?;
557
+ let effective_config = effective_config_from_user(user_config.clone());
558
+ let provider_name = effective_config.provider_name(provider)?.to_string();
559
+
560
+ if !user_config.providers.contains_key(&provider_name) {
561
+ let provider_config = effective_config
562
+ .providers
563
+ .get(&provider_name)
564
+ .with_context(|| format!("unknown provider '{provider_name}'"))?
565
+ .clone();
566
+ user_config
567
+ .providers
568
+ .insert(provider_name.clone(), provider_config);
569
+ }
570
+
571
+ let provider_config = user_config
572
+ .providers
573
+ .get_mut(&provider_name)
574
+ .with_context(|| format!("unknown provider '{provider_name}'"))?;
575
+ provider_config.set_default_model(model);
576
+
577
+ if user_config.default_provider.is_none() {
578
+ user_config.default_provider = Some(provider_name.clone());
579
+ }
580
+
581
+ write_user_config(&path, &user_config)?;
582
+ Ok((path, provider_name))
583
+ }
584
+
585
+ pub fn set_default_provider(provider: String) -> Result<PathBuf> {
586
+ let path = config_path()?;
587
+ let mut user_config = load_user_config_for_write(&path)?;
588
+ let effective_config = effective_config_from_user(user_config.clone());
589
+
590
+ if !effective_config.providers.contains_key(&provider) {
591
+ bail!("unknown provider '{provider}'");
592
+ }
593
+
594
+ user_config.default_provider = Some(provider);
595
+ write_user_config(&path, &user_config)?;
596
+ Ok(path)
597
+ }
598
+
599
+ pub fn print_path(path: &Path) -> String {
600
+ path.display().to_string()
601
+ }
602
+
603
+ fn load_user_config_for_write(path: &Path) -> Result<AppConfig> {
604
+ if !path.exists() {
605
+ return Ok(AppConfig {
606
+ default_provider: Some("sumopod".to_string()),
607
+ providers: BTreeMap::new(),
608
+ });
609
+ }
610
+
611
+ let raw = fs::read_to_string(path)
612
+ .with_context(|| format!("failed to read config {}", path.display()))?;
613
+ toml::from_str(&raw).with_context(|| format!("failed to parse config {}", path.display()))
614
+ }
615
+
616
+ fn effective_config_from_user(user_config: AppConfig) -> AppConfig {
617
+ let mut config = AppConfig::built_in();
618
+ config.merge_user(user_config);
619
+ config
620
+ }
621
+
622
+ fn write_user_config(path: &Path, config: &AppConfig) -> Result<()> {
623
+ if let Some(parent) = path.parent() {
624
+ fs::create_dir_all(parent)
625
+ .with_context(|| format!("failed to create config directory {}", parent.display()))?;
626
+ }
627
+
628
+ let raw = toml::to_string_pretty(config).context("failed to serialize config")?;
629
+ fs::write(path, raw).with_context(|| format!("failed to write config {}", path.display()))
630
+ }
631
+
632
+ #[cfg(test)]
633
+ mod tests {
634
+ use super::*;
635
+
636
+ #[test]
637
+ fn built_in_has_core_providers() {
638
+ let config = AppConfig::built_in();
639
+ assert_eq!(config.default_provider.as_deref(), Some("sumopod"));
640
+ assert!(config.providers.contains_key("openai"));
641
+ assert!(config.providers.contains_key("codex"));
642
+ assert!(config.providers.contains_key("claude-code"));
643
+ }
644
+
645
+ #[test]
646
+ fn merge_overrides_default_and_adds_providers() {
647
+ let mut config = AppConfig::built_in();
648
+ let user: AppConfig = toml::from_str(
649
+ r#"
650
+ default_provider = "myllm"
651
+
652
+ [providers.myllm]
653
+ kind = "openai-compatible"
654
+ base_url = "http://localhost:9000/v1"
655
+ default_model = "local-7b"
656
+ "#,
657
+ )
658
+ .unwrap();
659
+
660
+ config.merge_user(user);
661
+ assert_eq!(config.default_provider.as_deref(), Some("myllm"));
662
+ let provider = config.providers.get("myllm").expect("custom provider kept");
663
+ assert_eq!(provider.default_model(), Some("local-7b"));
664
+ // built-ins remain available after merge
665
+ assert!(config.providers.contains_key("openai"));
666
+ }
667
+
668
+ #[test]
669
+ fn merge_keeps_existing_default_when_user_omits_it() {
670
+ let mut config = AppConfig::built_in();
671
+ let user: AppConfig = toml::from_str(
672
+ r#"
673
+ [providers.extra]
674
+ kind = "openai-compatible"
675
+ base_url = "http://localhost:1/v1"
676
+ "#,
677
+ )
678
+ .unwrap();
679
+
680
+ config.merge_user(user);
681
+ assert_eq!(config.default_provider.as_deref(), Some("sumopod"));
682
+ assert!(config.providers.contains_key("extra"));
683
+ }
684
+
685
+ #[test]
686
+ fn sample_config_parses() {
687
+ let parsed: Result<AppConfig, _> = toml::from_str(SAMPLE_CONFIG);
688
+ assert!(parsed.is_ok(), "sample config should parse: {parsed:?}");
689
+ }
690
+ }