anveesa 0.7.0 → 0.7.2

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