Skip to main content

vtcode_core/llm/
usage_cost.rs

1//! Provider-normalized usage accumulation and cache-aware session cost estimation.
2//!
3//! Different providers report `prompt_tokens` with different cache semantics:
4//! Anthropic and Minimax report `prompt_tokens` *exclusive* of cache-read and
5//! cache-creation tokens, while every other supported provider (OpenAI, Gemini,
6//! etc.) reports `prompt_tokens` as a total that already includes cached
7//! tokens. This module normalizes per-turn provider usage into the canonical
8//! harness `Usage` shape, where `input_tokens` always means the total prompt
9//! tokens (uncached + cached + cache-creation), and provides a shared cost
10//! estimator used by both the interactive and headless runloops.
11
12#[cfg(test)]
13use vtcode_config::models::ModelPricing;
14
15use crate::llm::provider::ToolDefinition;
16#[cfg(test)]
17use crate::llm::provider::Usage as ProviderUsage;
18
19/// Estimate the token overhead of sending `tools` in the request payload.
20///
21/// Serializes each tool definition to JSON (the wire format sent to
22/// providers), sums the byte length, and converts to an approximate token
23/// count using the same "~4 bytes per token" heuristic used elsewhere in the
24/// codebase (see `system.rs` and `progress.rs`). A tool whose definition
25/// fails to serialize contributes zero bytes rather than failing the whole
26/// estimate, since this is an advisory figure, not a billing-accurate count.
27pub fn estimate_tool_definition_tokens(tools: &[ToolDefinition]) -> u64 {
28    // Reuse a single buffer across all tools instead of allocating a fresh
29    // `String` per definition. The buffer grows once to the largest serialized
30    // tool and stays there for the rest of the call.
31    let mut buf = Vec::new();
32    let total_bytes: u64 = tools
33        .iter()
34        .map(|tool| {
35            buf.clear();
36            serde_json::to_writer(&mut buf, tool).map(|_| buf.len() as u64).unwrap_or(0)
37        })
38        .sum();
39    total_bytes.div_ceil(4)
40}
41
42pub use vtcode_llm::usage_cost::{
43    SessionCostAccumulator, SessionCostEstimate, estimate_session_costs, estimate_session_costs_with_pricing,
44    normalized_turn_usage, provider_reports_exclusive_input, require_budget_pricing,
45};
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50    use serde_json::json;
51
52    fn approx_eq(a: f64, b: f64) {
53        assert!((a - b).abs() < 1e-12, "expected {a} to approx-equal {b}");
54    }
55
56    #[test]
57    fn estimate_tool_definition_tokens_is_zero_for_empty_slice() {
58        assert_eq!(estimate_tool_definition_tokens(&[]), 0);
59    }
60
61    #[test]
62    fn estimate_tool_definition_tokens_matches_serialized_byte_length() {
63        let tool = ToolDefinition::function(
64            "read_file".to_string(),
65            "Read the contents of a file from the workspace.".to_string(),
66            json!({
67                "type": "object",
68                "properties": {
69                    "path": { "type": "string" }
70                },
71                "required": ["path"],
72            }),
73        );
74
75        let expected_bytes = serde_json::to_string(&tool).expect("tool serializes").len() as u64;
76        let expected_tokens = expected_bytes.div_ceil(4);
77
78        assert_eq!(estimate_tool_definition_tokens(&[tool]), expected_tokens);
79    }
80
81    #[test]
82    fn normalized_turn_usage_adds_cache_tokens_for_anthropic() {
83        let usage = ProviderUsage {
84            prompt_tokens: 100,
85            completion_tokens: 20,
86            total_tokens: 120,
87            cached_prompt_tokens: None,
88            cache_creation_tokens: Some(50),
89            cache_read_tokens: Some(400),
90            iterations: None,
91        };
92
93        let normalized = normalized_turn_usage("anthropic", &usage);
94        assert_eq!(normalized.input_tokens, 550);
95        assert_eq!(normalized.cached_input_tokens, 400);
96        assert_eq!(normalized.cache_creation_tokens, 50);
97        assert_eq!(normalized.output_tokens, 20);
98    }
99
100    #[test]
101    fn normalized_turn_usage_treats_minimax_like_anthropic() {
102        let usage = ProviderUsage {
103            prompt_tokens: 100,
104            completion_tokens: 20,
105            total_tokens: 120,
106            cached_prompt_tokens: None,
107            cache_creation_tokens: Some(50),
108            cache_read_tokens: Some(400),
109            iterations: None,
110        };
111
112        let normalized = normalized_turn_usage("minimax", &usage);
113        assert_eq!(normalized.input_tokens, 550);
114        assert_eq!(normalized.cached_input_tokens, 400);
115        assert_eq!(normalized.cache_creation_tokens, 50);
116        assert_eq!(normalized.output_tokens, 20);
117    }
118
119    #[test]
120    fn normalized_turn_usage_keeps_openai_prompt_tokens_as_total() {
121        let usage = ProviderUsage {
122            prompt_tokens: 500,
123            completion_tokens: 30,
124            total_tokens: 530,
125            cached_prompt_tokens: Some(400),
126            cache_creation_tokens: None,
127            cache_read_tokens: None,
128            iterations: None,
129        };
130
131        let normalized = normalized_turn_usage("openai", &usage);
132        assert_eq!(normalized.input_tokens, 500);
133        assert_eq!(normalized.cached_input_tokens, 400);
134        assert_eq!(normalized.cache_creation_tokens, 0);
135    }
136
137    #[test]
138    fn provider_reports_exclusive_input_is_case_insensitive() {
139        assert!(provider_reports_exclusive_input("Anthropic"));
140        assert!(provider_reports_exclusive_input("ANTHROPIC"));
141        assert!(!provider_reports_exclusive_input("OpenAI"));
142        assert!(!provider_reports_exclusive_input("openai"));
143    }
144
145    #[test]
146    fn estimate_session_costs_with_pricing_discounts_cache_reads() {
147        let pricing = ModelPricing {
148            input: Some(0.01),
149            output: Some(0.02),
150            cache_read: Some(0.001),
151            cache_write: Some(0.0125),
152        };
153        let usage = vtcode_exec_events::Usage {
154            input_tokens: 1_000,
155            cached_input_tokens: 800,
156            cache_creation_tokens: 0,
157            output_tokens: 100,
158        };
159
160        let estimate = estimate_session_costs_with_pricing(pricing, &usage).expect("estimate");
161
162        // raw: all 1000 input tokens at full rate + output.
163        approx_eq(estimate.raw_usd, 1_000.0 * 0.01 + 100.0 * 0.02);
164        // effective: 200 uncached @ input rate + 800 cached @ read rate + output.
165        approx_eq(estimate.effective_usd, 200.0 * 0.01 + 800.0 * 0.001 + 100.0 * 0.02);
166        assert!(estimate.effective_usd < estimate.raw_usd);
167    }
168
169    #[test]
170    fn estimate_session_costs_with_pricing_matches_raw_when_no_cache_activity() {
171        let pricing = ModelPricing {
172            input: Some(0.01),
173            output: Some(0.02),
174            cache_read: Some(0.001),
175            cache_write: Some(0.0125),
176        };
177        let usage = vtcode_exec_events::Usage {
178            input_tokens: 1_000,
179            cached_input_tokens: 0,
180            cache_creation_tokens: 0,
181            output_tokens: 100,
182        };
183
184        let estimate = estimate_session_costs_with_pricing(pricing, &usage).expect("estimate");
185        approx_eq(estimate.raw_usd, estimate.effective_usd);
186    }
187
188    #[test]
189    fn estimate_session_costs_with_pricing_uses_heuristic_fallback_rates() {
190        let pricing = ModelPricing {
191            input: Some(0.01),
192            output: Some(0.02),
193            cache_read: None,
194            cache_write: None,
195        };
196        let usage = vtcode_exec_events::Usage {
197            input_tokens: 1_000,
198            cached_input_tokens: 50,
199            cache_creation_tokens: 500,
200            output_tokens: 50,
201        };
202
203        let estimate = estimate_session_costs_with_pricing(pricing, &usage).expect("estimate");
204
205        let read_rate = 0.01 * 0.10;
206        let write_rate = 0.01 * 1.25;
207        let uncached = 1_000.0 - 50.0 - 500.0;
208        let expected_effective = uncached * 0.01 + 50.0 * read_rate + 500.0 * write_rate + 50.0 * 0.02;
209        approx_eq(estimate.effective_usd, expected_effective);
210        approx_eq(estimate.raw_usd, 1_000.0 * 0.01 + 50.0 * 0.02);
211        // Cache-creation tokens dominate here (500 vs. 50 cache-read tokens),
212        // so the write-rate premium outweighs the read-rate discount and
213        // pushes effective above raw.
214        assert!(estimate.effective_usd > estimate.raw_usd);
215    }
216
217    #[test]
218    fn estimate_session_costs_with_pricing_returns_none_without_full_pricing() {
219        let missing_input = ModelPricing {
220            input: None,
221            output: Some(0.02),
222            cache_read: None,
223            cache_write: None,
224        };
225        let missing_output = ModelPricing {
226            input: Some(0.01),
227            output: None,
228            cache_read: None,
229            cache_write: None,
230        };
231        let usage = vtcode_exec_events::Usage::default();
232
233        assert!(estimate_session_costs_with_pricing(missing_input, &usage).is_none());
234        assert!(estimate_session_costs_with_pricing(missing_output, &usage).is_none());
235    }
236
237    #[test]
238    fn session_budget_tracks_spend_and_thresholds() {
239        let mut budget = SessionBudget::new(Some(1.0));
240        assert_eq!(budget.status(), BudgetStatus::Ok);
241        // 0.5 -> Ok
242        assert_eq!(budget.record(0.5), BudgetStatus::Ok);
243        // 0.3 -> 0.8 >= 0.75 cap -> Warning
244        assert_eq!(budget.record(0.3), BudgetStatus::Warning { spent: 0.8, max: 1.0 });
245        // 0.3 -> 1.1 >= cap -> Exceeded
246        assert_eq!(budget.record(0.3), BudgetStatus::Exceeded { spent: 1.1, max: 1.0 });
247        assert!((budget.spent_usd() - 1.1).abs() < 1e-9);
248        assert!((budget.remaining_usd().unwrap() - 0.0).abs() < 1e-9);
249    }
250
251    #[test]
252    fn session_budget_unlimited_is_always_ok() {
253        let mut budget = SessionBudget::new(None);
254        assert_eq!(budget.record(1000.0), BudgetStatus::Ok);
255        assert_eq!(budget.remaining_usd(), None);
256    }
257
258    #[test]
259    fn budget_status_classify_matches_harness_semantics() {
260        // Unlimited.
261        assert_eq!(BudgetStatus::classify(999.0, None, 0.75), BudgetStatus::Ok);
262        // Under warning.
263        assert_eq!(BudgetStatus::classify(0.5, Some(1.0), 0.75), BudgetStatus::Ok);
264        // At/above warning, within cap.
265        assert_eq!(BudgetStatus::classify(0.8, Some(1.0), 0.75), BudgetStatus::Warning { spent: 0.8, max: 1.0 });
266        // Exactly at cap is NOT exceeded (strict `>`), matching runner semantics.
267        assert!(!BudgetStatus::classify(1.0, Some(1.0), 0.75).is_exceeded());
268        // Over cap.
269        assert!(BudgetStatus::classify(1.01, Some(1.0), 0.75).is_exceeded());
270        // Configurable threshold.
271        assert_eq!(BudgetStatus::classify(0.6, Some(1.0), 0.5), BudgetStatus::Warning { spent: 0.6, max: 1.0 });
272    }
273}
274
275/// Default fraction of the budget at which the harness warns before hard
276/// exhaustion. Mirrors `agent.harness.budget_warning_threshold`'s default so a
277/// [`SessionBudget`] built without an explicit threshold behaves like the
278/// harness default.
279pub const DEFAULT_BUDGET_WARNING_RATIO: f64 = 0.75;
280
281/// Outcome of classifying cumulative spend against a budget cap.
282///
283/// This is the single source of truth for the harness budget decision. Both the
284/// `vtcode-core` runner ([`crate::core::agent::runner`]) and the binary crate's
285/// turn loop classify spend through [`BudgetStatus::classify`] rather than
286/// re-deriving the `> max` / `>= threshold * max` comparisons inline.
287#[derive(Debug, Clone, Copy, PartialEq)]
288pub enum BudgetStatus {
289    /// Under the warning threshold.
290    Ok,
291    /// At or above the warning ratio but still within the cap.
292    Warning {
293        /// Cumulative spend so far (USD).
294        spent: f64,
295        /// Configured cap (USD).
296        max: f64,
297    },
298    /// Over the cap — the run should stop or escalate.
299    Exceeded {
300        /// Cumulative spend so far (USD).
301        spent: f64,
302        /// Configured cap (USD).
303        max: f64,
304    },
305}
306
307impl BudgetStatus {
308    /// Classify `spent_usd` against an optional `max_usd` cap and a
309    /// `warning_threshold` fraction (`0.0..=1.0`).
310    ///
311    /// - `max_usd == None` → always [`BudgetStatus::Ok`] (unlimited).
312    /// - `spent_usd > max` → [`BudgetStatus::Exceeded`] (strict, matching the
313    ///   harness "stop after reaching the budget limit" semantics).
314    /// - `spent_usd >= warning_threshold * max` → [`BudgetStatus::Warning`].
315    /// - otherwise → [`BudgetStatus::Ok`].
316    #[must_use]
317    pub fn classify(spent_usd: f64, max_usd: Option<f64>, warning_threshold: f64) -> Self {
318        let Some(max) = max_usd else {
319            return BudgetStatus::Ok;
320        };
321        if spent_usd > max {
322            BudgetStatus::Exceeded { spent: spent_usd, max }
323        } else if spent_usd >= warning_threshold * max {
324            BudgetStatus::Warning { spent: spent_usd, max }
325        } else {
326            BudgetStatus::Ok
327        }
328    }
329
330    /// Whether the cap has been exceeded (the run should stop/escalate).
331    #[must_use]
332    pub fn is_exceeded(&self) -> bool {
333        matches!(self, BudgetStatus::Exceeded { .. })
334    }
335}
336
337/// Durable per-session cost budget for long-running (full-auto) sessions.
338///
339/// Long-horizon tasks accrue cost continuously; the harness should pause or
340/// escalate at thresholds rather than burning unbounded spend. `SessionBudget`
341/// accumulates the conservative `raw_usd` figure (see [`SessionCostEstimate`])
342/// and reports a [`BudgetStatus`] on each recorded turn. It delegates the
343/// decision to [`BudgetStatus::classify`] so callers that instead recompute the
344/// running total each turn (like the harness) share identical semantics.
345#[derive(Debug, Clone)]
346pub struct SessionBudget {
347    max_usd: Option<f64>,
348    warning_threshold: f64,
349    spent_usd: f64,
350}
351
352impl SessionBudget {
353    /// Create a budget with the default warning ratio. `None` max means
354    /// unlimited (status is always `Ok`).
355    #[must_use]
356    pub fn new(max_usd: Option<f64>) -> Self {
357        Self::with_warning_threshold(max_usd, DEFAULT_BUDGET_WARNING_RATIO)
358    }
359
360    /// Create a budget with an explicit warning threshold (e.g. from
361    /// `agent.harness.budget_warning_threshold`).
362    #[must_use]
363    pub fn with_warning_threshold(max_usd: Option<f64>, warning_threshold: f64) -> Self {
364        Self { max_usd, warning_threshold, spent_usd: 0.0 }
365    }
366
367    /// Record a turn's spend and return the resulting status.
368    pub fn record(&mut self, raw_usd: f64) -> BudgetStatus {
369        self.spent_usd += raw_usd.max(0.0);
370        self.status()
371    }
372
373    /// Current status given accumulated spend.
374    #[must_use]
375    pub fn status(&self) -> BudgetStatus {
376        BudgetStatus::classify(self.spent_usd, self.max_usd, self.warning_threshold)
377    }
378
379    /// Cumulative spend so far (USD).
380    #[must_use]
381    pub fn spent_usd(&self) -> f64 {
382        self.spent_usd
383    }
384
385    /// Remaining budget, or `None` when unlimited.
386    #[must_use]
387    pub fn remaining_usd(&self) -> Option<f64> {
388        self.max_usd.map(|m| (m - self.spent_usd).max(0.0))
389    }
390}