1#[cfg(test)]
13use vtcode_config::models::ModelPricing;
14
15use crate::llm::provider::ToolDefinition;
16#[cfg(test)]
17use crate::llm::provider::Usage as ProviderUsage;
18
19pub fn estimate_tool_definition_tokens(tools: &[ToolDefinition]) -> u64 {
28 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 approx_eq(estimate.raw_usd, 1_000.0 * 0.01 + 100.0 * 0.02);
164 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 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 assert_eq!(budget.record(0.5), BudgetStatus::Ok);
243 assert_eq!(budget.record(0.3), BudgetStatus::Warning { spent: 0.8, max: 1.0 });
245 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 assert_eq!(BudgetStatus::classify(999.0, None, 0.75), BudgetStatus::Ok);
262 assert_eq!(BudgetStatus::classify(0.5, Some(1.0), 0.75), BudgetStatus::Ok);
264 assert_eq!(BudgetStatus::classify(0.8, Some(1.0), 0.75), BudgetStatus::Warning { spent: 0.8, max: 1.0 });
266 assert!(!BudgetStatus::classify(1.0, Some(1.0), 0.75).is_exceeded());
268 assert!(BudgetStatus::classify(1.01, Some(1.0), 0.75).is_exceeded());
270 assert_eq!(BudgetStatus::classify(0.6, Some(1.0), 0.5), BudgetStatus::Warning { spent: 0.6, max: 1.0 });
272 }
273}
274
275pub const DEFAULT_BUDGET_WARNING_RATIO: f64 = 0.75;
280
281#[derive(Debug, Clone, Copy, PartialEq)]
288pub enum BudgetStatus {
289 Ok,
291 Warning {
293 spent: f64,
295 max: f64,
297 },
298 Exceeded {
300 spent: f64,
302 max: f64,
304 },
305}
306
307impl BudgetStatus {
308 #[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 #[must_use]
332 pub fn is_exceeded(&self) -> bool {
333 matches!(self, BudgetStatus::Exceeded { .. })
334 }
335}
336
337#[derive(Debug, Clone)]
346pub struct SessionBudget {
347 max_usd: Option<f64>,
348 warning_threshold: f64,
349 spent_usd: f64,
350}
351
352impl SessionBudget {
353 #[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 #[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 pub fn record(&mut self, raw_usd: f64) -> BudgetStatus {
369 self.spent_usd += raw_usd.max(0.0);
370 self.status()
371 }
372
373 #[must_use]
375 pub fn status(&self) -> BudgetStatus {
376 BudgetStatus::classify(self.spent_usd, self.max_usd, self.warning_threshold)
377 }
378
379 #[must_use]
381 pub fn spent_usd(&self) -> f64 {
382 self.spent_usd
383 }
384
385 #[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}