vct_core/pricing/cost.rs
1//! Per-model USD cost resolution.
2//!
3//! Turns a model's [`TokenCounts`](crate::utils::TokenCounts) into a dollar
4//! cost against the [`ModelPricingMap`], branching on the authoritative cost
5//! source for the provider that produced the tokens. This is pure pricing
6//! policy (no `usage`- or `analysis`-feature knowledge), so it lives in
7//! `pricing` and both the `usage` roll-up and the display summaries consume it.
8
9use crate::pricing::{ModelPricing, ModelPricingMap, calculate_cost};
10use crate::utils::TokenCounts;
11
12/// How a model's USD cost is resolved.
13///
14/// Different providers carry different authoritative cost sources, so the cost
15/// resolver branches on which one applies.
16#[derive(Debug, Clone, Copy)]
17pub enum CostSource {
18 /// File-based providers: the full LiteLLM lookup (exact → normalized →
19 /// substring → fuzzy).
20 Litellm,
21 /// OpenCode: an **exact** LiteLLM match prices from tokens, otherwise the
22 /// stored assistant-message cost is used verbatim. No fuzzy guessing, so a
23 /// novel model like `deepseek-v4-pro` reports OpenCode's own cost instead of
24 /// being priced against a loosely-similar name.
25 OpenCodeStored(f64),
26 /// Caller-supplied Cursor cost used verbatim. Retained for source
27 /// compatibility; VCT's local Cursor reader now returns zero stored cost
28 /// and its display path accepts only exact LiteLLM matches.
29 CursorStored(f64),
30 /// Hermes: same basis as [`OpenCodeStored`] — an **exact** LiteLLM match
31 /// prices from tokens, otherwise Hermes's own stored cost is used. Hermes
32 /// often bills novel models LiteLLM can't price, so its own number is the
33 /// safest fallback; the map is kept separate so a colliding bare model name
34 /// can't cross-contaminate another provider's cost.
35 HermesStored(f64),
36 /// Grok: the full LiteLLM lookup, but the context-gauge estimate lives
37 /// entirely in the cache-read bucket, so a matched model whose LiteLLM
38 /// entry publishes no cache-read price (null for several `xai/grok-*`
39 /// variants) falls back to the input rate instead of silently costing $0.
40 GrokGauge,
41}
42
43/// Resolves the USD cost (and optional matched-model annotation) for one model.
44///
45/// Returns `(cost_usd, matched_model)` where `matched_model` is `Some` only
46/// when a non-exact LiteLLM key was used (for display annotation).
47pub fn resolve_model_cost(
48 model: &str,
49 counts: &TokenCounts,
50 pricing_map: &ModelPricingMap,
51 source: CostSource,
52) -> (f64, Option<String>) {
53 let priced = |pricing: &ModelPricing| {
54 let token_cost = calculate_cost(counts, pricing);
55 // Web search is billed per query (Claude `server_tool_use`),
56 // separately from tokens. `web_search_requests` is 0 for every
57 // non-Claude model, so this term is a no-op for them.
58 token_cost + counts.web_search_requests as f64 * pricing.web_search_cost_per_query
59 };
60
61 match source {
62 // Cursor's dashboard cost is authoritative; never re-price from tokens.
63 CostSource::CursorStored(stored) => (stored, None),
64 // OpenCode / Hermes: only trust an exact price match; otherwise use the
65 // provider's own stored cost.
66 CostSource::OpenCodeStored(stored) | CostSource::HermesStored(stored) => {
67 match pricing_map.get_exact(model) {
68 Some(pricing) => (priced(&pricing), None),
69 None => (stored, None),
70 }
71 }
72 CostSource::Litellm => {
73 let result = pricing_map.get(model);
74 (priced(&result.pricing), result.matched_model)
75 }
76 CostSource::GrokGauge => {
77 let result = pricing_map.get(model);
78 let mut pricing = result.pricing;
79 if pricing.cache_read_input_token_cost <= 0.0 {
80 pricing.cache_read_input_token_cost = pricing.input_cost_per_token;
81 }
82 (priced(&pricing), result.matched_model)
83 }
84 }
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90 use crate::pricing::clear_pricing_cache;
91 use std::collections::HashMap;
92
93 fn map_with_gpt4() -> ModelPricingMap {
94 let mut raw = HashMap::new();
95 raw.insert(
96 "gpt-4".to_string(),
97 ModelPricing {
98 input_cost_per_token: 1e-5,
99 ..Default::default()
100 },
101 );
102 ModelPricingMap::new(raw)
103 }
104
105 fn counts(input: i64) -> TokenCounts {
106 TokenCounts {
107 input_tokens: input,
108 total: input,
109 ..Default::default()
110 }
111 }
112
113 #[test]
114 fn test_opencode_exact_match_computes_from_tokens() {
115 clear_pricing_cache();
116 let map = map_with_gpt4();
117 // Exact LiteLLM price exists -> compute from tokens, ignore stored cost.
118 let (cost, matched) = resolve_model_cost(
119 "gpt-4",
120 &counts(1_000_000),
121 &map,
122 CostSource::OpenCodeStored(99.0),
123 );
124 assert!((cost - 10.0).abs() < 1e-6); // 1e6 * 1e-5
125 assert!(matched.is_none());
126 }
127
128 #[test]
129 fn test_opencode_no_exact_match_uses_stored_cost() {
130 clear_pricing_cache();
131 let map = map_with_gpt4();
132 // No exact price; OpenCode must NOT fuzzy match -> use stored cost.
133 let (cost, matched) = resolve_model_cost(
134 "deepseek-v4-pro",
135 &counts(1_000_000),
136 &map,
137 CostSource::OpenCodeStored(99.0),
138 );
139 assert!((cost - 99.0).abs() < 1e-9);
140 assert!(matched.is_none());
141 }
142
143 #[test]
144 fn test_cursor_stored_cost_ignores_exact_match() {
145 clear_pricing_cache();
146 let map = map_with_gpt4();
147 // Cursor's dashboard cost is authoritative even when an exact LiteLLM
148 // price exists -> use the stored cost, never re-price from tokens.
149 let (cost, matched) = resolve_model_cost(
150 "gpt-4",
151 &counts(1_000_000),
152 &map,
153 CostSource::CursorStored(3.5),
154 );
155 assert!((cost - 3.5).abs() < 1e-9);
156 assert!(matched.is_none());
157 }
158
159 #[test]
160 fn test_non_opencode_keeps_existing_lookup() {
161 clear_pricing_cache();
162 let map = map_with_gpt4();
163 // Litellm path is unchanged: exact match still computes.
164 let (cost, matched) =
165 resolve_model_cost("gpt-4", &counts(1_000_000), &map, CostSource::Litellm);
166 assert!((cost - 10.0).abs() < 1e-6);
167 assert!(matched.is_none());
168 }
169}