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