Skip to main content

oxicode_ai/
model_db.rs

1//! Comprehensive model database for oxicode-ai
2//!
3//! Contains 934 models across 29 providers.
4//!
5//! # Usage
6//!
7//! ```ignore
8//! use oxicode_ai::model_db::{get_model_entry, get_provider_models, get_all_models};
9//!
10//! // Look up a specific model
11//! let entry = get_model_entry("anthropic", "claude-sonnet-4-20250514");
12//! assert!(entry.is_some());
13//!
14//! // Get all models for a provider
15//! let anthropic_models = get_provider_models("anthropic");
16//! assert!(!anthropic_models.is_empty());
17//!
18//! // Iterate all models
19//! let all = get_all_models();
20//! assert!(all.len() > 926);
21//! ```
22
23use std::collections::HashMap;
24use std::sync::OnceLock;
25
26use crate::catalog::BuiltinModelEntry;
27use crate::{Api, InputModality};
28
29// ---------------------------------------------------------------------------
30// ModelEntry bridge
31// ---------------------------------------------------------------------------
32//
33// The canonical model data is materialized from the embedded models.dev
34// snapshot (`_snapshot.json.gz`, Layer 1 of the dynamic catalog). The legacy
35// `ModelEntry` struct here is `&'static str` based, so we need to convert
36// each `BuiltinModelEntry` (String-based, from the materialize pipeline)
37// to a `ModelEntry` once and cache the result in a `OnceLock`.
38// String-to-`&'static str` is achieved via `Box::leak`, same
39// pattern used by `register_builtins.rs`.
40
41fn parse_api(s: &str) -> Api {
42    // Delegate to the single authoritative parser on `Api` (all 14 KnownApi).
43    Api::from_kebab_str(s).unwrap_or(Api::OpenAiCompletions)
44}
45
46fn parse_input_modality(s: &str) -> InputModality {
47    match s {
48        "text" | "Text" => InputModality::Text,
49        "image" | "Image" => InputModality::Image,
50        _ => InputModality::Text,
51    }
52}
53
54impl From<&BuiltinModelEntry> for ModelEntry {
55    fn from(e: &BuiltinModelEntry) -> Self {
56        // Leak the strings to obtain `&'static str`. Bounded by total model
57        // count and amortized once at startup.
58        let id: &'static str = Box::leak(e.id.clone().into_boxed_str());
59        let name: &'static str = Box::leak(e.name.clone().into_boxed_str());
60        let provider: &'static str = Box::leak(e.provider.clone().into_boxed_str());
61        let input: &'static [InputModality] = Box::leak(
62            e.input
63                .iter()
64                .map(|s| parse_input_modality(s))
65                .collect::<Vec<_>>()
66                .into_boxed_slice(),
67        );
68        // models.dev is the verified source of truth — all prices are
69        // treated as-is. A 0.0 cost means "verified free" (e.g. local
70        // models, free tiers). The legacy openclaw sentinel transform
71        // (`is_openclaw_sourced`) has been removed since models.dev data
72        // is community-verified, not placeholder zeros.
73        ModelEntry {
74            id,
75            name,
76            api: parse_api(&e.api),
77            provider,
78            reasoning: e.reasoning,
79            input,
80            cost_input: e.cost_input,
81            cost_output: e.cost_output,
82            cost_cache_read: e.cost_cache_read,
83            cost_cache_write: e.cost_cache_write,
84            context_window: e.context_window,
85            max_tokens: e.max_tokens,
86        }
87    }
88}
89
90/// Sentinel value used when a model entry has no verified price.
91///
92/// With the models.dev materialize path, all prices are treated as
93/// verified. This constant is retained for backward compatibility but
94/// is no longer produced by the materialize pipeline.
95pub const UNVERIFIED_PRICE: f64 = -1.0;
96
97/// Returns true if a provider id came from the openclaw port AND has
98/// unverified pricing.
99///
100/// These are the providers whose `0.0` cost values in the openclaw
101/// upstream are placeholder, not verified-free. The runtime sentinel
102/// transformation (`0.0` → `-1.0`) applies to them.
103///
104/// Providers with **verified** prices (venice, novita) are NOT in this
105/// set — their values are backfilled and treated as known.
106///
107/// See `data/catalog/README.md` for the data-quality breakdown.
108/// A static model entry in the database.
109///
110/// Uses `&'static str` references for zero-allocation lookups.
111#[derive(Debug, Clone, Copy, PartialEq)]
112pub struct ModelEntry {
113    /// Model identifier (e.g., "claude-sonnet-4-20250514")
114    pub id: &'static str,
115    /// Human-readable model name (e.g., "Claude Sonnet 4")
116    pub name: &'static str,
117    /// API protocol to use
118    pub api: Api,
119    /// Provider name (e.g., "anthropic", "openai")
120    pub provider: &'static str,
121    /// Whether this model supports reasoning/thinking
122    pub reasoning: bool,
123    /// Supported input modalities
124    pub input: &'static [InputModality],
125    /// Cost per million input tokens (USD)
126    pub cost_input: f64,
127    /// Cost per million output tokens (USD)
128    pub cost_output: f64,
129    /// Cost per million cached read tokens (USD)
130    pub cost_cache_read: f64,
131    /// Cost per million cached write tokens (USD)
132    pub cost_cache_write: f64,
133    /// Maximum context window in tokens
134    pub context_window: u32,
135    /// Maximum output tokens
136    pub max_tokens: u32,
137}
138
139impl ModelEntry {
140    /// Check if this model supports image/vision input
141    pub fn supports_vision(&self) -> bool {
142        self.input.contains(&InputModality::Image)
143    }
144
145    /// Check if this model supports reasoning/thinking
146    pub fn supports_reasoning(&self) -> bool {
147        self.reasoning
148    }
149
150    /// Calculate the cost for a given token usage.
151    ///
152    /// Returns 0.0 for any field that is the unverified sentinel
153    /// (`UNVERIFIED_PRICE`, i.e. negative). Callers that care about
154    /// unverified prices should check [`ModelEntry::pricing_unverified`]
155    /// first and warn the user.
156    pub fn calculate_cost(
157        &self,
158        input_tokens: u64,
159        output_tokens: u64,
160        cache_read: u64,
161        cache_write: u64,
162    ) -> f64 {
163        let in_cost = (input_tokens as f64 / 1_000_000.0) * self.cost_input.max(0.0);
164        let out_cost = (output_tokens as f64 / 1_000_000.0) * self.cost_output.max(0.0);
165        let cr_cost = (cache_read as f64 / 1_000_000.0) * self.cost_cache_read.max(0.0);
166        let cw_cost = (cache_write as f64 / 1_000_000.0) * self.cost_cache_write.max(0.0);
167        in_cost + out_cost + cr_cost + cw_cost
168    }
169
170    /// Sentinel value indicating "price unknown" / "not verified".
171    ///
172    /// Distinguishes upstream-supplied zero (e.g. a free local model) from
173    /// "we don't have the price yet, use with caution". The convention is:
174    ///
175    /// - `cost_input = -1.0` (or any negative) means: price is unverified.
176    ///   UIs should warn the user. Cost calculations may return 0 or refuse.
177    /// - `cost_input = 0.0` means: price is verified as zero (truly free).
178    /// - `cost_input > 0.0` means: verified price per million tokens.
179    ///
180    /// The `BuiltinModelEntry → ModelEntry` converter applies this
181    /// transformation: upstream `0.0` for a known paid provider becomes `-1.0`
182    /// here. The `pricing_verified` method lets callers check.
183    pub fn pricing_verified(&self) -> bool {
184        self.cost_input >= 0.0 && self.cost_output >= 0.0
185    }
186
187    /// Returns true if either cost field is the unverified sentinel.
188    pub fn pricing_unverified(&self) -> bool {
189        self.cost_input < 0.0 || self.cost_output < 0.0
190    }
191}
192
193/// Lazy, catalog-backed `(provider, models)` table.
194///
195/// Replaces the historical `static ALL_PROVIDER_MODELS` array. On first access,
196/// this iterates the `BuiltinModelEntry` map from `crate::catalog`, converts
197/// each entry via `From<&BuiltinModelEntry> for ModelEntry`, and stores the
198/// result. Subsequent accesses return the cached `&'static` slice.
199///
200/// The string-to-`&'static str` conversions happen inside the `From` impl.
201static ALL_PROVIDER_MODELS: OnceLock<Vec<(&'static str, &'static [ModelEntry])>> = OnceLock::new();
202
203fn all_provider_models() -> &'static [(&'static str, &'static [ModelEntry])] {
204    ALL_PROVIDER_MODELS
205        .get_or_init(|| {
206            // SAFETY: the embedded catalog snapshot is a REQUIRED resource — if
207            // it is missing or corrupt, oxicode cannot resolve any model and the
208            // process is broken regardless of error handling. Failing fast with
209            // a clear message is the designed behavior (see the fn doc). This
210            // is not a recoverable error path.
211            #[allow(clippy::expect_used)]
212            try_materialize_from_snapshot().expect(
213                "Failed to materialize from embedded snapshot. \
214             The catalog snapshot is required for oxicode to function.",
215            )
216        })
217        .as_slice()
218}
219
220/// Try to load and materialize from the embedded SNAP snapshot.
221fn try_materialize_from_snapshot() -> Option<Vec<(&'static str, &'static [ModelEntry])>> {
222    let catalog = crate::catalog::materialize::load_snapshot_catalog()?;
223    let product_meta = crate::catalog::ProductMeta::builtin();
224    let overrides = crate::catalog::load_overrides().unwrap_or_default();
225    let (_providers, models_by_pid) =
226        crate::catalog::materialize(&catalog, &product_meta, &overrides);
227    let mut out: Vec<(&'static str, &'static [ModelEntry])> =
228        Vec::with_capacity(models_by_pid.len());
229    for (pid, entries) in models_by_pid {
230        let pid_static: &'static str = Box::leak(pid.into_boxed_str());
231        let model_entries: Vec<ModelEntry> = entries.iter().map(ModelEntry::from).collect();
232        let slice: &'static [ModelEntry] = Box::leak(model_entries.into_boxed_slice());
233        out.push((pid_static, slice));
234    }
235    out.sort_by(|a, b| a.0.cmp(b.0));
236    Some(out)
237}
238
239// ── Materialize path (public, for tests / CLI) ─────────────────────────
240
241/// Initialize and materialize the catalog from models.dev data.
242pub fn try_materialize_all() -> Option<Vec<(&'static str, &'static [ModelEntry])>> {
243    let catalog = crate::catalog::models_dev::get()?;
244    let product_meta = crate::catalog::ProductMeta::builtin();
245    let overrides = crate::catalog::load_overrides().unwrap_or_default();
246    let (_providers, models_by_pid) =
247        crate::catalog::materialize(catalog, &product_meta, &overrides);
248    let mut out: Vec<(&'static str, &'static [ModelEntry])> =
249        Vec::with_capacity(models_by_pid.len());
250    for (pid, entries) in models_by_pid {
251        let pid_static: &'static str = Box::leak(pid.into_boxed_str());
252        let model_entries: Vec<ModelEntry> = entries.iter().map(ModelEntry::from).collect();
253        let slice: &'static [ModelEntry] = Box::leak(model_entries.into_boxed_slice());
254        out.push((pid_static, slice));
255    }
256    out.sort_by(|a, b| a.0.cmp(b.0));
257    Some(out)
258}
259
260// ── Lazy-initialized indexes for O(1) lookups ──────────────────────────
261
262/// Maps `"provider/id"` → `&'static ModelEntry` for O(1) model lookups.
263static MODEL_INDEX: OnceLock<HashMap<&'static str, &'static ModelEntry>> = OnceLock::new();
264
265fn model_index() -> &'static HashMap<&'static str, &'static ModelEntry> {
266    MODEL_INDEX.get_or_init(|| {
267        let mut map = HashMap::with_capacity(model_count());
268        for (provider, models) in all_provider_models().iter() {
269            for model in models.iter() {
270                let key = format!("{}/{}", provider, model.id);
271                let key_static: &'static str = Box::leak(key.into_boxed_str());
272                map.insert(key_static, model);
273            }
274        }
275        map
276    })
277}
278
279/// Maps provider name → its model slice for O(1) provider lookups.
280static PROVIDER_INDEX: OnceLock<HashMap<&'static str, &'static [ModelEntry]>> = OnceLock::new();
281
282fn provider_index() -> &'static HashMap<&'static str, &'static [ModelEntry]> {
283    PROVIDER_INDEX.get_or_init(|| {
284        let mut map = HashMap::with_capacity(all_provider_models().len());
285        for (provider, models) in all_provider_models().iter() {
286            map.insert(*provider, *models);
287        }
288        map
289    })
290}
291
292// ── Public API ───────────────────────────────────────────────────────────
293
294/// Look up a specific model entry by provider and model ID.
295///
296/// Uses an O(1) index internally. Falls back gracefully if not found.
297///
298/// # Arguments
299/// * `provider` - The provider name (e.g., "anthropic", "openai")
300/// * `id` - The model ID (e.g., "claude-sonnet-4-20250514")
301///
302/// # Returns
303/// `Some(&ModelEntry)` if found, `None` otherwise.
304///
305/// # Example
306/// ```ignore
307/// use oxicode_ai::model_db::get_model_entry;
308/// let m = get_model_entry("openai", "gpt-4o").unwrap();
309/// assert_eq!(m.name, "GPT-4o");
310/// ```
311pub fn get_model_entry(provider: &str, id: &str) -> Option<&'static ModelEntry> {
312    let key = format!("{}/{}", provider, id);
313    model_index().get(key.as_str()).copied()
314}
315
316/// Get all model entries for a given provider.
317///
318/// Uses an O(1) index internally.
319///
320/// # Arguments
321/// * `provider` - The provider name (e.g., "anthropic", "openai")
322///
323/// # Returns
324/// A slice of `ModelEntry` for the provider, or an empty slice if not found.
325pub fn get_provider_models(provider: &str) -> &'static [ModelEntry] {
326    provider_index().get(provider).copied().unwrap_or(&[])
327}
328
329/// Get all model entries across all providers.
330///
331/// Returns a flat iterator over every `ModelEntry` in the database.
332pub fn get_all_models() -> impl Iterator<Item = &'static ModelEntry> {
333    all_provider_models()
334        .iter()
335        .flat_map(|(_, models)| models.iter())
336}
337
338/// Get the total number of models in the database.
339pub fn model_count() -> usize {
340    all_provider_models().iter().map(|(_, m)| m.len()).sum()
341}
342
343/// Count of models with the unverified-pricing sentinel
344/// (`cost_input < 0.0 || cost_output < 0.0`).
345///
346/// These are openclaw-sourced entries where the upstream shipped `0.0`
347/// prices that we could not verify. The UI should display a warning.
348pub fn builtin_model_count_sentinel() -> usize {
349    get_all_models().filter(|m| m.pricing_unverified()).count()
350}
351
352/// Get all known provider names.
353pub fn get_providers() -> Vec<&'static str> {
354    all_provider_models()
355        .iter()
356        .map(|(name, _)| *name)
357        .collect()
358}
359
360/// Search models by name or ID pattern (case-insensitive).
361pub fn search_models(pattern: &str) -> Vec<&'static ModelEntry> {
362    let lower = pattern.to_lowercase();
363    get_all_models()
364        .filter(|m| m.id.to_lowercase().contains(&lower) || m.name.to_lowercase().contains(&lower))
365        .collect()
366}
367
368/// Find models that support reasoning/thinking.
369pub fn get_reasoning_models() -> Vec<&'static ModelEntry> {
370    get_all_models().filter(|m| m.reasoning).collect()
371}
372
373/// Find models that support image/vision input.
374pub fn get_vision_models() -> Vec<&'static ModelEntry> {
375    get_all_models().filter(|m| m.supports_vision()).collect()
376}
377
378/// Find the cheapest models by input cost, returning up to `limit` results.
379pub fn get_cheapest_models(limit: usize) -> Vec<&'static ModelEntry> {
380    let mut all: Vec<_> = get_all_models().collect();
381    all.sort_by(|a, b| {
382        a.cost_input
383            .partial_cmp(&b.cost_input)
384            .unwrap_or(std::cmp::Ordering::Equal)
385    });
386    all.truncate(limit);
387    all
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn test_total_model_count() {
396        let count = model_count();
397        assert!(count >= 934, "Expected at least 934 models, got {}", count);
398    }
399
400    #[test]
401    fn test_get_anthropic_model() {
402        let m = get_model_entry("anthropic", "claude-3-5-sonnet-20240620");
403        assert!(m.is_some(), "Claude Sonnet 3.5 should exist");
404        let m = m.unwrap();
405        assert_eq!(m.provider, "anthropic");
406        assert!(m.context_window >= 200_000);
407    }
408
409    #[test]
410    fn test_get_openai_model() {
411        let m = get_model_entry("openai", "gpt-4o");
412        assert!(m.is_some(), "GPT-4o should exist");
413        let m = m.unwrap();
414        assert_eq!(m.provider, "openai");
415    }
416
417    #[test]
418    fn test_provider_models() {
419        let anthropic = get_provider_models("anthropic");
420        assert!(!anthropic.is_empty(), "Anthropic should have models");
421        assert!(anthropic.iter().all(|m| m.provider == "anthropic"));
422
423        let unknown = get_provider_models("nonexistent-provider");
424        assert!(unknown.is_empty());
425    }
426
427    #[test]
428    fn test_search_models() {
429        let results = search_models("claude");
430        assert!(!results.is_empty(), "Should find Claude models");
431        assert!(
432            results
433                .iter()
434                .all(|m| m.name.to_lowercase().contains("claude")
435                    || m.id.to_lowercase().contains("claude"))
436        );
437    }
438
439    #[test]
440    fn test_all_providers() {
441        let providers = get_providers();
442        assert!(providers.contains(&"openai"), "Should have openai");
443        assert!(providers.contains(&"anthropic"), "Should have anthropic");
444    }
445
446    #[test]
447    fn test_reasoning_models() {
448        let reasoning = get_reasoning_models();
449        assert!(!reasoning.is_empty(), "Should have reasoning models");
450        assert!(reasoning.iter().all(|m| m.reasoning));
451    }
452
453    #[test]
454    fn test_vision_models() {
455        let vision = get_vision_models();
456        assert!(!vision.is_empty(), "Should have vision models");
457        assert!(vision.iter().all(|m| m.supports_vision()));
458    }
459
460    #[test]
461
462    fn test_cheapest_models() {
463        let cheapest = get_cheapest_models(5);
464        assert_eq!(cheapest.len(), 5.min(model_count()));
465        for i in 1..cheapest.len() {
466            assert!(cheapest[i].cost_input >= cheapest[i - 1].cost_input);
467        }
468    }
469
470    #[test]
471    fn try_materialize_from_snapshot() {
472        use std::io::Read;
473        // Verify the materialize path can decode the embedded snapshot and
474        // produce valid ModelEntry records. This tests the full pipeline:
475        //   gzip → MdCatalog → materialize() → ModelEntry
476        let compressed = oxicode_catalog::snapshot_gzip_bytes();
477        let mut decoder = flate2::read::GzDecoder::new(compressed);
478        let mut json = String::new();
479        decoder.read_to_string(&mut json).unwrap();
480        let catalog: crate::catalog::MdCatalog = serde_json::from_str(&json).unwrap();
481        let meta = crate::catalog::ProductMeta::builtin();
482        let (providers, models) = crate::catalog::materialize(&catalog, &meta, &Default::default());
483        // Convert to ModelEntry format
484        let mut entries: Vec<super::ModelEntry> = Vec::new();
485        for model_list in models.values() {
486            for bm in model_list {
487                entries.push(super::ModelEntry::from(bm));
488            }
489        }
490        assert_eq!(entries.len(), 5277, "expected 5277 models");
491        assert_eq!(providers.len(), 145, "expected 145 providers");
492        // Every ModelEntry must have a valid API type
493        for e in &entries {
494            assert!(
495                matches!(
496                    e.api,
497                    Api::AnthropicMessages
498                        | Api::OpenAiCompletions
499                        | Api::OpenAiResponses
500                        | Api::GoogleGenerativeAi
501                        | Api::GoogleVertex
502                        | Api::AzureOpenAiResponses
503                        | Api::BedrockConverseStream
504                ),
505                "unexpected api for model {}/{}",
506                e.provider,
507                e.id
508            );
509        }
510        // Verify at least one model has cost_input == 0.0 (free model exists)
511        assert!(
512            entries.iter().any(|e| e.cost_input == 0.0),
513            "expected at least one free model"
514        );
515    }
516}