1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
7pub struct ModelPricingSpec {
8 pub input_per_million: f64,
9 pub output_per_million: f64,
10 pub cache_hit_input_per_million: Option<f64>,
12}
13
14#[derive(Debug, Clone, Deserialize, Serialize)]
16pub struct ModelSpec {
17 pub name: String,
18 pub context_window: usize,
20 pub pricing: Option<ModelPricingSpec>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub max_tokens: Option<u32>,
29}
30
31#[derive(Debug, Clone, Deserialize, Serialize)]
32pub struct ProviderPreset {
33 pub id: String,
34 #[serde(default = "default_name")]
37 pub name: String,
38 pub provider_type: String,
39 pub api_base: String,
40 pub anthropic_api_base: Option<String>,
45 pub default_model: String,
46 pub models: Vec<ModelSpec>,
47 #[serde(default)]
50 pub mainland_accessible: bool,
51 #[serde(default)]
52 pub key_env: String,
53 #[serde(default)]
54 pub key_url: String,
55}
56
57fn default_name() -> String {
59 String::new()
60}
61
62#[derive(Deserialize)]
63struct PresetsFile {
64 providers: Vec<ProviderPreset>,
65}
66
67static PRESETS_TOML: &str = include_str!("../providers.toml");
68
69pub fn all_presets() -> &'static [ProviderPreset] {
70 use std::sync::OnceLock;
79 static CACHE: OnceLock<Vec<ProviderPreset>> = OnceLock::new();
80 CACHE.get_or_init(|| {
81 #[allow(
82 clippy::expect_used,
83 reason = "TOML is bundled at compile time and always valid"
84 )]
85 toml::from_str::<PresetsFile>(PRESETS_TOML)
86 .expect("providers.toml is bundled at compile time and must be valid")
87 .providers
88 })
89}
90
91pub fn find_preset(id: &str) -> Option<&'static ProviderPreset> {
92 all_presets().iter().find(|p| p.id == id)
93}
94
95pub fn find_preset_by_api_base(url: &str) -> Option<&'static ProviderPreset> {
100 all_presets().iter().find(|p| p.api_base == url)
101}
102
103pub fn bundled_presets() -> &'static [ProviderPreset] {
110 all_presets()
111}
112
113fn providers_d_dir() -> Option<std::path::PathBuf> {
117 Some(crate::paths::user_data_dir().join("providers.d"))
118}
119
120pub fn additional_presets() -> Vec<ProviderPreset> {
135 let dir = match providers_d_dir() {
136 Some(d) => d,
137 None => return Vec::new(),
138 };
139 if !dir.exists() {
140 return Vec::new();
141 }
142 let entries = match std::fs::read_dir(&dir) {
143 Ok(e) => e,
144 Err(e) => {
145 tracing::warn!("providers.d: read_dir failed: {e}");
146 return Vec::new();
147 }
148 };
149 let mut paths: Vec<std::path::PathBuf> = entries
150 .filter_map(|e| e.ok())
151 .map(|e| e.path())
152 .filter(|p| p.is_file() && p.extension().and_then(|s| s.to_str()) == Some("toml"))
153 .collect();
154 paths.sort();
157
158 let mut out: Vec<ProviderPreset> = Vec::new();
159 for path in paths {
160 let content = match std::fs::read_to_string(&path) {
161 Ok(c) => c,
162 Err(e) => {
163 tracing::warn!("providers.d: failed to read {}: {e}", path.display());
164 continue;
165 }
166 };
167 match toml::from_str::<PresetsFile>(&content) {
168 Ok(file) => {
169 for mut p in file.providers {
170 if p.name.is_empty() {
173 p.name = p.id.clone();
174 }
175 out.push(p);
176 }
177 }
178 Err(e) => {
179 tracing::warn!("providers.d: failed to parse {}: {e}", path.display());
180 }
181 }
182 }
183 out
184}
185
186pub fn all_presets_dynamic() -> Vec<ProviderPreset> {
191 let mut all: Vec<ProviderPreset> = bundled_presets().to_vec();
192 all.extend(additional_presets());
193 all
194}
195
196pub fn find_preset_extended(id: &str) -> Option<ProviderPreset> {
201 all_presets_dynamic().into_iter().find(|p| p.id == id)
202}
203
204fn merge_models(existing: &mut Vec<ModelSpec>, override_models: Vec<ModelSpec>) {
224 for om in override_models {
225 if let Some(slot) = existing.iter_mut().find(|m| m.name == om.name) {
226 *slot = om;
227 } else {
228 existing.push(om);
229 }
230 }
231}
232
233fn merge_preset_into(existing: &mut ProviderPreset, override_p: ProviderPreset) {
239 if !override_p.api_base.is_empty() {
240 existing.api_base = override_p.api_base;
241 }
242 if !override_p.provider_type.is_empty() {
243 existing.provider_type = override_p.provider_type;
244 }
245 if !override_p.default_model.is_empty() {
246 existing.default_model = override_p.default_model;
247 }
248 if override_p.anthropic_api_base.is_some() {
249 existing.anthropic_api_base = override_p.anthropic_api_base;
250 }
251 if !override_p.key_env.is_empty() {
252 existing.key_env = override_p.key_env;
253 }
254 if !override_p.key_url.is_empty() {
255 existing.key_url = override_p.key_url;
256 }
257 merge_models(&mut existing.models, override_p.models);
258}
259
260fn compute_effective_presets() -> Vec<ProviderPreset> {
262 let bundled = bundled_presets().to_vec();
263 let with_cache = match crate::providers_cache::load_cache() {
264 Some(cache) => crate::providers_cache::merge_over_bundled(&bundled, &cache.providers),
265 None => bundled,
266 };
267 let mut result = with_cache;
273 for p in additional_presets() {
274 match result.iter_mut().find(|r| r.id == p.id) {
275 Some(existing) => merge_preset_into(existing, p),
276 None => result.push(p),
277 }
278 }
279 result
280}
281
282pub fn all_presets_effective() -> Vec<ProviderPreset> {
294 compute_effective_presets()
295}
296
297pub fn find_preset_effective(id: &str) -> Option<ProviderPreset> {
301 all_presets_effective().into_iter().find(|p| p.id == id)
302}
303
304pub fn find_model_pricing_effective(model: &str) -> Option<ModelPricingSpec> {
309 for preset in all_presets_effective() {
310 for spec in &preset.models {
311 if spec.name == model {
312 if let Some(pricing) = spec.pricing {
313 return Some(pricing);
314 }
315 }
316 }
317 }
318 None
319}
320
321#[derive(Debug, Clone)]
325pub struct WrittenPreset {
326 pub path: std::path::PathBuf,
327 pub id: String,
328}
329
330#[derive(serde::Serialize)]
331struct PresetsFileSer<'a> {
332 providers: &'a [ProviderPreset],
333}
334
335pub fn write_user_preset(preset: &ProviderPreset) -> crate::error::Result<WrittenPreset> {
357 use crate::error::Error;
358
359 let dir = providers_d_dir().ok_or(Error::Config {
360 message: "cannot resolve user data directory for providers.d".into(),
361 })?;
362 std::fs::create_dir_all(&dir).map_err(Error::Io)?;
363 let path = dir.join(format!("{}.toml", preset.id));
364 let body = toml::to_string_pretty(&PresetsFileSer {
365 providers: std::slice::from_ref(preset),
366 })
367 .map_err(|e| Error::Config {
368 message: format!("serialize preset {}: {e}", preset.id),
369 })?;
370 std::fs::write(&path, body).map_err(Error::Io)?;
371 Ok(WrittenPreset {
372 path,
373 id: preset.id.clone(),
374 })
375}
376
377pub fn find_model_pricing(model: &str) -> Option<&'static ModelPricingSpec> {
380 for preset in all_presets() {
381 for spec in &preset.models {
382 if spec.name == model {
383 return spec.pricing.as_ref();
384 }
385 }
386 }
387 None
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
395 fn all_presets_non_empty() {
396 assert!(all_presets().len() > 10);
397 }
398
399 #[test]
400 fn find_preset_anthropic() {
401 let preset = find_preset("anthropic").unwrap();
402 assert_eq!(preset.provider_type, "anthropic");
403 }
404
405 #[test]
406 fn find_preset_unknown_returns_none() {
407 assert!(find_preset("bogus").is_none());
408 }
409
410 #[test]
411 fn all_presets_have_valid_provider_type() {
412 for p in all_presets() {
413 assert!(
414 p.provider_type == "openai" || p.provider_type == "anthropic",
415 "preset {} has invalid provider_type: {}",
416 p.id,
417 p.provider_type
418 );
419 }
420 }
421
422 #[test]
423 fn default_preset_is_anthropic() {
424 assert_eq!(all_presets()[0].id, "anthropic");
425 }
426
427 #[test]
428 fn find_preset_deepseek_api_base() {
429 let preset = find_preset("deepseek").unwrap();
430 assert_eq!(preset.api_base, "https://api.deepseek.com/v1");
431 }
432
433 #[test]
434 fn find_preset_minimax_cn_uses_domestic_endpoint() {
435 let preset = find_preset("minimax-cn").expect("minimax-cn preset must exist");
440 assert_eq!(preset.api_base, "https://api.minimaxi.com/v1");
441 assert_eq!(
442 preset.anthropic_api_base.as_deref(),
443 Some("https://api.minimaxi.com/anthropic"),
444 );
445 assert_eq!(preset.key_env, "MINIMAX_API_KEY");
446 assert!(preset.mainland_accessible);
447 }
448
449 #[test]
450 fn find_preset_by_api_base_known() {
451 let preset =
452 find_preset_by_api_base("https://api.deepseek.com/v1").expect("deepseek preset");
453 assert_eq!(preset.id, "deepseek");
454 }
455
456 #[test]
457 fn find_preset_by_api_base_unknown() {
458 assert!(find_preset_by_api_base("https://example.com/v1").is_none());
459 }
460
461 fn write_user_override(
467 dir: &std::path::Path,
468 name: &str,
469 body: &str,
470 ) -> Result<(), Box<dyn std::error::Error>> {
471 std::fs::create_dir_all(dir)?;
472 std::fs::write(dir.join(name), body)?;
473 Ok(())
474 }
475
476 #[test]
477 fn additional_presets_returns_empty_when_dir_absent() -> Result<(), Box<dyn std::error::Error>>
478 {
479 let tmp = tempfile::tempdir()?;
482 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path().join(".recursive"));
491 assert!(
492 additional_presets().is_empty(),
493 "no providers.d/ under temp home, expected empty list"
494 );
495 Ok(())
496 }
497
498 #[test]
499 fn additional_presets_loads_valid_file() -> Result<(), Box<dyn std::error::Error>> {
500 let tmp = tempfile::tempdir()?;
501 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path().join(".recursive"));
504
505 let dir = tmp.path().join(".recursive").join("providers.d");
506 write_user_override(
507 &dir,
508 "test-vendor.toml",
509 r#"
510[[providers]]
511id = "test-vendor"
512name = "Test Vendor"
513provider_type = "openai"
514api_base = "https://test.example.com/v1"
515default_model = "t1"
516models = [
517 {name = "t1", context_window = 32000},
518]
519mainland_accessible = false
520key_env = "TEST_API_KEY"
521key_url = "https://x"
522"#,
523 )?;
524
525 let loaded = additional_presets();
526 assert_eq!(loaded.len(), 1, "expected exactly one override");
527 let p = &loaded[0];
528 assert_eq!(p.id, "test-vendor");
529 assert_eq!(p.provider_type, "openai");
530 assert_eq!(p.api_base, "https://test.example.com/v1");
531 assert_eq!(p.default_model, "t1");
532 assert_eq!(p.key_env, "TEST_API_KEY");
533 assert_eq!(p.key_url, "https://x");
534 assert_eq!(p.models.len(), 1);
535 assert_eq!(p.models[0].name, "t1");
536 Ok(())
537 }
538
539 #[test]
540 fn effective_presets_merge_user_models_into_existing_provider(
541 ) -> Result<(), Box<dyn std::error::Error>> {
542 let tmp = tempfile::tempdir()?;
547 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path().join(".recursive"));
548
549 let dir = tmp.path().join(".recursive").join("providers.d");
550 write_user_override(
551 &dir,
552 "zhipu.toml",
553 r#"
554[[providers]]
555id = "zhipu"
556name = "智谱 AI (GLM)"
557provider_type = "openai"
558api_base = "https://open.bigmodel.cn/api/paas/v4"
559default_model = "glm-4.7"
560models = [
561 { name = "glm-user-only", context_window = 12345, pricing = { input_per_million = 0.5, output_per_million = 1.0 } },
562]
563"#,
564 )?;
565
566 let zhipu = all_presets_effective()
567 .into_iter()
568 .find(|p| p.id == "zhipu")
569 .expect("zhipu must exist in effective catalog");
570
571 assert!(
573 zhipu.models.iter().any(|m| m.name == "glm-5.2"),
574 "bundled glm-5.2 should survive the merge"
575 );
576 assert!(
577 zhipu.models.iter().any(|m| m.name == "glm-4.7"),
578 "bundled glm-4.7 should survive the merge"
579 );
580 let user_only = zhipu
582 .models
583 .iter()
584 .find(|m| m.name == "glm-user-only")
585 .expect("user-only model should be merged in from providers.d");
586 assert_eq!(user_only.context_window, 12345);
587 Ok(())
588 }
589
590 #[test]
591 fn effective_presets_user_model_overrides_existing_pricing(
592 ) -> Result<(), Box<dyn std::error::Error>> {
593 let tmp = tempfile::tempdir()?;
596 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path().join(".recursive"));
597
598 let dir = tmp.path().join(".recursive").join("providers.d");
599 write_user_override(
600 &dir,
601 "zhipu.toml",
602 r#"
603[[providers]]
604id = "zhipu"
605provider_type = "openai"
606api_base = "https://open.bigmodel.cn/api/paas/v4"
607default_model = "glm-4.7"
608models = [
609 { name = "glm-4.7", context_window = 999999, pricing = { input_per_million = 0.01, output_per_million = 0.02 } },
610]
611"#,
612 )?;
613
614 let zhipu = all_presets_effective()
615 .into_iter()
616 .find(|p| p.id == "zhipu")
617 .expect("zhipu must exist");
618 let glm47 = zhipu
619 .models
620 .iter()
621 .find(|m| m.name == "glm-4.7")
622 .expect("glm-4.7 must still exist");
623 assert_eq!(
624 glm47.context_window, 999999,
625 "user override must win on same name"
626 );
627 assert!(zhipu.models.iter().any(|m| m.name == "glm-5.2"));
629 Ok(())
630 }
631
632 #[test]
633 fn additional_presets_skips_invalid_file() -> Result<(), Box<dyn std::error::Error>> {
634 let tmp = tempfile::tempdir()?;
635 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path().join(".recursive"));
638
639 let dir = tmp.path().join(".recursive").join("providers.d");
640 write_user_override(&dir, "bad.toml", "this is [[[not valid toml")?;
642
643 let loaded = additional_presets();
647 assert!(
648 loaded.is_empty(),
649 "malformed override file must be skipped silently, got {loaded:?}"
650 );
651 Ok(())
652 }
653
654 #[test]
655 fn bundled_presets_is_same_as_all_presets() {
656 let bundled = bundled_presets();
658 let all = all_presets();
659 assert_eq!(bundled.len(), all.len());
660 }
661
662 #[test]
663 fn all_presets_dynamic_includes_bundled() {
664 let dynamic = all_presets_dynamic();
666 assert!(
667 dynamic.iter().any(|p| p.id == "anthropic"),
668 "anthropic must be in dynamic list, got: {:?}",
669 dynamic.iter().map(|p| &p.id).collect::<Vec<_>>()
670 );
671 }
672
673 #[test]
674 fn find_preset_effective_finds_bundled_anthropic() {
675 let p = find_preset_effective("anthropic").expect("anthropic must be found");
677 assert_eq!(p.id, "anthropic");
678 }
679
680 #[test]
681 fn find_preset_effective_returns_none_for_unknown() {
682 assert!(find_preset_effective("completely-unknown-preset-xyz").is_none());
684 }
685
686 #[test]
687 fn find_model_pricing_returns_none_for_unknown_model() {
688 assert!(find_model_pricing("no-such-model-xyz-999").is_none());
690 }
691
692 #[test]
693 fn find_model_pricing_effective_returns_none_for_unknown_model() {
694 assert!(find_model_pricing_effective("no-such-model-xyz-999").is_none());
696 }
697
698 #[test]
699 fn find_preset_extended_finds_user_override() -> Result<(), Box<dyn std::error::Error>> {
700 let tmp = tempfile::tempdir()?;
701 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path().join(".recursive"));
704
705 let dir = tmp.path().join(".recursive").join("providers.d");
706 write_user_override(
707 &dir,
708 "custom.toml",
709 r#"
710[[providers]]
711id = "custom-runtime"
712name = "Custom Runtime"
713provider_type = "openai"
714api_base = "https://custom.example.com/v1"
715default_model = "c1"
716models = [
717 {name = "c1", context_window = 16000},
718]
719mainland_accessible = false
720key_env = "CUSTOM_API_KEY"
721key_url = "https://custom.example.com/keys"
722"#,
723 )?;
724
725 let ext = find_preset_extended("custom-runtime")
727 .ok_or("extended lookup should find user override")?;
728 assert_eq!(ext.id, "custom-runtime");
729 assert_eq!(ext.api_base, "https://custom.example.com/v1");
730
731 assert!(
735 find_preset("custom-runtime").is_none(),
736 "find_preset must remain strict: bundled-catalog only"
737 );
738 assert!(find_preset("anthropic").is_some());
741 Ok(())
742 }
743
744 fn sample_test_preset(id: &str) -> ProviderPreset {
751 ProviderPreset {
752 id: id.to_string(),
753 name: "Test Write Helper".to_string(),
754 provider_type: "openai".to_string(),
755 api_base: "https://write-helper.example.com/v1".to_string(),
756 anthropic_api_base: None,
757 default_model: "wh-1".to_string(),
758 models: vec![ModelSpec {
759 name: "wh-1".to_string(),
760 context_window: 8192,
761 max_tokens: None,
762 pricing: Some(ModelPricingSpec {
763 input_per_million: 0.10,
764 output_per_million: 0.20,
765 cache_hit_input_per_million: None,
766 }),
767 }],
768 mainland_accessible: false,
769 key_env: "WH_KEY".to_string(),
770 key_url: "https://write-helper.example.com/keys".to_string(),
771 }
772 }
773
774 #[test]
775 fn write_user_preset_round_trips_through_additional_presets(
776 ) -> Result<(), Box<dyn std::error::Error>> {
777 let tmp = tempfile::tempdir()?;
782 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path().join(".recursive"));
783
784 let written = write_user_preset(&sample_test_preset("write-rt"))?;
785 assert_eq!(written.id, "write-rt");
786 assert!(
787 written.path.ends_with("providers.d/write-rt.toml"),
788 "file must land at <user_data_dir>/providers.d/<id>.toml, got {}",
789 written.path.display()
790 );
791
792 let loaded = additional_presets();
793 assert_eq!(loaded.len(), 1, "exactly the preset we wrote should load");
794 assert_eq!(loaded[0].id, "write-rt");
795 assert_eq!(loaded[0].api_base, "https://write-helper.example.com/v1");
796 assert_eq!(loaded[0].models.len(), 1);
797 assert_eq!(loaded[0].models[0].name, "wh-1");
798 Ok(())
799 }
800
801 #[test]
802 fn write_user_preset_overwrites_existing_file() -> Result<(), Box<dyn std::error::Error>> {
803 let tmp = tempfile::tempdir()?;
804 let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path().join(".recursive"));
805
806 let mut first = sample_test_preset("over");
807 first.default_model = "old".to_string();
808 write_user_preset(&first)?;
809
810 let mut second = sample_test_preset("over");
811 second.default_model = "new".to_string();
812 write_user_preset(&second)?;
813
814 let loaded = additional_presets();
815 assert_eq!(loaded.len(), 1, "overwrite must not duplicate files");
816 assert_eq!(
817 loaded[0].default_model, "new",
818 "second write must replace the first"
819 );
820 Ok(())
821 }
822}