Skip to main content

vtcode_llm/
usage_cost.rs

1//! Provider-normalized token usage and shared raw/cache-aware USD estimates.
2
3use crate::model_resolver::ModelResolver;
4use crate::provider::Usage as ProviderUsage;
5use vtcode_config::models::ModelPricing;
6
7/// Returns true when `provider` reports `prompt_tokens` exclusive of
8/// cache-read and cache-creation tokens.
9///
10/// Anthropic and Minimax (which wraps the Anthropic provider) report
11/// `prompt_tokens` as the count of tokens billed at the full input rate,
12/// separate from cache-read and cache-creation tokens. All other providers
13/// report `prompt_tokens` as a total that already includes cached tokens, so
14/// no adjustment is needed for them.
15pub fn provider_reports_exclusive_input(provider: &str) -> bool {
16    matches!(provider.trim().to_ascii_lowercase().as_str(), "anthropic" | "minimax")
17}
18
19/// Build a per-turn harness `Usage` sample from raw provider usage, applying
20/// the provider-specific normalization documented on
21/// [`provider_reports_exclusive_input`] so `input_tokens` always represents
22/// the total prompt token count across every provider.
23pub fn normalized_turn_usage(provider: &str, usage: &ProviderUsage) -> vtcode_exec_events::Usage {
24    let totals = usage.billable_totals();
25    let cached = u64::from(totals.cache_read_tokens);
26    let creation = u64::from(totals.cache_creation_tokens);
27    let mut input = u64::from(totals.prompt_tokens);
28    if provider_reports_exclusive_input(provider) {
29        input = input.saturating_add(cached).saturating_add(creation);
30    }
31    let output = u64::from(totals.completion_tokens);
32
33    vtcode_exec_events::Usage {
34        input_tokens: input,
35        cached_input_tokens: cached,
36        cache_creation_tokens: creation,
37        output_tokens: output,
38    }
39}
40
41/// Cache-aware and conservative session cost estimates in USD.
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct SessionCostEstimate {
44    /// Every input token priced at the full input rate, with no cache
45    /// discount applied. This is the conservative, deterministic figure used
46    /// for budget enforcement.
47    pub raw_usd: f64,
48    /// Cache-aware estimate that discounts cache-read tokens and surcharges
49    /// cache-creation tokens, for transparency in user-facing reporting.
50    pub effective_usd: f64,
51}
52
53/// Base price for the bounded, standard-endpoint Decisions probe, not generation.
54/// OpenAI charges $0.10 per million input tokens and no output/cache surcharge.
55/// Regional and long-context premiums are outside this bounded probe route.
56pub fn estimate_decisions_cost(usage: &ProviderUsage) -> SessionCostEstimate {
57    let cost = f64::from(usage.prompt_tokens) * 0.10 / 1_000_000.0;
58    SessionCostEstimate { raw_usd: cost, effective_usd: cost }
59}
60
61/// Accumulates independently priced turns without repricing earlier model routes.
62/// Once a turn cannot be priced, a complete session total remains unknown.
63#[derive(Debug, Clone)]
64pub struct SessionCostAccumulator {
65    total: Option<SessionCostEstimate>,
66}
67
68impl Default for SessionCostAccumulator {
69    fn default() -> Self {
70        Self {
71            total: Some(SessionCostEstimate { raw_usd: 0.0, effective_usd: 0.0 }),
72        }
73    }
74}
75
76impl SessionCostAccumulator {
77    pub fn record(&mut self, estimate: Option<SessionCostEstimate>) -> Option<SessionCostEstimate> {
78        self.total = self.total.zip(estimate).and_then(|(total, turn)| {
79            let raw_usd = total.raw_usd + turn.raw_usd;
80            let effective_usd = total.effective_usd + turn.effective_usd;
81            (raw_usd.is_finite() && effective_usd.is_finite()).then_some(SessionCostEstimate { raw_usd, effective_usd })
82        });
83        self.total
84    }
85
86    pub fn total(&self) -> Option<SessionCostEstimate> {
87        self.total
88    }
89}
90
91/// Resolve pricing for `provider`/`model` and estimate session costs from
92/// accumulated harness usage. Returns `None` when the model cannot be
93/// resolved or pricing metadata is unavailable.
94pub fn estimate_session_costs(
95    provider: &str,
96    model: &str,
97    usage: &vtcode_exec_events::Usage,
98) -> Option<SessionCostEstimate> {
99    let resolved = ModelResolver::resolve(Some(provider), model, &[], None)?;
100    let pricing = resolved.pricing()?;
101    estimate_session_costs_with_pricing(pricing, usage)
102}
103
104/// Reject a priced session budget when its selected route cannot be priced.
105/// Call before any inference, including automatic compaction.
106pub fn require_budget_pricing(provider: &str, model: &str, max_budget_usd: Option<f64>) -> anyhow::Result<()> {
107    if let Some(maximum) = max_budget_usd {
108        anyhow::ensure!(maximum.is_finite() && maximum >= 0.0, "Session USD budget must be finite and non-negative");
109        anyhow::ensure!(
110            estimate_session_costs(provider, model, &vtcode_exec_events::Usage::default()).is_some(),
111            "Cannot enforce session USD budget for `{provider}/{model}`: complete valid pricing metadata is unavailable"
112        );
113    }
114    Ok(())
115}
116
117/// Estimate session costs from an already-resolved [`ModelPricing`].
118///
119/// `effective_usd` can exceed `raw_usd` early in a session when
120/// cache-creation tokens (billed at a premium) dominate the accumulated
121/// usage. `raw_usd` remains the enforcement figure so budget behavior stays
122/// deterministic and discount-free.
123pub fn estimate_session_costs_with_pricing(
124    pricing: ModelPricing,
125    usage: &vtcode_exec_events::Usage,
126) -> Option<SessionCostEstimate> {
127    let input_rate = pricing.input?;
128    let output_rate = pricing.output?;
129    if [pricing.input, pricing.output, pricing.cache_read, pricing.cache_write]
130        .into_iter()
131        .flatten()
132        .any(|rate| !rate.is_finite() || rate < 0.0)
133    {
134        return None;
135    }
136
137    let input_tokens = usage.input_tokens as f64;
138    let output_tokens = usage.output_tokens as f64;
139    let cached_tokens = usage.cached_input_tokens as f64;
140    let creation_tokens = usage.cache_creation_tokens as f64;
141
142    let raw_usd = input_tokens * input_rate + output_tokens * output_rate;
143
144    // Heuristic fallbacks when a model's catalog entry does not specify
145    // dedicated cache rates: cache reads are assumed to cost roughly 10% of
146    // the input rate, and cache writes roughly 125% of the input rate
147    // (2x for Anthropic 1h extended TTL).
148    let read_rate = pricing.cache_read.unwrap_or(input_rate * 0.10);
149    let write_rate = pricing.cache_write.unwrap_or(input_rate * DEFAULT_CACHE_WRITE_MULTIPLIER);
150
151    let uncached_tokens = usage
152        .input_tokens
153        .saturating_sub(usage.cached_input_tokens)
154        .saturating_sub(usage.cache_creation_tokens) as f64;
155
156    let effective_usd = uncached_tokens * input_rate
157        + cached_tokens * read_rate
158        + creation_tokens * write_rate
159        + output_tokens * output_rate;
160
161    (raw_usd.is_finite() && effective_usd.is_finite()).then_some(SessionCostEstimate { raw_usd, effective_usd })
162}
163
164/// Default cache-write multiplier versus uncached input (5m TTL).
165pub const DEFAULT_CACHE_WRITE_MULTIPLIER: f64 = 1.25;
166
167/// Anthropic 1h extended-TTL cache writes cost 2x base input.
168pub const EXTENDED_TTL_CACHE_WRITE_MULTIPLIER: f64 = 2.0;
169
170/// Effective cache-write rate. `extended_ttl` selects the 2x 1h multiplier
171/// when the catalog does not declare an explicit write rate.
172#[must_use]
173pub fn cache_write_rate(input_rate: f64, configured: Option<f64>, extended_ttl: bool) -> f64 {
174    if let Some(rate) = configured {
175        return rate;
176    }
177    let multiplier = if extended_ttl {
178        EXTENDED_TTL_CACHE_WRITE_MULTIPLIER
179    } else {
180        DEFAULT_CACHE_WRITE_MULTIPLIER
181    };
182    input_rate * multiplier
183}
184
185/// Prompt volume that counts toward provider TPM-style rate limits. Cached
186/// and cache-write tokens are cheaper but still consume provider capacity.
187#[must_use]
188pub fn prompt_tokens_for_rate_limit(usage: &vtcode_exec_events::Usage) -> u64 {
189    let uncached = usage
190        .input_tokens
191        .saturating_sub(usage.cached_input_tokens)
192        .saturating_sub(usage.cache_creation_tokens);
193    uncached
194        .saturating_add(usage.cached_input_tokens)
195        .saturating_add(usage.cache_creation_tokens)
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn cost_normalization_matches_across_three_provider_families() {
204        let pricing = ModelPricing {
205            input: Some(0.01),
206            output: Some(0.02),
207            cache_read: Some(0.001),
208            cache_write: Some(0.0125),
209        };
210        for provider in ["openai", "anthropic", "gemini"] {
211            let usage = ProviderUsage {
212                prompt_tokens: if provider == "anthropic" { 150 } else { 1000 },
213                completion_tokens: 100,
214                total_tokens: 1100,
215                cached_prompt_tokens: None,
216                cache_read_tokens: Some(800),
217                cache_creation_tokens: Some(50),
218                iterations: None,
219            };
220            let normalized = normalized_turn_usage(provider, &usage);
221            let cost = estimate_session_costs_with_pricing(pricing, &normalized).expect("priced");
222            assert!((cost.raw_usd - 12.0).abs() < 1e-12, "{provider}");
223            assert!((cost.effective_usd - 4.925).abs() < 1e-12, "{provider}");
224        }
225    }
226
227    #[test]
228    fn anthropic_compaction_iterations_are_included_in_normalized_usage() {
229        let usage = ProviderUsage {
230            // These top-level values describe only the final message pass.
231            prompt_tokens: 10,
232            completion_tokens: 1,
233            total_tokens: 11,
234            cached_prompt_tokens: None,
235            cache_read_tokens: None,
236            cache_creation_tokens: None,
237            iterations: Some(vec![
238                serde_json::json!({
239                    "type": "compaction",
240                    "input_tokens": 50,
241                    "output_tokens": 5,
242                }),
243                serde_json::json!({
244                    "type": "message",
245                    "input_tokens": 10,
246                    "output_tokens": 2,
247                }),
248            ]),
249        };
250
251        let normalized = normalized_turn_usage("anthropic", &usage);
252        assert_eq!(normalized.input_tokens, 60);
253        assert_eq!(normalized.output_tokens, 7);
254    }
255
256    #[test]
257    fn astra_pricing_is_resolved_per_route_without_inference() {
258        let usage = vtcode_exec_events::Usage {
259            input_tokens: 1000,
260            output_tokens: 100,
261            cached_input_tokens: 800,
262            cache_creation_tokens: 0,
263        };
264        for (provider, model, priced) in [
265            ("openai", "gpt-6-astra", true),
266            ("openrouter", "openai/gpt-6-astra", true),
267            ("merge-gateway", "openai/gpt-6-astra", false),
268        ] {
269            assert_eq!(estimate_session_costs(provider, model, &usage).is_some(), priced, "{provider}");
270            assert_eq!(require_budget_pricing(provider, model, Some(1.0)).is_ok(), priced, "{provider}");
271        }
272    }
273
274    #[test]
275    fn switching_to_a_cheaper_model_never_reprices_previous_spend() {
276        let usage = vtcode_exec_events::Usage { input_tokens: 100, ..Default::default() };
277        let mut session = SessionCostAccumulator::default();
278        for (rate, expected) in [(0.10, 10.0), (0.001, 10.1), (0.10, 20.1)] {
279            let pricing = ModelPricing {
280                input: Some(rate),
281                output: Some(rate),
282                cache_read: None,
283                cache_write: None,
284            };
285            let total = session
286                .record(estimate_session_costs_with_pricing(pricing, &usage))
287                .expect("priced");
288            assert!((total.raw_usd - expected).abs() < 1e-12);
289            assert!((total.effective_usd - expected).abs() < 1e-12);
290        }
291    }
292
293    #[test]
294    fn an_unpriced_turn_keeps_session_cost_unknown() {
295        let mut session = SessionCostAccumulator::default();
296        assert!(session.record(None).is_none());
297        assert!(
298            session
299                .record(Some(SessionCostEstimate { raw_usd: 1.0, effective_usd: 1.0 }))
300                .is_none()
301        );
302    }
303
304    #[test]
305    fn missing_pricing_requires_removing_the_budget_explicitly() {
306        assert!(require_budget_pricing("openai", "unknown-dynamic-model", Some(1.0)).is_err());
307        assert!(require_budget_pricing("openai", "unknown-dynamic-model", None).is_ok());
308    }
309
310    #[test]
311    fn invalid_pricing_cannot_bypass_budget_enforcement() {
312        for invalid in [f64::NAN, f64::INFINITY, -1.0] {
313            let pricing = ModelPricing {
314                input: Some(invalid),
315                output: Some(0.01),
316                cache_read: None,
317                cache_write: None,
318            };
319            assert!(estimate_session_costs_with_pricing(pricing, &vtcode_exec_events::Usage::default()).is_none());
320        }
321    }
322
323    #[test]
324    fn overflowing_estimates_are_treated_as_unpriced() {
325        let pricing = ModelPricing {
326            input: Some(f64::MAX),
327            output: Some(f64::MAX),
328            cache_read: None,
329            cache_write: None,
330        };
331        let usage = vtcode_exec_events::Usage {
332            input_tokens: u64::MAX,
333            output_tokens: u64::MAX,
334            ..Default::default()
335        };
336        assert!(estimate_session_costs_with_pricing(pricing, &usage).is_none());
337    }
338
339    #[test]
340    fn cache_write_rate_uses_extended_ttl_multiplier() {
341        #[allow(clippy::float_cmp, reason = "exact constants under test")]
342        {
343            assert_eq!(cache_write_rate(1.0, None, false), DEFAULT_CACHE_WRITE_MULTIPLIER);
344            assert_eq!(cache_write_rate(1.0, None, true), EXTENDED_TTL_CACHE_WRITE_MULTIPLIER);
345            // Explicit catalog rates win over the heuristic multipliers.
346            assert_eq!(cache_write_rate(1.0, Some(0.5), true), 0.5);
347        }
348    }
349
350    #[test]
351    fn prompt_tokens_for_rate_limit_count_cache_traffic() {
352        let usage = vtcode_exec_events::Usage {
353            input_tokens: 1000,
354            cached_input_tokens: 400,
355            cache_creation_tokens: 200,
356            output_tokens: 50,
357        };
358        // Uncached (400) + cached (400) + creation (200) = 1000.
359        assert_eq!(prompt_tokens_for_rate_limit(&usage), 1000);
360    }
361}