Skip to main content

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}