1use serde::{Deserialize, Serialize};
2use std::collections::{HashMap, HashSet};
3use std::sync::LazyLock;
4
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub struct ModelCost {
12 pub input: f64,
14 pub output: f64,
16 #[serde(default)]
18 pub cache_read: Option<f64>,
19 #[serde(default)]
21 pub cache_write: Option<f64>,
22}
23
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct ModelLimit {
27 pub context: u64,
29 pub output: u64,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct ModelModalities {
36 pub input: Vec<String>,
38 pub output: Vec<String>,
40}
41
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44pub struct ModelCapabilities {
45 #[serde(default)]
47 pub attachment: bool,
48 #[serde(default)]
50 pub reasoning: bool,
51 #[serde(default)]
53 pub temperature: bool,
54 #[serde(default)]
56 pub tool_call: bool,
57 #[serde(default = "default_true")]
59 pub streaming: bool,
60}
61
62fn default_true() -> bool {
63 true
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct ModelDescriptor {
69 pub id: String,
71 pub name: String,
73 #[serde(default)]
75 pub family: Option<String>,
76 #[serde(default)]
78 pub release_date: Option<String>,
79 #[serde(default)]
81 pub cost: Option<ModelCost>,
82 #[serde(default)]
84 pub limit: Option<ModelLimit>,
85 #[serde(default)]
87 pub modalities: Option<ModelModalities>,
88 #[serde(default)]
90 pub capabilities: Option<ModelCapabilities>,
91 #[serde(default)]
93 pub knowledge: Option<String>,
94}
95
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct ServiceDescriptor {
103 pub id: String,
105 #[serde(rename = "display_name")]
107 pub display_name: String,
108 pub description: String,
110 pub category: String,
112 pub family: String,
114 #[serde(rename = "auth_mode")]
116 pub auth_mode: String,
117 #[serde(rename = "key_var", skip_serializing_if = "String::is_empty", default)]
119 pub key_var: String,
120 #[serde(
122 rename = "literal_auth_token",
123 skip_serializing_if = "String::is_empty",
124 default
125 )]
126 pub literal_auth_token: String,
127 #[serde(rename = "base_url")]
129 pub base_url: String,
130 #[serde(rename = "default_model")]
132 pub default_model: String,
133 #[serde(rename = "model_tiers", default)]
135 pub model_tiers: HashMap<String, String>,
136 #[serde(rename = "model_choices", default)]
138 pub model_choices: Vec<ModelChoice>,
139 #[serde(rename = "test_url")]
141 pub test_url: String,
142 #[serde(default)]
144 pub setup: Vec<String>,
145 #[serde(default)]
147 pub usage: Vec<String>,
148
149 #[serde(default)]
152 pub api_base_url: Option<String>,
153 #[serde(default)]
155 pub npm_package: Option<String>,
156 #[serde(default)]
158 pub doc_url: Option<String>,
159 #[serde(default)]
161 pub models: Vec<ModelDescriptor>,
162}
163
164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167pub struct ModelChoice {
168 pub id: String,
170 pub description: String,
172}
173
174#[derive(Debug, Serialize, Deserialize)]
175struct IndexPayload {
176 providers: Vec<ServiceDescriptor>,
177}
178
179#[derive(Debug, Clone)]
188pub struct ProviderIndex {
189 entries: Vec<ServiceDescriptor>,
190 index: HashMap<String, usize>,
191}
192
193impl ProviderIndex {
194 fn from_payload(payload: IndexPayload) -> Self {
195 Self::from_providers(payload.providers)
196 }
197
198 pub fn from_providers(providers: Vec<ServiceDescriptor>) -> Self {
206 let index: HashMap<String, usize> = providers
207 .iter()
208 .enumerate()
209 .map(|(i, p)| (p.id.clone(), i))
210 .collect();
211 Self {
212 entries: providers,
213 index,
214 }
215 }
216
217 pub fn embedded() -> &'static ProviderIndex {
219 &EMBEDDED
220 }
221
222 #[cfg(feature = "discovery")]
234 pub fn with_discovered(&self, discovered: &[crate::discovery::ModelEntry]) -> ProviderIndex {
235 let mut entries: Vec<ServiceDescriptor> = self.entries.clone();
236 let mut synthetic: Option<ServiceDescriptor> = None;
237
238 for entry in discovered {
239 let model: ModelDescriptor = entry.clone().into();
240 match self.index.get(&entry.provider_id).copied() {
241 Some(idx) => {
242 let provider = &mut entries[idx];
243 if let Some(pos) = provider.models.iter().position(|m| m.id == model.id) {
244 provider.models[pos] = model;
245 } else {
246 provider.models.push(model);
247 }
248 }
249 None => {
250 let synth = synthetic.get_or_insert_with(|| ServiceDescriptor {
251 id: "discovered".to_string(),
252 display_name: "Discovered".to_string(),
253 description: "Runtime-discovered models not present in the embedded \
254 catalog."
255 .to_string(),
256 category: "discovered".to_string(),
257 family: "discovered".to_string(),
258 auth_mode: "secret".to_string(),
259 key_var: String::new(),
260 literal_auth_token: String::new(),
261 base_url: String::new(),
262 default_model: String::new(),
263 model_tiers: HashMap::new(),
264 model_choices: vec![],
265 test_url: String::new(),
266 setup: vec![],
267 usage: vec![],
268 api_base_url: None,
269 npm_package: None,
270 doc_url: None,
271 models: vec![],
272 });
273 synth.models.push(model);
274 }
275 }
276 }
277
278 if let Some(synth) = synthetic {
279 entries.push(synth);
280 }
281
282 ProviderIndex::from_providers(entries)
283 }
284
285 pub fn all(&self) -> &[ServiceDescriptor] {
287 &self.entries
288 }
289
290 pub fn ids(&self) -> Vec<String> {
292 self.entries.iter().map(|p| p.id.clone()).collect()
293 }
294
295 pub fn get(&self, id: &str) -> Option<&ServiceDescriptor> {
297 self.index.get(id).map(|&i| &self.entries[i])
298 }
299
300 pub fn categories(&self) -> Vec<String> {
302 self.entries
303 .iter()
304 .scan(HashSet::new(), |seen, p| {
305 Some(if seen.insert(p.category.clone()) {
306 Some(p.category.clone())
307 } else {
308 None
309 })
310 })
311 .flatten()
312 .collect()
313 }
314
315 pub fn providers_by_category(&self, category: &str) -> Vec<&ServiceDescriptor> {
317 self.entries
318 .iter()
319 .filter(|p| p.category == category)
320 .collect()
321 }
322
323 pub fn builtin_secret_keys(&self) -> HashSet<String> {
325 self.entries
326 .iter()
327 .filter(|p| !p.key_var.is_empty())
328 .map(|p| p.key_var.clone())
329 .collect()
330 }
331
332 pub fn models_for(&self, provider_id: &str) -> &[ModelDescriptor] {
334 self.get(provider_id)
335 .map(|p| p.models.as_slice())
336 .unwrap_or(&[])
337 }
338
339 pub fn find_model(&self, model_id: &str) -> Option<(&ServiceDescriptor, &ModelDescriptor)> {
342 self.entries
343 .iter()
344 .find_map(|p| p.models.iter().find(|m| m.id == model_id).map(|m| (p, m)))
345 }
346
347 pub fn estimate_cost(
354 &self,
355 model_id: &str,
356 prompt_tokens: u32,
357 completion_tokens: u32,
358 ) -> Option<f64> {
359 let (_, model) = self.find_model(model_id)?;
360 let cost = model.cost.as_ref()?;
361 Some(
362 cost.input * prompt_tokens as f64 / 1_000_000.0
363 + cost.output * completion_tokens as f64 / 1_000_000.0,
364 )
365 }
366}
367
368static EMBEDDED: LazyLock<ProviderIndex> = LazyLock::new(|| {
370 let raw = include_str!("catalog.json");
371 let payload: IndexPayload = serde_json::from_str(raw).expect("catalog.json is valid");
372 ProviderIndex::from_payload(payload)
373});
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[test]
380 fn test_embedded_loads() {
381 let catalog = ProviderIndex::embedded();
382 assert!(!catalog.all().is_empty());
383 }
384
385 #[test]
386 fn test_get_known_provider() {
387 let catalog = ProviderIndex::embedded();
388 let p = catalog.get("zai").expect("zai should exist");
390 assert_eq!(p.id, "zai");
391 assert!(!p.base_url.is_empty());
392 assert!(!p.default_model.is_empty());
393 }
394
395 #[test]
396 fn test_get_unknown_returns_none() {
397 let catalog = ProviderIndex::embedded();
398 assert!(catalog.get("nonexistent_provider_xyz").is_none());
399 }
400
401 #[test]
402 fn test_categories_no_duplicates() {
403 let catalog = ProviderIndex::embedded();
404 let cats = catalog.categories();
405 let mut seen = HashSet::new();
406 for c in &cats {
407 assert!(seen.insert(c.clone()), "duplicate category: {}", c);
408 }
409 }
410
411 #[test]
412 fn test_builtin_secret_keys() {
413 let catalog = ProviderIndex::embedded();
414 let keys = catalog.builtin_secret_keys();
415 assert!(!keys.is_empty(), "should contain at least one secret key");
416 assert!(
417 keys.contains("ZAI_API_KEY"),
418 "should contain ZAI_API_KEY, got: {:?}",
419 keys
420 );
421 }
422
423 #[test]
424 fn test_providers_by_category() {
425 let catalog = ProviderIndex::embedded();
426 let cats = catalog.categories();
427 if let Some(cat) = cats.first() {
428 let providers = catalog.providers_by_category(cat);
429 assert!(!providers.is_empty());
430 for p in &providers {
431 assert_eq!(p.category, *cat);
432 }
433 }
434 }
435
436 #[test]
437 fn test_models_for_provider() {
438 let catalog = ProviderIndex::embedded();
439 let models = catalog.models_for("zai");
440 assert!(!models.is_empty(), "zai should have models");
441 assert!(!models[0].id.is_empty());
443 }
444
445 #[test]
446 fn test_models_for_unknown_provider() {
447 let catalog = ProviderIndex::embedded();
448 let models = catalog.models_for("nonexistent_provider_xyz");
449 assert!(models.is_empty());
450 }
451
452 #[test]
453 fn test_find_model() {
454 let catalog = ProviderIndex::embedded();
455 let (provider, model) = catalog.find_model("glm-5").expect("glm-5 should be found");
456 assert_eq!(model.id, "glm-5");
457 assert!(
458 provider.id == "zai" || provider.id == "zai-cn",
459 "glm-5 should belong to a Z.AI provider, got: {}",
460 provider.id
461 );
462 }
463
464 #[test]
465 fn test_find_model_unknown() {
466 let catalog = ProviderIndex::embedded();
467 assert!(catalog.find_model("nonexistent-model-xyz").is_none());
468 }
469
470 #[test]
471 fn test_model_has_pricing() {
472 let catalog = ProviderIndex::embedded();
473 let (_, model) = catalog.find_model("glm-5").expect("glm-5 should exist");
474 let cost = model.cost.as_ref().expect("glm-5 should have cost");
475 assert!(cost.input > 0.0, "input cost should be positive");
476 assert!(cost.output > 0.0, "output cost should be positive");
477 }
478
479 #[test]
480 fn test_from_providers_round_trip() {
481 let original = ProviderIndex::embedded();
482 let rebuilt = ProviderIndex::from_providers(original.entries.clone());
483 assert_eq!(rebuilt.ids().len(), original.ids().len());
484 assert!(rebuilt.get("zai").is_some());
486 assert!(rebuilt.find_model("glm-5").is_some());
487 }
488
489 #[test]
490 fn test_from_providers_preserves_order() {
491 let providers = vec![
492 ServiceDescriptor {
493 id: "p1".to_string(),
494 display_name: "P1".to_string(),
495 description: String::new(),
496 category: "c".to_string(),
497 family: "f".to_string(),
498 auth_mode: "none".to_string(),
499 key_var: String::new(),
500 literal_auth_token: String::new(),
501 base_url: String::new(),
502 default_model: String::new(),
503 model_tiers: HashMap::new(),
504 model_choices: vec![],
505 test_url: String::new(),
506 setup: vec![],
507 usage: vec![],
508 api_base_url: None,
509 npm_package: None,
510 doc_url: None,
511 models: vec![],
512 },
513 ServiceDescriptor {
514 id: "p2".to_string(),
515 display_name: "P2".to_string(),
516 ..ProviderIndex::embedded().get("zai").unwrap().clone()
517 },
518 ];
519 let idx = ProviderIndex::from_providers(providers);
520 assert_eq!(idx.ids(), vec!["p1".to_string(), "p2".to_string()]);
521 assert_eq!(idx.get("p1").unwrap().display_name, "P1");
522 assert_eq!(idx.get("p2").unwrap().display_name, "P2");
523 }
524
525 #[cfg(feature = "discovery")]
526 #[test]
527 fn test_with_discovered_merges_and_enables_cost() {
528 use crate::discovery::{ModelEntry, ModelLimits};
529 use crate::provider::ModelCost;
530
531 let catalog = ProviderIndex::embedded();
532
533 let fresh = ModelEntry {
535 id: "future-model-xyz".to_string(),
536 name: "Future Model".to_string(),
537 provider_id: "zai".to_string(),
538 cost: Some(ModelCost {
539 input: 2.0,
540 output: 8.0,
541 cache_read: None,
542 cache_write: None,
543 }),
544 limits: Some(ModelLimits {
545 context: Some(100_000),
546 input: None,
547 output: Some(4_000),
548 }),
549 ..Default::default()
550 };
551 let orphan = ModelEntry {
553 id: "mystery/m1".to_string(),
554 name: "Mystery M1".to_string(),
555 provider_id: "mystery".to_string(),
556 cost: Some(ModelCost {
557 input: 1.0,
558 output: 1.0,
559 cache_read: None,
560 cache_write: None,
561 }),
562 ..Default::default()
563 };
564
565 let merged = catalog.with_discovered(&[fresh, orphan]);
566
567 assert!(merged.find_model("future-model-xyz").is_some());
569 assert_eq!(
570 merged.estimate_cost("future-model-xyz", 1_000_000, 1_000_000),
571 Some(10.0)
572 );
573
574 assert!(merged.get("discovered").is_some());
576 assert!(merged.find_model("mystery/m1").is_some());
577 assert_eq!(merged.estimate_cost("mystery/m1", 1_000_000, 0), Some(1.0));
578
579 assert!(catalog.find_model("future-model-xyz").is_none());
581 }
582}