Skip to main content

oxicode_sdk/observability/
cost.rs

1//! Token cost tracking — per-agent token usage and cost breakdown.
2
3use oxicode_ai::{Model, ModelRegistry};
4use parking_lot::RwLock;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::sync::Arc;
8
9/// Token usage broken down by component.
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11pub struct TokenUsage {
12    /// Input (prompt) tokens.
13    pub input: u64,
14    /// Output (completion) tokens.
15    pub output: u64,
16    /// Tokens read from cache.
17    pub cache_read: u64,
18    /// Tokens written to cache.
19    pub cache_write: u64,
20}
21
22impl TokenUsage {
23    /// Total tokens across all categories.
24    pub fn total(&self) -> u64 {
25        self.input + self.output + self.cache_read + self.cache_write
26    }
27
28    /// Compute cost breakdown using a model's pricing data.
29    pub fn cost(&self, model: &Model) -> CostBreakdown {
30        let input_cost = self.input as f64 * model.cost.input / 1_000_000.0;
31        let output_cost = self.output as f64 * model.cost.output / 1_000_000.0;
32        let cache_read_cost = self.cache_read as f64 * model.cost.cache_read / 1_000_000.0;
33        let cache_write_cost = self.cache_write as f64 * model.cost.cache_write / 1_000_000.0;
34        CostBreakdown {
35            input_cost,
36            output_cost,
37            cache_read_cost,
38            cache_write_cost,
39        }
40    }
41}
42
43/// Cost breakdown by category (USD).
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct CostBreakdown {
46    /// Input cost in USD.
47    pub input_cost: f64,
48    /// Output cost in USD.
49    pub output_cost: f64,
50    /// Cache read cost in USD.
51    pub cache_read_cost: f64,
52    /// Cache write cost in USD.
53    pub cache_write_cost: f64,
54}
55
56impl CostBreakdown {
57    /// Total cost across all categories.
58    pub fn total(&self) -> f64 {
59        self.input_cost + self.output_cost + self.cache_read_cost + self.cache_write_cost
60    }
61}
62
63/// Per-agent cost snapshot.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct CostSnapshot {
66    /// ID of the agent this snapshot covers.
67    pub agent_id: String,
68    /// Token usage accumulated for the agent.
69    pub usage: TokenUsage,
70    /// Computed cost breakdown for the agent.
71    pub cost: CostBreakdown,
72    /// Remaining budget in USD, if a budget is set.
73    pub budget_remaining: Option<f64>,
74}
75
76/// Global cost snapshot across all agents.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct GlobalCostSnapshot {
79    /// Number of agents tracked.
80    pub total_agents: usize,
81    /// Aggregate token usage across all agents.
82    pub total_usage: TokenUsage,
83    /// Aggregate cost across all agents.
84    pub total_cost: CostBreakdown,
85    /// Remaining global budget in USD, if set.
86    pub global_budget_remaining: Option<f64>,
87    /// Per-agent cost snapshots.
88    pub per_agent: Vec<CostSnapshot>,
89}
90
91/// Configuration for the cost tracker.
92#[derive(Debug, Clone, Default)]
93pub struct CostTrackerConfig {
94    /// Budget per agent in USD. `None` = unlimited.
95    pub per_agent_budget: Option<f64>,
96    /// Global budget in USD. `None` = unlimited.
97    pub global_budget: Option<f64>,
98}
99
100/// Cost tracker — accumulates token usage and computed costs per agent.
101///
102/// Usage: call `record(agent_id, model, usage)` after each LLM call,
103/// then query `snapshot(agent_id)` or `global_snapshot()`.
104pub struct CostTracker {
105    usage: Arc<RwLock<HashMap<String, TokenUsage>>>,
106    costs: Arc<RwLock<HashMap<String, CostBreakdown>>>,
107    _model_registry: Arc<ModelRegistry>,
108    config: CostTrackerConfig,
109}
110
111impl CostTracker {
112    /// Create a new cost tracker.
113    pub fn new(model_registry: Arc<ModelRegistry>, config: CostTrackerConfig) -> Self {
114        Self {
115            usage: Arc::new(RwLock::new(HashMap::new())),
116            costs: Arc::new(RwLock::new(HashMap::new())),
117            _model_registry: model_registry,
118            config,
119        }
120    }
121
122    /// Record token usage for an agent using a model's pricing.
123    pub fn record(&self, agent_id: &str, model: &Model, usage: TokenUsage) {
124        let cost = usage.cost(model);
125
126        self.usage
127            .write()
128            .entry(agent_id.into())
129            .and_modify(|u| {
130                u.input += usage.input;
131                u.output += usage.output;
132                u.cache_read += usage.cache_read;
133                u.cache_write += usage.cache_write;
134            })
135            .or_insert(usage);
136
137        self.costs
138            .write()
139            .entry(agent_id.into())
140            .and_modify(|c| {
141                c.input_cost += cost.input_cost;
142                c.output_cost += cost.output_cost;
143                c.cache_read_cost += cost.cache_read_cost;
144                c.cache_write_cost += cost.cache_write_cost;
145            })
146            .or_insert(cost);
147    }
148
149    /// Snapshot for a specific agent.
150    pub fn snapshot(&self, agent_id: &str) -> Option<CostSnapshot> {
151        let usage = self.usage.read().get(agent_id)?.clone();
152        let cost = self.costs.read().get(agent_id)?.clone();
153        let budget_remaining = self.config.per_agent_budget.map(|b| b - cost.total());
154        Some(CostSnapshot {
155            agent_id: agent_id.into(),
156            usage,
157            cost,
158            budget_remaining,
159        })
160    }
161
162    /// Global snapshot across all agents.
163    pub fn global_snapshot(&self) -> GlobalCostSnapshot {
164        let usage_guard = self.usage.read();
165        let cost_guard = self.costs.read();
166
167        let total_usage = usage_guard
168            .values()
169            .fold(TokenUsage::default(), |mut acc, u| {
170                acc.input += u.input;
171                acc.output += u.output;
172                acc.cache_read += u.cache_read;
173                acc.cache_write += u.cache_write;
174                acc
175            });
176
177        let total_cost = cost_guard
178            .values()
179            .fold(CostBreakdown::default(), |mut acc, c| {
180                acc.input_cost += c.input_cost;
181                acc.output_cost += c.output_cost;
182                acc.cache_read_cost += c.cache_read_cost;
183                acc.cache_write_cost += c.cache_write_cost;
184                acc
185            });
186
187        let global_budget_remaining = self.config.global_budget.map(|b| b - total_cost.total());
188        let per_agent = usage_guard
189            .keys()
190            .map(|id| CostSnapshot {
191                agent_id: id.clone(),
192                usage: usage_guard.get(id).cloned().unwrap_or_default(),
193                cost: cost_guard.get(id).cloned().unwrap_or_default(),
194                budget_remaining: self
195                    .config
196                    .per_agent_budget
197                    .map(|b| b - cost_guard.get(id).map(|c| c.total()).unwrap_or(0.0)),
198            })
199            .collect();
200
201        GlobalCostSnapshot {
202            total_agents: usage_guard.len(),
203            total_usage,
204            total_cost,
205            global_budget_remaining,
206            per_agent,
207        }
208    }
209
210    /// Returns `true` if the agent has exceeded its per-agent budget.
211    pub fn is_over_budget(&self, agent_id: &str) -> bool {
212        if let Some(budget) = self.config.per_agent_budget {
213            let cost = self
214                .costs
215                .read()
216                .get(agent_id)
217                .map(|c| c.total())
218                .unwrap_or(0.0);
219            cost > budget
220        } else {
221            false
222        }
223    }
224
225    /// Returns `true` if the total across all agents exceeds the global budget.
226    pub fn is_over_global_budget(&self) -> bool {
227        if let Some(budget) = self.config.global_budget {
228            let total = self.costs.read().values().map(|c| c.total()).sum::<f64>();
229            total > budget
230        } else {
231            false
232        }
233    }
234
235    /// Get current total cost for an agent.
236    pub fn agent_cost(&self, agent_id: &str) -> f64 {
237        self.costs
238            .read()
239            .get(agent_id)
240            .map(|c| c.total())
241            .unwrap_or(0.0)
242    }
243
244    /// Reset counters for a specific agent.
245    pub fn reset(&self, agent_id: &str) {
246        self.usage.write().remove(agent_id);
247        self.costs.write().remove(agent_id);
248    }
249
250    /// Reset all counters.
251    pub fn reset_all(&self) {
252        self.usage.write().clear();
253        self.costs.write().clear();
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use oxicode_ai::Api;
261
262    fn test_model() -> Model {
263        let mut m = Model::new(
264            "gpt-4o",
265            "GPT-4o",
266            Api::AnthropicMessages,
267            "openai",
268            "https://api.openai.com",
269        );
270        m.cost.input = 2.5; // $2.50 / M
271        m.cost.output = 10.0; // $10 / M
272        m.cost.cache_read = 1.25;
273        m.cost.cache_write = 0.0;
274        m
275    }
276
277    #[test]
278    fn token_usage_total() {
279        let usage = TokenUsage {
280            input: 1000,
281            output: 500,
282            cache_read: 200,
283            cache_write: 100,
284        };
285        assert_eq!(usage.total(), 1800);
286    }
287
288    #[test]
289    fn token_usage_cost() {
290        let usage = TokenUsage {
291            input: 1_000_000,
292            output: 500_000,
293            cache_read: 0,
294            cache_write: 0,
295        };
296        let cost = usage.cost(&test_model());
297        assert!((cost.input_cost - 2.5).abs() < 1e-9);
298        assert!((cost.output_cost - 5.0).abs() < 1e-9);
299    }
300
301    #[test]
302    fn cost_breakdown_total() {
303        let cost = CostBreakdown {
304            input_cost: 1.0,
305            output_cost: 2.0,
306            cache_read_cost: 0.5,
307            cache_write_cost: 0.0,
308        };
309        assert!((cost.total() - 3.5).abs() < f64::EPSILON);
310    }
311
312    #[test]
313    fn cost_tracker_record() {
314        let registry = Arc::new(ModelRegistry::new());
315        let tracker = CostTracker::new(registry, CostTrackerConfig::default());
316
317        let model = test_model();
318        let usage = TokenUsage {
319            input: 1_000_000,
320            output: 500_000,
321            cache_read: 0,
322            cache_write: 0,
323        };
324        tracker.record("agent-1", &model, usage.clone());
325
326        let snap = tracker.snapshot("agent-1").unwrap();
327        assert_eq!(snap.agent_id, "agent-1");
328        assert_eq!(snap.usage.input, 1_000_000);
329        assert!((snap.cost.total() - 7.5).abs() < 1e-6);
330    }
331
332    #[test]
333    fn cost_tracker_accumulation() {
334        let registry = Arc::new(ModelRegistry::new());
335        let tracker = CostTracker::new(registry, CostTrackerConfig::default());
336
337        let model = test_model();
338        tracker.record(
339            "a1",
340            &model,
341            TokenUsage {
342                input: 100,
343                output: 0,
344                cache_read: 0,
345                cache_write: 0,
346            },
347        );
348        tracker.record(
349            "a1",
350            &model,
351            TokenUsage {
352                input: 100,
353                output: 0,
354                cache_read: 0,
355                cache_write: 0,
356            },
357        );
358
359        let snap = tracker.snapshot("a1").unwrap();
360        assert_eq!(snap.usage.input, 200);
361    }
362
363    #[test]
364    fn cost_tracker_budget_check() {
365        let registry = Arc::new(ModelRegistry::new());
366        let tracker = CostTracker::new(
367            registry,
368            CostTrackerConfig {
369                per_agent_budget: Some(1.0),
370                global_budget: None,
371            },
372        );
373
374        let model = test_model();
375        // $2.50/M input × 1M = $2.50 > $1.00 budget
376        tracker.record(
377            "a1",
378            &model,
379            TokenUsage {
380                input: 1_000_000,
381                output: 0,
382                cache_read: 0,
383                cache_write: 0,
384            },
385        );
386
387        assert!(tracker.is_over_budget("a1"));
388    }
389
390    #[test]
391    fn cost_tracker_reset() {
392        let registry = Arc::new(ModelRegistry::new());
393        let tracker = CostTracker::new(registry, CostTrackerConfig::default());
394
395        let model = test_model();
396        tracker.record(
397            "a1",
398            &model,
399            TokenUsage {
400                input: 100,
401                output: 0,
402                cache_read: 0,
403                cache_write: 0,
404            },
405        );
406
407        tracker.reset("a1");
408        assert!(tracker.snapshot("a1").is_none());
409    }
410
411    #[test]
412    fn cost_tracker_global_snapshot() {
413        let registry = Arc::new(ModelRegistry::new());
414        let tracker = CostTracker::new(registry, CostTrackerConfig::default());
415
416        let model = test_model();
417        tracker.record(
418            "a1",
419            &model,
420            TokenUsage {
421                input: 1_000_000,
422                output: 0,
423                cache_read: 0,
424                cache_write: 0,
425            },
426        );
427        tracker.record(
428            "a2",
429            &model,
430            TokenUsage {
431                input: 500_000,
432                output: 0,
433                cache_read: 0,
434                cache_write: 0,
435            },
436        );
437
438        let global = tracker.global_snapshot();
439        assert_eq!(global.total_agents, 2);
440        assert_eq!(global.total_usage.input, 1_500_000);
441        assert!((global.total_cost.input_cost - 3.75).abs() < 1e-6);
442    }
443}