oxi-ai 0.27.1

Unified LLM API — multi-provider streaming interface for AI coding assistants
Documentation
//! Model metadata structures — TOML ↔ Rust.

use serde::{Deserialize, Serialize};

/// A single built-in model entry, deserialized from `data/catalog/models/*.toml`.
///
/// Matches the shape of `model_db::ModelEntry` but uses owned `String` types
/// because it comes from a runtime-parsed TOML rather than static literals.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuiltinModelEntry {
    /// Model identifier (e.g., "claude-sonnet-4-20250514")
    pub id: String,
    /// Human-readable model name (e.g., "Claude Sonnet 4")
    pub name: String,
    /// API protocol to use
    pub api: String,
    /// Provider name (e.g., "anthropic", "openai")
    pub provider: String,
    /// Whether this model supports reasoning/thinking
    #[serde(default)]
    pub reasoning: bool,
    /// Supported input modalities
    #[serde(default)]
    pub input: Vec<String>,
    /// Cost per million input tokens (USD)
    #[serde(default)]
    pub cost_input: f64,
    /// Cost per million output tokens (USD)
    #[serde(default)]
    pub cost_output: f64,
    /// Cost per million cached read tokens (USD)
    #[serde(default)]
    pub cost_cache_read: f64,
    /// Cost per million cached write tokens (USD)
    #[serde(default)]
    pub cost_cache_write: f64,
    /// Maximum context window in tokens
    #[serde(default)]
    pub context_window: u32,
    /// Maximum output tokens
    #[serde(default)]
    pub max_tokens: u32,
}

impl BuiltinModelEntry {
    /// Check if this model supports image/vision input.
    pub fn supports_vision(&self) -> bool {
        self.input.iter().any(|m| m == "image" || m == "Image")
    }

    /// Check if this model supports reasoning/thinking.
    pub fn supports_reasoning(&self) -> bool {
        self.reasoning
    }

    /// Calculate the cost for a given token usage.
    pub fn calculate_cost(
        &self,
        input_tokens: u64,
        output_tokens: u64,
        cache_read: u64,
        cache_write: u64,
    ) -> f64 {
        let in_cost = (input_tokens as f64 / 1_000_000.0) * self.cost_input;
        let out_cost = (output_tokens as f64 / 1_000_000.0) * self.cost_output;
        let cr_cost = (cache_read as f64 / 1_000_000.0) * self.cost_cache_read;
        let cw_cost = (cache_write as f64 / 1_000_000.0) * self.cost_cache_write;
        in_cost + out_cost + cr_cost + cw_cost
    }
}

/// Load all built-in models from the bundled TOML files.
pub fn load_builtin_models() -> &'static std::collections::BTreeMap<String, Vec<BuiltinModelEntry>>
{
    &crate::catalog::CatalogRoot::get().models
}

/// Number of built-in models (across all providers).
pub fn builtin_model_count() -> usize {
    load_builtin_models().values().map(|v| v.len()).sum()
}

/// Index of all built-in model TOML files.
///
/// AUTO-GENERATED by `build.rs`. The script enumerates every file under
/// `data/catalog/models/` and `data/catalog/openclaw/` and emits a body
/// expression at `OUT_DIR/catalog_index.rs` (a `&[(&str, &str)]` literal
/// of `include_str!` calls). We `include!` that body as the return value
/// of this function.
///
/// Adding a new TOML file to either directory requires NO Rust code changes
/// — the build picks it up on the next compile.
pub fn models_index() -> &'static [(&'static str, &'static str)] {
    include!(concat!(env!("OUT_DIR"), "/catalog_index.rs"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn models_index_loads_all_providers() {
        let idx = models_index();
        // 29 oxi-original + 13 openclaw = 42 files. The build script
        // auto-enumerates them; this test catches miscounts.
        assert_eq!(idx.len(), 42, "expected 42 files, got {}", idx.len());
        for (pid, body) in idx {
            assert!(!pid.is_empty(), "empty provider id");
            assert!(
                body.contains("provider ="),
                "{pid}: missing top-level provider field"
            );
            assert!(body.contains("[[model]]"), "{pid}: missing model entries");
        }
    }

    #[test]
    fn all_loaded_models_round_trip() {
        // Each TOML file should be parseable and yield >=1 model.
        // The exact count must match the known catalog size (1099 as of
        // 2026-Q2). If this fails, either a TOML file was deleted, a
        // model was added/removed without updating the test, or a
        // parsing error silently dropped models.
        let root = crate::catalog::CatalogRoot::get();
        let total: usize = root.models.values().map(|v| v.len()).sum();
        assert_eq!(
            total, 1099,
            "model count mismatch (expected 1099, got {total})"
        );
    }

    #[test]
    fn sentinel_pricing_counted() {
        // After the price-backfill pass, 11 openclaw-sourced providers have
        // unverified `0.0` upstream values. These must surface as
        // `pricing_unverified` in the model DB.
        use crate::model_db::{builtin_model_count_sentinel, get_all_models};
        let mut s = 0;
        let mut z = 0;
        let mut v = 0;
        for m in get_all_models() {
            if m.pricing_unverified() {
                s += 1;
            } else if m.cost_input == 0.0 && m.cost_output == 0.0 {
                z += 1;
            } else {
                v += 1;
            }
        }
        let helper = builtin_model_count_sentinel();
        assert_eq!(
            s, helper,
            "helper and direct count disagree: {s} vs {helper}"
        );
        assert_eq!(s, 34, "sentinel count drift (expected 34, got {s})");
        // Sanity: v + s + z should equal total model count
        assert_eq!(
            v + s + z,
            1099,
            "category counts don't sum to total (v={v} s={s} z={z})"
        );
    }
}