tokenmiser_config/
pricing.rs1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
8pub struct ModelPricing {
9 pub input_per_million: f64,
11 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 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#[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 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 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 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 models.insert("deepseek-r1".into(), ModelPricing::new(0.29, 0.29));
57
58 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 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 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 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}