Skip to main content

skopos_pricing/
lib.rs

1//! Pricing catalog for the providers Skopos tracks.
2//!
3//! A built-in [`Catalog::defaults`] ships with the per-million-token rates
4//! Anthropic, OpenAI and Google publish for the models that currently
5//! appear in `skopos.db`. The catalog can be overlaid at runtime from a
6//! TOML file (default path: `~/.config/skopos/pricing.toml`) so the user
7//! can refresh prices without recompiling.
8//!
9//! The expected TOML shape is one `[[model]]` table per entry:
10//!
11//! ```toml
12//! [[model]]
13//! provider = "anthropic"
14//! model = "claude-opus-4-7"
15//! input_per_million = 5.0
16//! output_per_million = 25.0
17//! cached_input_per_million = 0.50
18//! ```
19
20use 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    /// Tokens are passed as Skopos stores them: `input_tokens` is the
38    /// *uncached* portion (collectors already subtract `cached` from the
39    /// vendor's gross input), and `cached_input_tokens` is the cached
40    /// portion. Output is straightforward.
41    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    /// Built-in price table. Last validated against vendor docs on
72    /// 2026-05-19. Cached rate uses the cache-read tier (0.1× base for
73    /// Claude, ditto for gpt-5.5 and Gemini) because cache reads
74    /// dominate the agentic-CLI workload Skopos observes.
75    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    /// Defaults overlaid with prices from `path`. A missing file leaves
105    /// the catalog at defaults; an unparseable file is a hard error so
106    /// silent typos don't quietly fall back to stale numbers.
107    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
122/// Default location for the user-editable override file.
123pub 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        // Hermes-routed traffic. Hermes is multi-billing-route (openai-codex,
161        // copilot, direct API…) and the effective price the user pays depends
162        // on which subscription was active. The defaults below mirror the
163        // native API pricing of each underlying model so `est cost` stops
164        // reading $0 — users on flat-fee routes (e.g. Copilot) should override
165        // these entries in ~/.config/skopos/pricing.toml.
166        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        // No native Gemini 3.1 Pro entry yet; this estimate tracks Google's
188        // published Gemini Pro tier (~2.5× Flash). Override locally once
189        // Google publishes the final 3.1 Pro pricing.
190        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        // 1M uncached input × $1 + 500k output × $2 + 100k cached × $0.25
215        // = 1.00 + 1.00 + 0.025 = 2.025
216        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        // Hermes routes — model names differ slightly from the native
230        // catalog (e.g. claude-haiku-4.5 vs claude-haiku-4-5-20251001),
231        // so they need their own entries even though the prices match.
232        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}