pricing.rs
Language Rust5.9 KB160 lines
//! Models and their prices.//!//! DeepInfra's `GET /v1/openai/models` needs no key and carries, per model,//! `metadata.pricing` in US dollars per million tokens (`input_tokens`,//! `output_tokens`, sometimes `cache_read_tokens`), the context length, and//! tags (`chat`, `vision`, `reasoning`, ...). That list is an extension to the//! OpenAI format rather than a documented contract, so a dated snapshot ships//! with the app as a fallback and the interface says which one is in use.//! Local servers list bare model ids; they cost nothing.use serde::{Deserialize, Serialize};use serde_json::Value;use ts_rs::TS;use crate::types::Prices;/// Anything above this per million tokens is treated as a parsing mistake.const PRICE_CEILING: f64 = 1000.0;#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]#[ts(export)]pub struct ModelInfo {pub provider_id: String,pub id: String,pub prices: Prices,#[ts(type = "number | null")]pub context_length: Option<i64>,#[ts(type = "number | null")]pub max_tokens: Option<i64>,pub tags: Vec<String>,}impl ModelInfo {pub fn has_tag(&self, tag: &str) -> bool {self.tags.iter().any(|t| t == tag)}pub fn vision(&self) -> bool {self.has_tag("vision") || self.has_tag("vlm")}}fn price(v: Option<&Value>) -> Option<f64> {let p = v?.as_f64()?;(p.is_finite() && (0.0..=PRICE_CEILING).contains(&p)).then_some(p)}/// DeepInfra's list (live or snapshot): chat models with token prices only.pub fn parse_priced_models(provider_id: &str, v: &Value) -> Vec<ModelInfo> {let Some(data) = v.get("data").and_then(Value::as_array) else { return vec![] };let mut out: Vec<ModelInfo> = data.iter().filter_map(|m| {let id = m.get("id")?.as_str()?.to_string();let meta = m.get("metadata")?;let tags: Vec<String> = meta.get("tags").and_then(Value::as_array).map(|a| a.iter().filter_map(|t| t.as_str().map(str::to_string)).collect()).unwrap_or_default();if !tags.iter().any(|t| t == "chat") {return None;}let p = meta.get("pricing")?;let prices = Prices {input_per_m: price(p.get("input_tokens"))?,output_per_m: price(p.get("output_tokens"))?,cache_read_per_m: price(p.get("cache_read_tokens")),};Some(ModelInfo {provider_id: provider_id.to_string(),id,prices,context_length: meta.get("context_length").and_then(Value::as_i64),max_tokens: meta.get("max_tokens").and_then(Value::as_i64),tags,})}).collect();out.sort_by_key(|m| m.id.to_lowercase());out}/// A local server's `{"data": [{"id": ...}]}`: every model free.pub fn parse_local_models(provider_id: &str, v: &Value) -> Vec<ModelInfo> {let Some(data) = v.get("data").and_then(Value::as_array) else { return vec![] };let mut out: Vec<ModelInfo> = data.iter().filter_map(|m| m.get("id")?.as_str()).map(|id| ModelInfo {provider_id: provider_id.to_string(),id: id.to_string(),prices: Prices::default(),context_length: None,max_tokens: None,tags: vec!["chat".into(), "local".into()],}).collect();out.sort_by(|a, b| a.id.cmp(&b.id));out}/// The bundled fallback, written by `scripts/snapshot-pricing.mjs`.pub const SNAPSHOT_JSON: &str = include_str!("../data/pricing-snapshot.json");pub struct Snapshot {pub fetched_at: String,pub models: Vec<ModelInfo>,}pub fn snapshot(provider_id: &str) -> Snapshot {let v: Value = serde_json::from_str(SNAPSHOT_JSON).expect("bundled pricing snapshot is valid JSON");Snapshot {fetched_at: v.get("fetched_at").and_then(Value::as_str).unwrap_or("unknown").to_string(),models: parse_priced_models(provider_id, &v),}}#[cfg(test)]mod tests {use super::*;#[test]fn keeps_priced_chat_models_only() {let models = parse_priced_models("deepinfra", &standin::models());let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect();assert!(ids.contains(&"standin/echo"));assert!(!ids.contains(&"standin/embed") && !ids.contains(&"standin/image"));let cached = models.iter().find(|m| m.id == "standin/cached").unwrap();assert_eq!(cached.prices.cache_read_per_m, Some(0.1));assert!(models.iter().find(|m| m.id == "standin/vision").unwrap().vision());}#[test]fn rejects_absurd_prices() {let v = serde_json::json!({"data": [{"id": "a", "metadata": {"tags": ["chat"], "pricing": {"input_tokens": 5000.0, "output_tokens": 1.0}}},{"id": "b", "metadata": {"tags": ["chat"], "pricing": {"input_tokens": -1.0, "output_tokens": 1.0}}},{"id": "c", "metadata": {"tags": ["chat"], "pricing": {"input_tokens": 0.1}}},{"id": "d", "metadata": {"tags": ["chat"], "pricing": {"input_tokens": 0.1, "output_tokens": 0.2}}}]});let ids: Vec<String> = parse_priced_models("p", &v).into_iter().map(|m| m.id).collect();assert_eq!(ids, ["d"]);}#[test]fn local_models_are_free() {let v = serde_json::json!({"object": "list", "data": [{"id": "qwen3:8b"}, {"id": "llama3.2"}]});let m = parse_local_models("ollama", &v);assert_eq!(m[0].id, "llama3.2");assert_eq!(m[0].prices, Prices::default());}#[test]fn bundled_snapshot_parses() {let s = snapshot("deepinfra");assert!(s.models.len() > 20, "snapshot has {} chat models", s.models.len());assert!(s.fetched_at.starts_with("20"));assert!(s.models.iter().all(|m| m.has_tag("chat")));}}