1use crate::model_resolver::ModelResolver;
4use crate::provider::Usage as ProviderUsage;
5use vtcode_config::models::ModelPricing;
6
7pub fn provider_reports_exclusive_input(provider: &str) -> bool {
16 matches!(provider.trim().to_ascii_lowercase().as_str(), "anthropic" | "minimax")
17}
18
19pub 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#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct SessionCostEstimate {
44 pub raw_usd: f64,
48 pub effective_usd: f64,
51}
52
53pub 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#[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
91pub 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
104pub 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
117pub 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 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
164pub const DEFAULT_CACHE_WRITE_MULTIPLIER: f64 = 1.25;
166
167pub const EXTENDED_TTL_CACHE_WRITE_MULTIPLIER: f64 = 2.0;
169
170#[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#[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 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 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 assert_eq!(prompt_tokens_for_rate_limit(&usage), 1000);
360 }
361}