Skip to main content

apollo/
cost.rs

1//! Cost tracking — token counting and billing for LLM calls
2//! Phase 4 feature: Production billing support
3
4use serde::{Deserialize, Serialize};
5use std::sync::Arc;
6use tokio::sync::RwLock;
7
8/// Cost per 1M tokens (input/output separate)
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ModelCost {
11    pub model: String,
12    pub input_cost_per_1m: f64,
13    pub output_cost_per_1m: f64,
14}
15
16/// Token usage for a call
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct TokenUsage {
19    pub input_tokens: usize,
20    pub output_tokens: usize,
21    pub total_tokens: usize,
22}
23
24impl TokenUsage {
25    pub fn calculate_cost(&self, cost: &ModelCost) -> f64 {
26        let input_cost = (self.input_tokens as f64 / 1_000_000.0) * cost.input_cost_per_1m;
27        let output_cost = (self.output_tokens as f64 / 1_000_000.0) * cost.output_cost_per_1m;
28        input_cost + output_cost
29    }
30}
31
32/// Cost record for a single LLM call
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct CostRecord {
35    pub id: String,
36    pub model: String,
37    pub input_tokens: usize,
38    pub output_tokens: usize,
39    pub cost_usd: f64,
40    /// False means usage was recorded but no configured price was available.
41    #[serde(default = "default_pricing_known")]
42    pub pricing_known: bool,
43    pub timestamp: chrono::DateTime<chrono::Utc>,
44}
45
46fn default_pricing_known() -> bool {
47    true
48}
49
50/// Estimated input shape for one provider request. These counts are based on
51/// characters because providers do not expose tokenizers uniformly; they are
52/// intended for comparing Apollo configurations, not billing.
53#[derive(Debug, Clone, Default, Serialize, Deserialize)]
54pub struct ContextSnapshot {
55    pub system_chars: usize,
56    pub history_chars: usize,
57    pub tool_chars: usize,
58    pub estimated_input_tokens: usize,
59}
60
61#[derive(Debug, Clone, Default, Serialize, Deserialize)]
62pub struct ContextSummary {
63    pub request_count: usize,
64    pub system_chars: usize,
65    pub history_chars: usize,
66    pub tool_chars: usize,
67    pub estimated_input_tokens: usize,
68}
69
70/// Claude API rate limit status (from response headers)
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct RateLimitStatus {
73    pub requests_limit: Option<usize>,
74    pub requests_remaining: Option<usize>,
75    pub input_tokens_limit: Option<usize>,
76    pub input_tokens_remaining: Option<usize>,
77    pub output_tokens_limit: Option<usize>,
78    pub output_tokens_remaining: Option<usize>,
79    pub tokens_reset: Option<String>,
80}
81
82/// Cost tracker (in-memory + persistent accounting hooks)
83pub struct CostTracker {
84    costs: Arc<RwLock<Vec<CostRecord>>>,
85    contexts: Arc<RwLock<ContextSummary>>,
86    models: Arc<RwLock<Vec<ModelCost>>>,
87    rate_limit_status: Arc<RwLock<Option<RateLimitStatus>>>,
88}
89
90impl CostTracker {
91    pub fn new() -> Self {
92        let models = vec![
93            ModelCost {
94                model: "claude-opus-4-6".to_string(),
95                input_cost_per_1m: 15.0,
96                output_cost_per_1m: 75.0,
97            },
98            ModelCost {
99                model: "claude-3-5-sonnet-20241022".to_string(),
100                input_cost_per_1m: 3.0,
101                output_cost_per_1m: 15.0,
102            },
103            ModelCost {
104                model: "gpt-4-turbo".to_string(),
105                input_cost_per_1m: 10.0,
106                output_cost_per_1m: 30.0,
107            },
108            ModelCost {
109                model: "gpt-4".to_string(),
110                input_cost_per_1m: 30.0,
111                output_cost_per_1m: 60.0,
112            },
113            ModelCost {
114                model: "gpt-3.5-turbo".to_string(),
115                input_cost_per_1m: 0.5,
116                output_cost_per_1m: 1.5,
117            },
118            ModelCost {
119                model: "gemini-2.0-flash".to_string(),
120                input_cost_per_1m: 0.075,
121                output_cost_per_1m: 0.3,
122            },
123        ];
124
125        Self {
126            costs: Arc::new(RwLock::new(Vec::new())),
127            contexts: Arc::new(RwLock::new(ContextSummary::default())),
128            models: Arc::new(RwLock::new(models)),
129            rate_limit_status: Arc::new(RwLock::new(None)),
130        }
131    }
132
133    /// Record a cost from an LLM call
134    pub async fn record(&self, model: &str, usage: TokenUsage) -> anyhow::Result<()> {
135        let models = self.models.read().await;
136        let model_cost = models.iter().find(|m| m.model == model).cloned();
137
138        let (cost_usd, pricing_known) = match model_cost {
139            Some(model_cost) => (usage.calculate_cost(&model_cost), true),
140            None => {
141                tracing::warn!(model, "recording usage without a configured model price");
142                (0.0, false)
143            }
144        };
145
146        let record = CostRecord {
147            id: uuid::Uuid::new_v4().to_string(),
148            model: model.to_string(),
149            input_tokens: usage.input_tokens,
150            output_tokens: usage.output_tokens,
151            cost_usd,
152            pricing_known,
153            timestamp: chrono::Utc::now(),
154        };
155
156        self.costs.write().await.push(record);
157        Ok(())
158    }
159
160    /// Record the estimated shape of a provider request for harness telemetry.
161    pub async fn record_context(&self, snapshot: ContextSnapshot) {
162        let mut summary = self.contexts.write().await;
163        summary.request_count += 1;
164        summary.system_chars += snapshot.system_chars;
165        summary.history_chars += snapshot.history_chars;
166        summary.tool_chars += snapshot.tool_chars;
167        summary.estimated_input_tokens += snapshot.estimated_input_tokens;
168    }
169
170    /// Aggregate prompt-shape telemetry since this tracker was created.
171    pub async fn context_summary(&self) -> ContextSummary {
172        self.contexts.read().await.clone()
173    }
174
175    /// Get cost summary
176    pub async fn summary(&self) -> CostSummary {
177        let costs = self.costs.read().await;
178
179        // Folded from a positive zero rather than summed: `<f64 as Sum>` starts
180        // at -0.0, so an empty tracker would otherwise report "-0.0" spent.
181        let total_cost: f64 = costs.iter().map(|c| c.cost_usd).fold(0.0, |acc, c| acc + c);
182        let total_tokens: usize = costs.iter().map(|c| c.input_tokens + c.output_tokens).sum();
183
184        let mut by_model: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
185        let mut unpriced_models = std::collections::BTreeSet::new();
186        for cost in costs.iter() {
187            *by_model.entry(cost.model.clone()).or_insert(0.0) += cost.cost_usd;
188            if !cost.pricing_known {
189                unpriced_models.insert(cost.model.clone());
190            }
191        }
192
193        let context = self.context_summary().await;
194        CostSummary {
195            total_cost,
196            total_tokens,
197            by_model,
198            call_count: costs.len(),
199            unpriced_call_count: costs.iter().filter(|cost| !cost.pricing_known).count(),
200            unpriced_models: unpriced_models.into_iter().collect(),
201            pricing_complete: costs.iter().all(|cost| cost.pricing_known),
202            context,
203        }
204    }
205
206    /// Get cost history (with date filtering)
207    pub async fn history(&self, days: usize) -> Vec<CostRecord> {
208        let costs = self.costs.read().await;
209        let cutoff = chrono::Utc::now() - chrono::Duration::days(days as i64);
210
211        costs
212            .iter()
213            .filter(|c| c.timestamp > cutoff)
214            .cloned()
215            .collect()
216    }
217
218    /// Update rate limit status from Anthropic API response headers
219    pub async fn update_rate_limits(&self, headers: &reqwest::header::HeaderMap) {
220        let parse_usize = |key| {
221            headers
222                .get(key)
223                .and_then(|v| v.to_str().ok())
224                .and_then(|s| s.parse().ok())
225        };
226
227        let parse_string = |key| {
228            headers
229                .get(key)
230                .and_then(|v| v.to_str().ok())
231                .map(|s| s.to_string())
232        };
233
234        let status = RateLimitStatus {
235            requests_limit: parse_usize("anthropic-ratelimit-requests-limit"),
236            requests_remaining: parse_usize("anthropic-ratelimit-requests-remaining"),
237            input_tokens_limit: parse_usize("anthropic-ratelimit-input-tokens-limit"),
238            input_tokens_remaining: parse_usize("anthropic-ratelimit-input-tokens-remaining"),
239            output_tokens_limit: parse_usize("anthropic-ratelimit-output-tokens-limit"),
240            output_tokens_remaining: parse_usize("anthropic-ratelimit-output-tokens-remaining"),
241            tokens_reset: parse_string("anthropic-ratelimit-tokens-reset"),
242        };
243
244        *self.rate_limit_status.write().await = Some(status);
245    }
246
247    /// Get current rate limit status
248    pub async fn get_rate_limits(&self) -> Option<RateLimitStatus> {
249        self.rate_limit_status.read().await.clone()
250    }
251}
252
253impl Default for CostTracker {
254    fn default() -> Self {
255        Self::new()
256    }
257}
258
259#[derive(Debug, Serialize, Deserialize)]
260pub struct CostSummary {
261    pub total_cost: f64,
262    pub total_tokens: usize,
263    pub by_model: std::collections::HashMap<String, f64>,
264    pub call_count: usize,
265    /// Calls whose token usage was recorded without a known price.
266    #[serde(default)]
267    pub unpriced_call_count: usize,
268    #[serde(default)]
269    pub unpriced_models: Vec<String>,
270    /// False means `total_cost` excludes one or more calls with unknown
271    /// pricing; it must not be presented as a complete bill.
272    #[serde(default = "default_pricing_complete")]
273    pub pricing_complete: bool,
274    pub context: ContextSummary,
275}
276
277fn default_pricing_complete() -> bool {
278    true
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_token_cost_calculation() {
287        let cost = ModelCost {
288            model: "test".to_string(),
289            input_cost_per_1m: 1.0,
290            output_cost_per_1m: 2.0,
291        };
292
293        let usage = TokenUsage {
294            input_tokens: 1_000_000,
295            output_tokens: 1_000_000,
296            total_tokens: 2_000_000,
297        };
298
299        let calculated = usage.calculate_cost(&cost);
300        assert_eq!(calculated, 3.0); // 1.0 + 2.0
301    }
302
303    #[tokio::test]
304    async fn test_cost_tracking() {
305        let tracker = CostTracker::new();
306
307        tracker
308            .record(
309                "claude-opus-4-6",
310                TokenUsage {
311                    input_tokens: 100,
312                    output_tokens: 50,
313                    total_tokens: 150,
314                },
315            )
316            .await
317            .unwrap();
318
319        let summary = tracker.summary().await;
320        assert_eq!(summary.call_count, 1);
321        assert!(summary.total_cost > 0.0);
322    }
323
324    #[tokio::test]
325    async fn unknown_model_usage_is_reported_as_unpriced() {
326        let tracker = CostTracker::new();
327        tracker
328            .record(
329                "future-model",
330                TokenUsage {
331                    input_tokens: 100,
332                    output_tokens: 50,
333                    total_tokens: 150,
334                },
335            )
336            .await
337            .unwrap();
338
339        let summary = tracker.summary().await;
340        assert_eq!(summary.unpriced_call_count, 1);
341        assert_eq!(summary.unpriced_models, vec!["future-model"]);
342        assert!(!summary.pricing_complete);
343        assert_eq!(summary.total_cost, 0.0);
344    }
345
346    #[tokio::test]
347    async fn an_empty_tracker_reports_a_positive_zero_cost() {
348        let summary = CostTracker::new().summary().await;
349        assert_eq!(summary.call_count, 0);
350        assert_eq!(summary.total_cost, 0.0);
351        assert!(!summary.total_cost.is_sign_negative());
352        assert_eq!(format!("{:.1}", summary.total_cost), "0.0");
353    }
354
355    #[tokio::test]
356    async fn test_update_rate_limits() {
357        let tracker = CostTracker::new();
358        let mut headers = reqwest::header::HeaderMap::new();
359
360        headers.insert(
361            "anthropic-ratelimit-requests-limit",
362            "1000".parse().unwrap(),
363        );
364        headers.insert(
365            "anthropic-ratelimit-requests-remaining",
366            "999".parse().unwrap(),
367        );
368        headers.insert(
369            "anthropic-ratelimit-input-tokens-limit",
370            "400000".parse().unwrap(),
371        );
372        headers.insert(
373            "anthropic-ratelimit-input-tokens-remaining",
374            "399000".parse().unwrap(),
375        );
376        headers.insert(
377            "anthropic-ratelimit-output-tokens-limit",
378            "100000".parse().unwrap(),
379        );
380        headers.insert(
381            "anthropic-ratelimit-output-tokens-remaining",
382            "99000".parse().unwrap(),
383        );
384        headers.insert(
385            "anthropic-ratelimit-tokens-reset",
386            "2023-11-20T12:00:00Z".parse().unwrap(),
387        );
388
389        tracker.update_rate_limits(&headers).await;
390
391        let status = tracker.get_rate_limits().await.unwrap();
392
393        assert_eq!(status.requests_limit, Some(1000));
394        assert_eq!(status.requests_remaining, Some(999));
395        assert_eq!(status.input_tokens_limit, Some(400000));
396        assert_eq!(status.input_tokens_remaining, Some(399000));
397        assert_eq!(status.output_tokens_limit, Some(100000));
398        assert_eq!(status.output_tokens_remaining, Some(99000));
399        assert_eq!(
400            status.tokens_reset,
401            Some("2023-11-20T12:00:00Z".to_string())
402        );
403    }
404
405    #[tokio::test]
406    async fn context_telemetry_aggregates_request_shape() {
407        let tracker = CostTracker::new();
408        tracker
409            .record_context(ContextSnapshot {
410                system_chars: 40,
411                history_chars: 80,
412                tool_chars: 20,
413                estimated_input_tokens: 35,
414            })
415            .await;
416        let summary = tracker.context_summary().await;
417        assert_eq!(summary.request_count, 1);
418        assert_eq!(summary.estimated_input_tokens, 35);
419        assert_eq!(summary.system_chars, 40);
420
421        tracker
422            .record_context(ContextSnapshot {
423                system_chars: 2,
424                history_chars: 3,
425                tool_chars: 4,
426                estimated_input_tokens: 5,
427            })
428            .await;
429        let summary = tracker.context_summary().await;
430        assert_eq!(summary.request_count, 2);
431        assert_eq!(summary.system_chars, 42);
432        assert_eq!(summary.estimated_input_tokens, 40);
433    }
434}