Skip to main content

newt_core/
pricing.rs

1//! Cost estimation for inference backends.
2//!
3//! `PricingConfig` is stored under `[pricing]` in `newt.toml`. It carries
4//! user overrides and is consulted together with a built-in rate table to
5//! produce best-effort USD cost estimates for each inference turn.
6//!
7//! Local models (Ollama-served gemma, llama, qwen, mistral, …) return a
8//! cost of `0.0` so the TUI can display "free (local)".
9//! Unknown models return `None` — the TUI shows nothing.
10
11use std::collections::HashMap;
12
13use serde::{Deserialize, Serialize};
14
15use crate::metrics::TokenUsage;
16
17/// Per-model pricing rate.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ModelRate {
20    /// USD per 1 000 input tokens.
21    pub input_usd_per_1k: f64,
22    /// USD per 1 000 output tokens.
23    pub output_usd_per_1k: f64,
24}
25
26impl ModelRate {
27    pub fn cost(&self, usage: &TokenUsage) -> f64 {
28        let input_cost = (usage.input_tokens as f64 / 1000.0) * self.input_usd_per_1k;
29        let output_cost = (usage.output_tokens as f64 / 1000.0) * self.output_usd_per_1k;
30        input_cost + output_cost
31    }
32}
33
34/// Pricing configuration stored under `[pricing]` in `newt.toml`.
35#[derive(Debug, Clone, Default, Serialize, Deserialize)]
36#[serde(default)]
37pub struct PricingConfig {
38    /// Per-model overrides. Keys are exact model-id strings or prefix patterns.
39    /// Example: `{ "my-private-gpt4" = { input_usd_per_1k = 0.01, output_usd_per_1k = 0.03 } }`
40    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
41    pub overrides: HashMap<String, ModelRate>,
42}
43
44impl PricingConfig {
45    /// Look up the rate for `model_id`.
46    ///
47    /// Resolution order:
48    /// 1. Exact match in `overrides`
49    /// 2. Prefix match in `overrides` (longest prefix wins)
50    /// 3. Built-in rate table
51    /// 4. `None` — unknown model; caller should not display a cost
52    ///
53    /// Returns `Some(ModelRate { input: 0.0, output: 0.0 })` for known local
54    /// models so the display can show "free (local)".
55    pub fn rate_for(&self, model_id: &str) -> Option<ModelRate> {
56        // 1. Exact override.
57        if let Some(r) = self.overrides.get(model_id) {
58            return Some(r.clone());
59        }
60
61        // 2. Longest prefix override.
62        let prefix_match = self
63            .overrides
64            .iter()
65            .filter(|(k, _)| model_id.starts_with(k.as_str()))
66            .max_by_key(|(k, _)| k.len());
67        if let Some((_, r)) = prefix_match {
68            return Some(r.clone());
69        }
70
71        // 3. Built-in table.
72        builtin_rate(model_id)
73    }
74
75    /// Estimate cost for `usage` with `model_id`.
76    /// Returns `None` when the model is unknown (not local, not in table, not overridden).
77    pub fn estimate_cost(&self, model_id: &str, usage: Option<&TokenUsage>) -> Option<f64> {
78        let usage = usage?;
79        let rate = self.rate_for(model_id)?;
80        Some(rate.cost(usage))
81    }
82}
83
84// ---------------------------------------------------------------------------
85// Built-in rate table
86// ---------------------------------------------------------------------------
87//
88// Rates are approximate and updated periodically. Users can override any
89// entry via `[pricing.overrides]` in `newt.toml`.
90// All rates are USD per 1 000 tokens (input / output).
91
92fn builtin_rate(model_id: &str) -> Option<ModelRate> {
93    let m = model_id.to_lowercase();
94
95    // --- Known local model families → free ---
96    for prefix in &[
97        "gemma",
98        "llama",
99        "qwen",
100        "mistral",
101        "phi",
102        "orca",
103        "codellama",
104        "deepseek",
105        "starcoder",
106        "falcon",
107        "vicuna",
108        "neural-chat",
109        "openhermes",
110        "nous-hermes",
111        "dolphin",
112        "bakllava",
113        "llava",
114        "yi:",
115        "yi-",
116        "zephyr",
117        "tinyllama",
118    ] {
119        if m.starts_with(prefix) {
120            return Some(ModelRate {
121                input_usd_per_1k: 0.0,
122                output_usd_per_1k: 0.0,
123            });
124        }
125    }
126
127    // --- OpenAI ---
128    if m.starts_with("gpt-4o-mini") {
129        return Some(ModelRate {
130            input_usd_per_1k: 0.00015,
131            output_usd_per_1k: 0.0006,
132        });
133    }
134    if m.starts_with("gpt-4o") {
135        return Some(ModelRate {
136            input_usd_per_1k: 0.005,
137            output_usd_per_1k: 0.015,
138        });
139    }
140    if m.starts_with("gpt-4-turbo") || m.starts_with("gpt-4") {
141        return Some(ModelRate {
142            input_usd_per_1k: 0.01,
143            output_usd_per_1k: 0.03,
144        });
145    }
146    if m.starts_with("gpt-3.5-turbo") {
147        return Some(ModelRate {
148            input_usd_per_1k: 0.0005,
149            output_usd_per_1k: 0.0015,
150        });
151    }
152    if m.starts_with("o1-mini") {
153        return Some(ModelRate {
154            input_usd_per_1k: 0.003,
155            output_usd_per_1k: 0.012,
156        });
157    }
158    if m.starts_with("o1") {
159        return Some(ModelRate {
160            input_usd_per_1k: 0.015,
161            output_usd_per_1k: 0.06,
162        });
163    }
164
165    // --- Anthropic ---
166    if m.contains("claude-3-5-sonnet") || m.contains("claude-3.5-sonnet") {
167        return Some(ModelRate {
168            input_usd_per_1k: 0.003,
169            output_usd_per_1k: 0.015,
170        });
171    }
172    if m.contains("claude-3-5-haiku") || m.contains("claude-3.5-haiku") {
173        return Some(ModelRate {
174            input_usd_per_1k: 0.0008,
175            output_usd_per_1k: 0.004,
176        });
177    }
178    if m.contains("claude-3-opus") {
179        return Some(ModelRate {
180            input_usd_per_1k: 0.015,
181            output_usd_per_1k: 0.075,
182        });
183    }
184    if m.contains("claude-3-sonnet") {
185        return Some(ModelRate {
186            input_usd_per_1k: 0.003,
187            output_usd_per_1k: 0.015,
188        });
189    }
190    if m.contains("claude-3-haiku") {
191        return Some(ModelRate {
192            input_usd_per_1k: 0.00025,
193            output_usd_per_1k: 0.00125,
194        });
195    }
196    if m.contains("claude-2") {
197        return Some(ModelRate {
198            input_usd_per_1k: 0.008,
199            output_usd_per_1k: 0.024,
200        });
201    }
202
203    // --- Google Gemini (API, not local Ollama) ---
204    if m.contains("gemini-1.5-pro") {
205        return Some(ModelRate {
206            input_usd_per_1k: 0.0035,
207            output_usd_per_1k: 0.0105,
208        });
209    }
210    if m.contains("gemini-1.5-flash") {
211        return Some(ModelRate {
212            input_usd_per_1k: 0.000075,
213            output_usd_per_1k: 0.0003,
214        });
215    }
216
217    // Unknown — return None so callers display nothing.
218    None
219}
220
221// ---------------------------------------------------------------------------
222// Tests
223// ---------------------------------------------------------------------------
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn usage(inp: u32, out: u32) -> TokenUsage {
230        TokenUsage {
231            input_tokens: inp,
232            output_tokens: out,
233        }
234    }
235
236    #[test]
237    fn local_models_are_free() {
238        let cfg = PricingConfig::default();
239        for model in &[
240            "gemma4:e2b",
241            "llama3.1:8b",
242            "qwen2.5-coder:32b",
243            "mistral:7b",
244        ] {
245            let cost = cfg.estimate_cost(model, Some(&usage(1000, 500)));
246            assert_eq!(cost, Some(0.0), "expected free for {model}");
247        }
248    }
249
250    #[test]
251    fn gpt4o_has_cost() {
252        let cfg = PricingConfig::default();
253        let cost = cfg.estimate_cost("gpt-4o", Some(&usage(1000, 500)));
254        assert!(cost.is_some(), "gpt-4o should have a rate");
255        assert!(cost.unwrap() > 0.0);
256    }
257
258    #[test]
259    fn unknown_model_returns_none() {
260        let cfg = PricingConfig::default();
261        let cost = cfg.estimate_cost("some-private-model-v99", Some(&usage(100, 50)));
262        assert!(cost.is_none(), "unknown model should return None");
263    }
264
265    #[test]
266    fn no_usage_returns_none() {
267        let cfg = PricingConfig::default();
268        assert!(cfg.estimate_cost("gpt-4o", None).is_none());
269    }
270
271    #[test]
272    fn override_takes_precedence() {
273        let mut cfg = PricingConfig::default();
274        cfg.overrides.insert(
275            "gemma4:e2b".into(),
276            ModelRate {
277                input_usd_per_1k: 1.0,
278                output_usd_per_1k: 2.0,
279            },
280        );
281        let cost = cfg.estimate_cost("gemma4:e2b", Some(&usage(1000, 1000)));
282        // 1.0 * 1 + 2.0 * 1 = 3.0
283        assert!((cost.unwrap() - 3.0).abs() < 0.0001);
284    }
285
286    #[test]
287    fn prefix_override() {
288        let mut cfg = PricingConfig::default();
289        cfg.overrides.insert(
290            "my-model".into(),
291            ModelRate {
292                input_usd_per_1k: 0.5,
293                output_usd_per_1k: 1.0,
294            },
295        );
296        let cost = cfg.estimate_cost("my-model-v2", Some(&usage(2000, 1000)));
297        // 0.5 * 2 + 1.0 * 1 = 2.0
298        assert!((cost.unwrap() - 2.0).abs() < 0.0001);
299    }
300
301    #[test]
302    fn claude_models_have_rates() {
303        let cfg = PricingConfig::default();
304        for m in &[
305            "claude-3-5-sonnet-20241022",
306            "claude-3-haiku-20240307",
307            "claude-3-opus-20240229",
308        ] {
309            assert!(
310                cfg.estimate_cost(m, Some(&usage(100, 50))).unwrap() > 0.0,
311                "{m} should have a positive rate"
312            );
313        }
314    }
315
316    #[test]
317    fn pricing_config_toml_roundtrip() {
318        let mut cfg = PricingConfig::default();
319        cfg.overrides.insert(
320            "test-model".into(),
321            ModelRate {
322                input_usd_per_1k: 0.01,
323                output_usd_per_1k: 0.02,
324            },
325        );
326        let s = toml::to_string(&cfg).unwrap();
327        let back: PricingConfig = toml::from_str(&s).unwrap();
328        assert!(back.overrides.contains_key("test-model"));
329    }
330}