Skip to main content

llm_kernel/provider/
catalog.rs

1use serde::{Deserialize, Serialize};
2use std::collections::{HashMap, HashSet};
3use std::sync::LazyLock;
4
5// ---------------------------------------------------------------------------
6// models.dev-compatible model descriptor types
7// ---------------------------------------------------------------------------
8
9/// Per-million-token pricing for a model.
10#[non_exhaustive]
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
12pub struct ModelCost {
13    /// Price per million input (prompt) tokens in USD.
14    pub input: f64,
15    /// Price per million output (completion) tokens in USD.
16    pub output: f64,
17    /// Price per million cache-read tokens, if the provider supports prompt caching.
18    #[serde(default)]
19    pub cache_read: Option<f64>,
20    /// Price per million cache-write tokens, if the provider supports prompt caching.
21    #[serde(default)]
22    pub cache_write: Option<f64>,
23}
24
25/// Token limits for a model.
26#[non_exhaustive]
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
28pub struct ModelLimit {
29    /// Maximum context window in tokens (prompt + completion).
30    pub context: u64,
31    /// Maximum output (completion) tokens per request.
32    pub output: u64,
33}
34
35/// Input/output modalities a model supports.
36#[non_exhaustive]
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
38pub struct ModelModalities {
39    /// Accepted input modalities (e.g. `["text", "image"]`).
40    pub input: Vec<String>,
41    /// Produced output modalities (e.g. `["text"]`).
42    pub output: Vec<String>,
43}
44
45/// Capability flags for a model.
46#[non_exhaustive]
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
48pub struct ModelCapabilities {
49    /// Whether the model accepts file/image attachments.
50    #[serde(default)]
51    pub attachment: bool,
52    /// Whether the model supports extended reasoning / chain-of-thought.
53    #[serde(default)]
54    pub reasoning: bool,
55    /// Whether the model accepts a `temperature` parameter.
56    #[serde(default)]
57    pub temperature: bool,
58    /// Whether the model supports tool/function calling.
59    #[serde(default)]
60    pub tool_call: bool,
61    /// Whether the model supports streaming responses (SSE).
62    #[serde(default = "default_true")]
63    pub streaming: bool,
64}
65
66fn default_true() -> bool {
67    true
68}
69
70/// A model offered by a provider (models.dev-compatible).
71#[non_exhaustive]
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
73pub struct ModelDescriptor {
74    /// Unique model identifier (e.g. `"gpt-4o"`, `"claude-sonnet-4-6"`).
75    pub id: String,
76    /// Human-readable model name.
77    pub name: String,
78    /// Model family grouping (e.g. `"gpt-4"`, `"claude-3"`).
79    #[serde(default)]
80    pub family: Option<String>,
81    /// ISO 8601 date the model was released.
82    #[serde(default)]
83    pub release_date: Option<String>,
84    /// Pricing information per million tokens.
85    #[serde(default)]
86    pub cost: Option<ModelCost>,
87    /// Token limits for context and output.
88    #[serde(default)]
89    pub limit: Option<ModelLimit>,
90    /// Input and output modalities.
91    #[serde(default)]
92    pub modalities: Option<ModelModalities>,
93    /// Capability flags (tool calling, streaming, etc.).
94    #[serde(default)]
95    pub capabilities: Option<ModelCapabilities>,
96    /// Knowledge cutoff date (ISO 8601).
97    #[serde(default)]
98    pub knowledge: Option<String>,
99}
100
101// ---------------------------------------------------------------------------
102// Provider service descriptor
103// ---------------------------------------------------------------------------
104
105/// Describes an LLM provider service with all metadata needed to connect and use it.
106#[non_exhaustive]
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
108pub struct ServiceDescriptor {
109    /// Unique provider identifier (e.g. `"openai"`, `"anthropic"`).
110    pub id: String,
111    /// Human-readable display name.
112    #[serde(rename = "display_name")]
113    pub display_name: String,
114    /// Short description of the provider.
115    pub description: String,
116    /// Provider category (e.g. `"cloud"`, `"local"`).
117    pub category: String,
118    /// Provider family used to group related providers.
119    pub family: String,
120    /// Authentication mode: `"none"`, `"literal"`, or `"secret"`.
121    #[serde(rename = "auth_mode")]
122    pub auth_mode: String,
123    /// Environment variable name that holds the API key (empty if not required).
124    #[serde(rename = "key_var", skip_serializing_if = "String::is_empty", default)]
125    pub key_var: String,
126    /// Literal auth token embedded in the catalog (only set when `auth_mode = "literal"`).
127    #[serde(
128        rename = "literal_auth_token",
129        skip_serializing_if = "String::is_empty",
130        default
131    )]
132    pub literal_auth_token: String,
133    /// Base URL for the provider's web interface.
134    #[serde(rename = "base_url")]
135    pub base_url: String,
136    /// Default model ID used when no model override is specified.
137    #[serde(rename = "default_model")]
138    pub default_model: String,
139    /// Named model tiers mapping tier name → model ID (e.g. `"fast"` → `"gpt-4o-mini"`).
140    #[serde(rename = "model_tiers", default)]
141    pub model_tiers: HashMap<String, String>,
142    /// Legacy list of available model choices (claudy-specific).
143    #[serde(rename = "model_choices", default)]
144    pub model_choices: Vec<ModelChoice>,
145    /// URL used to test connectivity to the provider.
146    #[serde(rename = "test_url")]
147    pub test_url: String,
148    /// Setup instructions shown to the user during first-time configuration.
149    #[serde(default)]
150    pub setup: Vec<String>,
151    /// Usage examples shown to the user in the install wizard.
152    #[serde(default)]
153    pub usage: Vec<String>,
154
155    // models.dev-compatible fields
156    /// API base URL override (models.dev-compatible field).
157    #[serde(default)]
158    pub api_base_url: Option<String>,
159    /// npm package name (models.dev-compatible field, for AI coding tools).
160    #[serde(default)]
161    pub npm_package: Option<String>,
162    /// Link to provider documentation.
163    #[serde(default)]
164    pub doc_url: Option<String>,
165    /// Full list of models offered by this provider.
166    #[serde(default)]
167    pub models: Vec<ModelDescriptor>,
168}
169
170/// Legacy model choice (claudy-specific: id + description).
171/// Retained for backward compatibility with existing catalog.json entries.
172#[non_exhaustive]
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
174pub struct ModelChoice {
175    /// Model identifier.
176    pub id: String,
177    /// Short description of the model.
178    pub description: String,
179}
180
181#[derive(Debug, Serialize, Deserialize)]
182struct IndexPayload {
183    providers: Vec<ServiceDescriptor>,
184}
185
186// ---------------------------------------------------------------------------
187// Provider index
188// ---------------------------------------------------------------------------
189
190/// Immutable provider catalog with O(1) lookup by id.
191///
192/// The catalog is compiled into the binary from `catalog.json` via `include_str!`.
193/// Access it through [`ProviderIndex::embedded()`].
194#[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    /// Build a [`ProviderIndex`] from an explicit list of providers.
206    ///
207    /// Useful for tests, overlays, or merging discovered providers into the
208    /// embedded catalog. Provider order is preserved. The embedded catalog has
209    /// no duplicate ids; if duplicates are passed, [`ProviderIndex::get`]
210    /// resolves to the last occurrence while [`ProviderIndex::all`] retains
211    /// every entry.
212    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    /// Access the static catalog embedded at compile time.
225    pub fn embedded() -> &'static ProviderIndex {
226        &EMBEDDED
227    }
228
229    /// Return a new catalog where discovered model entries overlay this one.
230    ///
231    /// For a discovered entry whose `provider_id` matches an existing provider,
232    /// its model is merged into that provider (replacing on id collision,
233    /// appending otherwise). Entries whose `provider_id` is not in the catalog
234    /// are gathered under a synthetic `"discovered"` provider.
235    ///
236    /// This resolves the catalog↔discovery gap: once merged, discovered models
237    /// are visible to [`ProviderIndex::find_model`] and
238    /// [`ProviderIndex::estimate_cost`]. The catalog is not mutated; an owned
239    /// [`ProviderIndex`] is returned.
240    #[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    /// Return all providers in catalog order.
293    pub fn all(&self) -> &[ServiceDescriptor] {
294        &self.entries
295    }
296
297    /// Return all provider IDs.
298    pub fn ids(&self) -> Vec<String> {
299        self.entries.iter().map(|p| p.id.clone()).collect()
300    }
301
302    /// Look up a provider by ID. O(1).
303    pub fn get(&self, id: &str) -> Option<&ServiceDescriptor> {
304        self.index.get(id).map(|&i| &self.entries[i])
305    }
306
307    /// Unique categories in catalog order.
308    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    /// Filter providers by category.
323    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    /// Collect all secret key variable names from providers that require one.
331    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    /// Get models for a specific provider.
340    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    /// Find a model by ID across all providers.
347    /// Returns the first match (provider, model).
348    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    /// Estimate the USD cost of an LLM call given token counts.
355    ///
356    /// Looks up `model_id` across all providers and computes:
357    /// `(input_price * prompt_tokens + output_price * completion_tokens) / 1_000_000`
358    ///
359    /// Returns `None` if the model is not found or has no pricing data.
360    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
375/// Static catalog compiled into the binary from `catalog.json`.
376static 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        // catalog.json contains "zai" (first provider with key_var)
396        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        // First model should have an id
449        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        // O(1) lookup survives reconstruction.
492        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        // A model not in the static catalog, attached to an existing provider.
541        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        // A model under a provider absent from the catalog → synthetic bucket.
559        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        // fresh merged into existing zai → estimate_cost now works.
575        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        // orphan landed under a synthetic "discovered" provider.
582        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        // The embedded static catalog is not mutated.
587        assert!(catalog.find_model("future-model-xyz").is_none());
588    }
589}