1use std::collections::HashMap;
21use std::fs;
22use std::path::{Path, PathBuf};
23
24use serde::{Deserialize, Serialize};
25use skopos_core::Money;
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct ModelPrice {
29 pub provider: String,
30 pub model: String,
31 pub input_per_million: f64,
32 pub output_per_million: f64,
33 pub cached_input_per_million: Option<f64>,
34}
35
36impl ModelPrice {
37 pub fn estimate_usd(
42 &self,
43 input_tokens: u64,
44 output_tokens: u64,
45 cached_input_tokens: Option<u64>,
46 ) -> Money {
47 let cached = cached_input_tokens.unwrap_or(0);
48 let input_cost = input_tokens as f64 / 1_000_000.0 * self.input_per_million;
49 let output_cost = output_tokens as f64 / 1_000_000.0 * self.output_per_million;
50 let cached_cost = cached as f64 / 1_000_000.0
51 * self
52 .cached_input_per_million
53 .unwrap_or(self.input_per_million);
54
55 Money::usd(input_cost + output_cost + cached_cost)
56 }
57}
58
59#[derive(Debug, Default, Deserialize)]
60struct PricingFile {
61 #[serde(default)]
62 model: Vec<ModelPrice>,
63}
64
65#[derive(Debug, Clone, Default)]
66pub struct Catalog {
67 by_key: HashMap<(String, String), ModelPrice>,
68}
69
70impl Catalog {
71 pub fn defaults() -> Self {
76 let mut catalog = Self::default();
77 for price in default_prices() {
78 catalog.insert(price);
79 }
80 catalog
81 }
82
83 pub fn insert(&mut self, price: ModelPrice) {
84 let key = (price.provider.clone(), price.model.clone());
85 self.by_key.insert(key, price);
86 }
87
88 pub fn price(&self, provider: &str, model: &str) -> Option<&ModelPrice> {
89 self.by_key.get(&(provider.to_string(), model.to_string()))
90 }
91
92 pub fn estimate(
93 &self,
94 provider: &str,
95 model: &str,
96 input_tokens: u64,
97 cached_input_tokens: Option<u64>,
98 output_tokens: u64,
99 ) -> Option<Money> {
100 self.price(provider, model)
101 .map(|p| p.estimate_usd(input_tokens, output_tokens, cached_input_tokens))
102 }
103
104 pub fn load_with_overrides(path: &Path) -> anyhow::Result<Self> {
108 let mut catalog = Self::defaults();
109 if !path.exists() {
110 return Ok(catalog);
111 }
112 let raw = fs::read_to_string(path)?;
113 let parsed: PricingFile = toml::from_str(&raw)
114 .map_err(|err| anyhow::anyhow!("failed to parse {}: {err}", path.display()))?;
115 for price in parsed.model {
116 catalog.insert(price);
117 }
118 Ok(catalog)
119 }
120}
121
122pub fn default_overrides_path() -> PathBuf {
124 let home = std::env::var_os("HOME")
125 .map(PathBuf::from)
126 .unwrap_or_else(|| PathBuf::from("."));
127 home.join(".config").join("skopos").join("pricing.toml")
128}
129
130fn default_prices() -> Vec<ModelPrice> {
131 vec![
132 ModelPrice {
133 provider: "anthropic".to_string(),
134 model: "claude-opus-4-7".to_string(),
135 input_per_million: 5.0,
136 output_per_million: 25.0,
137 cached_input_per_million: Some(0.50),
138 },
139 ModelPrice {
140 provider: "anthropic".to_string(),
141 model: "claude-haiku-4-5-20251001".to_string(),
142 input_per_million: 1.0,
143 output_per_million: 5.0,
144 cached_input_per_million: Some(0.10),
145 },
146 ModelPrice {
147 provider: "openai".to_string(),
148 model: "gpt-5.5".to_string(),
149 input_per_million: 5.0,
150 output_per_million: 30.0,
151 cached_input_per_million: Some(0.50),
152 },
153 ModelPrice {
154 provider: "google".to_string(),
155 model: "gemini-3-flash-preview".to_string(),
156 input_per_million: 0.50,
157 output_per_million: 3.0,
158 cached_input_per_million: Some(0.05),
159 },
160 ModelPrice {
167 provider: "hermes".to_string(),
168 model: "gpt-5.5".to_string(),
169 input_per_million: 5.0,
170 output_per_million: 30.0,
171 cached_input_per_million: Some(0.50),
172 },
173 ModelPrice {
174 provider: "hermes".to_string(),
175 model: "claude-haiku-4.5".to_string(),
176 input_per_million: 1.0,
177 output_per_million: 5.0,
178 cached_input_per_million: Some(0.10),
179 },
180 ModelPrice {
181 provider: "hermes".to_string(),
182 model: "gemini-3-flash-preview".to_string(),
183 input_per_million: 0.50,
184 output_per_million: 3.0,
185 cached_input_per_million: Some(0.05),
186 },
187 ModelPrice {
191 provider: "hermes".to_string(),
192 model: "gemini-3.1-pro-preview".to_string(),
193 input_per_million: 1.25,
194 output_per_million: 5.0,
195 cached_input_per_million: Some(0.125),
196 },
197 ]
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn estimates_usd_from_token_counts() {
206 let price = ModelPrice {
207 provider: "example".to_string(),
208 model: "model".to_string(),
209 input_per_million: 1.0,
210 output_per_million: 2.0,
211 cached_input_per_million: Some(0.25),
212 };
213
214 let cost = price.estimate_usd(1_000_000, 500_000, Some(100_000));
217 assert!((cost.amount - 2.025).abs() < 1e-9);
218 }
219
220 #[test]
221 fn catalog_defaults_cover_known_models() {
222 let catalog = Catalog::defaults();
223 assert!(catalog.price("anthropic", "claude-opus-4-7").is_some());
224 assert!(catalog
225 .price("anthropic", "claude-haiku-4-5-20251001")
226 .is_some());
227 assert!(catalog.price("openai", "gpt-5.5").is_some());
228 assert!(catalog.price("google", "gemini-3-flash-preview").is_some());
229 assert!(catalog.price("hermes", "gpt-5.5").is_some());
233 assert!(catalog.price("hermes", "claude-haiku-4.5").is_some());
234 assert!(catalog.price("hermes", "gemini-3-flash-preview").is_some());
235 assert!(catalog.price("hermes", "gemini-3.1-pro-preview").is_some());
236 assert!(catalog.price("openai", "ghost-model-9000").is_none());
237 }
238
239 #[test]
240 fn override_file_replaces_default_entry() {
241 let dir =
242 std::env::temp_dir().join(format!("skopos-pricing-override-{}", std::process::id()));
243 let _ = std::fs::remove_dir_all(&dir);
244 std::fs::create_dir_all(&dir).unwrap();
245 let path = dir.join("pricing.toml");
246 std::fs::write(
247 &path,
248 r#"
249[[model]]
250provider = "anthropic"
251model = "claude-opus-4-7"
252input_per_million = 99.0
253output_per_million = 199.0
254cached_input_per_million = 9.0
255"#,
256 )
257 .unwrap();
258
259 let catalog = Catalog::load_with_overrides(&path).unwrap();
260 let price = catalog.price("anthropic", "claude-opus-4-7").unwrap();
261 assert_eq!(price.input_per_million, 99.0);
262 assert_eq!(price.output_per_million, 199.0);
263 assert_eq!(price.cached_input_per_million, Some(9.0));
264
265 assert!(catalog.price("openai", "gpt-5.5").is_some());
266 }
267
268 #[test]
269 fn missing_override_file_returns_defaults() {
270 let path = std::env::temp_dir().join("skopos-pricing-does-not-exist.toml");
271 let _ = std::fs::remove_file(&path);
272 let catalog = Catalog::load_with_overrides(&path).unwrap();
273 assert!(catalog.price("anthropic", "claude-opus-4-7").is_some());
274 }
275
276 #[test]
277 fn estimate_returns_none_for_unknown_model() {
278 let catalog = Catalog::defaults();
279 assert!(catalog
280 .estimate("openai", "ghost-model-9000", 1_000_000, None, 1_000_000)
281 .is_none());
282 }
283}