use vtcode_config::models::ModelPricing;
use crate::llm::model_resolver::ModelResolver;
use crate::llm::provider::ToolDefinition;
use crate::llm::provider::Usage as ProviderUsage;
pub fn estimate_tool_definition_tokens(tools: &[ToolDefinition]) -> u64 {
let total_bytes: u64 = tools
.iter()
.map(|tool| serde_json::to_string(tool).map(|json| json.len() as u64).unwrap_or(0))
.sum();
total_bytes.div_ceil(4)
}
pub fn provider_reports_exclusive_input(provider: &str) -> bool {
matches!(provider.trim().to_ascii_lowercase().as_str(), "anthropic" | "minimax")
}
pub fn normalized_turn_usage(provider: &str, usage: &ProviderUsage) -> vtcode_exec_events::Usage {
let cached = u64::from(usage.cache_read_tokens_or_fallback());
let creation = u64::from(usage.cache_creation_tokens_or_zero());
let mut input = u64::from(usage.prompt_tokens);
if provider_reports_exclusive_input(provider) {
input = input.saturating_add(cached).saturating_add(creation);
}
let output = u64::from(usage.completion_tokens);
vtcode_exec_events::Usage {
input_tokens: input,
cached_input_tokens: cached,
cache_creation_tokens: creation,
output_tokens: output,
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SessionCostEstimate {
pub raw_usd: f64,
pub effective_usd: f64,
}
pub fn estimate_session_costs(
provider: &str,
model: &str,
usage: &vtcode_exec_events::Usage,
) -> Option<SessionCostEstimate> {
let resolved = ModelResolver::resolve(Some(provider), model, &[], None)?;
let pricing = resolved.pricing()?;
estimate_session_costs_with_pricing(pricing, usage)
}
pub fn estimate_session_costs_with_pricing(
pricing: ModelPricing,
usage: &vtcode_exec_events::Usage,
) -> Option<SessionCostEstimate> {
let input_rate = pricing.input?;
let output_rate = pricing.output?;
let input_tokens = usage.input_tokens as f64;
let output_tokens = usage.output_tokens as f64;
let cached_tokens = usage.cached_input_tokens as f64;
let creation_tokens = usage.cache_creation_tokens as f64;
let raw_usd = input_tokens * input_rate + output_tokens * output_rate;
let read_rate = pricing.cache_read.unwrap_or(input_rate * 0.10);
let write_rate = pricing.cache_write.unwrap_or(input_rate * 1.25);
let uncached_tokens = usage
.input_tokens
.saturating_sub(usage.cached_input_tokens)
.saturating_sub(usage.cache_creation_tokens) as f64;
let effective_usd = uncached_tokens * input_rate
+ cached_tokens * read_rate
+ creation_tokens * write_rate
+ output_tokens * output_rate;
Some(SessionCostEstimate { raw_usd, effective_usd })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn approx_eq(a: f64, b: f64) {
assert!((a - b).abs() < 1e-12, "expected {a} to approx-equal {b}");
}
#[test]
fn estimate_tool_definition_tokens_is_zero_for_empty_slice() {
assert_eq!(estimate_tool_definition_tokens(&[]), 0);
}
#[test]
fn estimate_tool_definition_tokens_matches_serialized_byte_length() {
let tool = ToolDefinition::function(
"read_file".to_string(),
"Read the contents of a file from the workspace.".to_string(),
json!({
"type": "object",
"properties": {
"path": { "type": "string" }
},
"required": ["path"],
}),
);
let expected_bytes = serde_json::to_string(&tool).expect("tool serializes").len() as u64;
let expected_tokens = expected_bytes.div_ceil(4);
assert_eq!(estimate_tool_definition_tokens(&[tool]), expected_tokens);
}
#[test]
fn normalized_turn_usage_adds_cache_tokens_for_anthropic() {
let usage = ProviderUsage {
prompt_tokens: 100,
completion_tokens: 20,
total_tokens: 120,
cached_prompt_tokens: None,
cache_creation_tokens: Some(50),
cache_read_tokens: Some(400),
iterations: None,
};
let normalized = normalized_turn_usage("anthropic", &usage);
assert_eq!(normalized.input_tokens, 550);
assert_eq!(normalized.cached_input_tokens, 400);
assert_eq!(normalized.cache_creation_tokens, 50);
assert_eq!(normalized.output_tokens, 20);
}
#[test]
fn normalized_turn_usage_treats_minimax_like_anthropic() {
let usage = ProviderUsage {
prompt_tokens: 100,
completion_tokens: 20,
total_tokens: 120,
cached_prompt_tokens: None,
cache_creation_tokens: Some(50),
cache_read_tokens: Some(400),
iterations: None,
};
let normalized = normalized_turn_usage("minimax", &usage);
assert_eq!(normalized.input_tokens, 550);
assert_eq!(normalized.cached_input_tokens, 400);
assert_eq!(normalized.cache_creation_tokens, 50);
assert_eq!(normalized.output_tokens, 20);
}
#[test]
fn normalized_turn_usage_keeps_openai_prompt_tokens_as_total() {
let usage = ProviderUsage {
prompt_tokens: 500,
completion_tokens: 30,
total_tokens: 530,
cached_prompt_tokens: Some(400),
cache_creation_tokens: None,
cache_read_tokens: None,
iterations: None,
};
let normalized = normalized_turn_usage("openai", &usage);
assert_eq!(normalized.input_tokens, 500);
assert_eq!(normalized.cached_input_tokens, 400);
assert_eq!(normalized.cache_creation_tokens, 0);
}
#[test]
fn provider_reports_exclusive_input_is_case_insensitive() {
assert!(provider_reports_exclusive_input("Anthropic"));
assert!(provider_reports_exclusive_input("ANTHROPIC"));
assert!(!provider_reports_exclusive_input("OpenAI"));
assert!(!provider_reports_exclusive_input("openai"));
}
#[test]
fn estimate_session_costs_with_pricing_discounts_cache_reads() {
let pricing = ModelPricing {
input: Some(0.01),
output: Some(0.02),
cache_read: Some(0.001),
cache_write: Some(0.0125),
};
let usage = vtcode_exec_events::Usage {
input_tokens: 1_000,
cached_input_tokens: 800,
cache_creation_tokens: 0,
output_tokens: 100,
};
let estimate = estimate_session_costs_with_pricing(pricing, &usage).expect("estimate");
approx_eq(estimate.raw_usd, 1_000.0 * 0.01 + 100.0 * 0.02);
approx_eq(estimate.effective_usd, 200.0 * 0.01 + 800.0 * 0.001 + 100.0 * 0.02);
assert!(estimate.effective_usd < estimate.raw_usd);
}
#[test]
fn estimate_session_costs_with_pricing_matches_raw_when_no_cache_activity() {
let pricing = ModelPricing {
input: Some(0.01),
output: Some(0.02),
cache_read: Some(0.001),
cache_write: Some(0.0125),
};
let usage = vtcode_exec_events::Usage {
input_tokens: 1_000,
cached_input_tokens: 0,
cache_creation_tokens: 0,
output_tokens: 100,
};
let estimate = estimate_session_costs_with_pricing(pricing, &usage).expect("estimate");
approx_eq(estimate.raw_usd, estimate.effective_usd);
}
#[test]
fn estimate_session_costs_with_pricing_uses_heuristic_fallback_rates() {
let pricing = ModelPricing {
input: Some(0.01),
output: Some(0.02),
cache_read: None,
cache_write: None,
};
let usage = vtcode_exec_events::Usage {
input_tokens: 1_000,
cached_input_tokens: 50,
cache_creation_tokens: 500,
output_tokens: 50,
};
let estimate = estimate_session_costs_with_pricing(pricing, &usage).expect("estimate");
let read_rate = 0.01 * 0.10;
let write_rate = 0.01 * 1.25;
let uncached = 1_000.0 - 50.0 - 500.0;
let expected_effective = uncached * 0.01 + 50.0 * read_rate + 500.0 * write_rate + 50.0 * 0.02;
approx_eq(estimate.effective_usd, expected_effective);
approx_eq(estimate.raw_usd, 1_000.0 * 0.01 + 50.0 * 0.02);
assert!(estimate.effective_usd > estimate.raw_usd);
}
#[test]
fn estimate_session_costs_with_pricing_returns_none_without_full_pricing() {
let missing_input = ModelPricing {
input: None,
output: Some(0.02),
cache_read: None,
cache_write: None,
};
let missing_output = ModelPricing {
input: Some(0.01),
output: None,
cache_read: None,
cache_write: None,
};
let usage = vtcode_exec_events::Usage::default();
assert!(estimate_session_costs_with_pricing(missing_input, &usage).is_none());
assert!(estimate_session_costs_with_pricing(missing_output, &usage).is_none());
}
#[test]
fn session_budget_tracks_spend_and_thresholds() {
let mut budget = SessionBudget::new(Some(1.0));
assert_eq!(budget.status(), BudgetStatus::Ok);
assert_eq!(budget.record(0.5), BudgetStatus::Ok);
assert_eq!(budget.record(0.3), BudgetStatus::Warning { spent: 0.8, max: 1.0 });
assert_eq!(budget.record(0.3), BudgetStatus::Exceeded { spent: 1.1, max: 1.0 });
assert!((budget.spent_usd() - 1.1).abs() < 1e-9);
assert!((budget.remaining_usd().unwrap() - 0.0).abs() < 1e-9);
}
#[test]
fn session_budget_unlimited_is_always_ok() {
let mut budget = SessionBudget::new(None);
assert_eq!(budget.record(1000.0), BudgetStatus::Ok);
assert_eq!(budget.remaining_usd(), None);
}
#[test]
fn budget_status_classify_matches_harness_semantics() {
assert_eq!(BudgetStatus::classify(999.0, None, 0.75), BudgetStatus::Ok);
assert_eq!(BudgetStatus::classify(0.5, Some(1.0), 0.75), BudgetStatus::Ok);
assert_eq!(BudgetStatus::classify(0.8, Some(1.0), 0.75), BudgetStatus::Warning { spent: 0.8, max: 1.0 });
assert!(!BudgetStatus::classify(1.0, Some(1.0), 0.75).is_exceeded());
assert!(BudgetStatus::classify(1.01, Some(1.0), 0.75).is_exceeded());
assert_eq!(BudgetStatus::classify(0.6, Some(1.0), 0.5), BudgetStatus::Warning { spent: 0.6, max: 1.0 });
}
}
pub const DEFAULT_BUDGET_WARNING_RATIO: f64 = 0.75;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BudgetStatus {
Ok,
Warning {
spent: f64,
max: f64,
},
Exceeded {
spent: f64,
max: f64,
},
}
impl BudgetStatus {
#[must_use]
pub fn classify(spent_usd: f64, max_usd: Option<f64>, warning_threshold: f64) -> Self {
let Some(max) = max_usd else {
return BudgetStatus::Ok;
};
if spent_usd > max {
BudgetStatus::Exceeded { spent: spent_usd, max }
} else if spent_usd >= warning_threshold * max {
BudgetStatus::Warning { spent: spent_usd, max }
} else {
BudgetStatus::Ok
}
}
#[must_use]
pub fn is_exceeded(&self) -> bool {
matches!(self, BudgetStatus::Exceeded { .. })
}
}
#[derive(Debug, Clone)]
pub struct SessionBudget {
max_usd: Option<f64>,
warning_threshold: f64,
spent_usd: f64,
}
impl SessionBudget {
#[must_use]
pub fn new(max_usd: Option<f64>) -> Self {
Self::with_warning_threshold(max_usd, DEFAULT_BUDGET_WARNING_RATIO)
}
#[must_use]
pub fn with_warning_threshold(max_usd: Option<f64>, warning_threshold: f64) -> Self {
Self { max_usd, warning_threshold, spent_usd: 0.0 }
}
pub fn record(&mut self, raw_usd: f64) -> BudgetStatus {
self.spent_usd += raw_usd.max(0.0);
self.status()
}
#[must_use]
pub fn status(&self) -> BudgetStatus {
BudgetStatus::classify(self.spent_usd, self.max_usd, self.warning_threshold)
}
#[must_use]
pub fn spent_usd(&self) -> f64 {
self.spent_usd
}
#[must_use]
pub fn remaining_usd(&self) -> Option<f64> {
self.max_usd.map(|m| (m - self.spent_usd).max(0.0))
}
}