Skip to main content

tokenmiser_config/
pricing.rs

1//! Canonical per-model pricing table.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// Per-million-token pricing for a single model.
7#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
8pub struct ModelPricing {
9    /// USD per 1M input tokens.
10    pub input_per_million: f64,
11    /// USD per 1M output tokens.
12    pub output_per_million: f64,
13}
14
15impl ModelPricing {
16    pub const fn new(input: f64, output: f64) -> Self {
17        Self {
18            input_per_million: input,
19            output_per_million: output,
20        }
21    }
22
23    /// Compute the cost of a single request in USD.
24    pub fn cost_usd(&self, input_tokens: u64, output_tokens: u64) -> f64 {
25        (input_tokens as f64 / 1_000_000.0) * self.input_per_million
26            + (output_tokens as f64 / 1_000_000.0) * self.output_per_million
27    }
28}
29
30/// Backed by `pricing.json` at deploy time; the canonical table is hard-coded
31/// so the build stays self-contained.
32#[derive(Debug, Clone, Default, Serialize, Deserialize)]
33pub struct PricingTable {
34    pub models: HashMap<String, ModelPricing>,
35}
36
37impl PricingTable {
38    pub fn canonical() -> Self {
39        let mut models = HashMap::new();
40
41        // Anthropic
42        models.insert("claude-opus-4-7".into(), ModelPricing::new(5.00, 25.00));
43        models.insert("claude-sonnet-4-6".into(), ModelPricing::new(3.00, 15.00));
44        models.insert("claude-haiku-4-5".into(), ModelPricing::new(1.00, 5.00));
45
46        // OpenAI
47        models.insert("gpt-5".into(), ModelPricing::new(1.25, 10.00));
48        models.insert("gpt-5.4".into(), ModelPricing::new(2.50, 15.00));
49        models.insert("gpt-4o-mini".into(), ModelPricing::new(0.15, 0.60));
50
51        // Google
52        models.insert("gemini-2.5-pro".into(), ModelPricing::new(1.00, 10.00));
53        models.insert("gemini-2.5-flash".into(), ModelPricing::new(0.30, 2.50));
54
55        // DeepSeek
56        models.insert("deepseek-r1".into(), ModelPricing::new(0.29, 0.29));
57
58        // Inference-as-a-service
59        models.insert("cerebras-llama3.1-8b".into(), ModelPricing::new(0.10, 0.10));
60        models.insert(
61            "deepinfra-llama3.1-8b".into(),
62            ModelPricing::new(0.03, 0.05),
63        );
64
65        Self { models }
66    }
67
68    pub fn get(&self, model: &str) -> Option<&ModelPricing> {
69        self.models.get(model)
70    }
71
72    /// True when this model executes on the operator's own hardware.
73    ///
74    /// Ollama Cloud tags are not free: they look local (same daemon, same API,
75    /// an `ollama:` prefix) but generate tokens on a paid account. See
76    /// [`Self::is_ollama_cloud`].
77    pub fn is_free(model: &str) -> bool {
78        if Self::is_ollama_cloud(model) {
79            return false;
80        }
81        model.starts_with("ollama:") || model == "local"
82    }
83
84    /// True for an Ollama model whose tag ends in `-cloud`, which executes
85    /// remotely on a paid account.
86    ///
87    /// Matching is on the tag (after the last `:`), so a local model merely
88    /// named `cloudy-llama:7b` is unaffected. Case-insensitive, as Ollama tags
89    /// are.
90    pub fn is_ollama_cloud(model: &str) -> bool {
91        let stripped = model.strip_prefix("ollama:").unwrap_or(model);
92        let tag = stripped.rsplit(':').next().unwrap_or_default();
93        tag.len() > "-cloud".len() && tag.to_ascii_lowercase().ends_with("-cloud")
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn opus_pricing_matches_arch_doc() {
103        let p = PricingTable::canonical();
104        let opus = p.get("claude-opus-4-7").expect("opus 4.7 present");
105        assert!((opus.input_per_million - 5.00).abs() < 1e-9);
106        assert!((opus.output_per_million - 25.00).abs() < 1e-9);
107    }
108
109    #[test]
110    fn cost_calculation_is_correct() {
111        // $5/M input + $25/M output over 1k + 500 tokens.
112        let p = ModelPricing::new(5.00, 25.00);
113        let cost = p.cost_usd(1_000, 500);
114        assert!((cost - 0.0175).abs() < 1e-9);
115    }
116
117    #[test]
118    fn ollama_models_are_free() {
119        assert!(PricingTable::is_free("ollama:llama3.2"));
120        assert!(PricingTable::is_free("local"));
121        assert!(!PricingTable::is_free("gpt-5"));
122    }
123
124    #[test]
125    fn ollama_cloud_tags_are_not_free() {
126        for m in [
127            "gpt-oss:20b-cloud",
128            "ollama:gpt-oss:20b-cloud",
129            "ollama:deepseek-v3.1:671b-cloud",
130            "ollama:qwen3-coder:480b-cloud",
131            "GPT-OSS:120B-CLOUD",
132        ] {
133            assert!(PricingTable::is_ollama_cloud(m), "{m} must be cloud");
134            assert!(!PricingTable::is_free(m), "{m} must not be free");
135        }
136    }
137
138    #[test]
139    fn local_models_named_cloud_are_still_free() {
140        for m in [
141            "ollama:llama3.2",
142            "ollama:qwen2.5:7b",
143            "ollama:cloudy-llama:7b",
144            "ollama:nimbus-cloud-chat:7b",
145            "ollama:cloud",
146            "local",
147        ] {
148            assert!(!PricingTable::is_ollama_cloud(m), "{m} must not be cloud");
149        }
150        assert!(PricingTable::is_free("ollama:cloudy-llama:7b"));
151        assert!(PricingTable::is_free("ollama:qwen2.5:7b"));
152    }
153
154    #[test]
155    fn bare_cloud_tag_is_not_treated_as_cloud() {
156        assert!(!PricingTable::is_ollama_cloud("ollama:model:-cloud"));
157    }
158}