oxicode_sdk/observability/
cost.rs1use oxicode_ai::{Model, ModelRegistry};
4use parking_lot::RwLock;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::sync::Arc;
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11pub struct TokenUsage {
12 pub input: u64,
14 pub output: u64,
16 pub cache_read: u64,
18 pub cache_write: u64,
20}
21
22impl TokenUsage {
23 pub fn total(&self) -> u64 {
25 self.input + self.output + self.cache_read + self.cache_write
26 }
27
28 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct CostBreakdown {
46 pub input_cost: f64,
48 pub output_cost: f64,
50 pub cache_read_cost: f64,
52 pub cache_write_cost: f64,
54}
55
56impl CostBreakdown {
57 pub fn total(&self) -> f64 {
59 self.input_cost + self.output_cost + self.cache_read_cost + self.cache_write_cost
60 }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct CostSnapshot {
66 pub agent_id: String,
68 pub usage: TokenUsage,
70 pub cost: CostBreakdown,
72 pub budget_remaining: Option<f64>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct GlobalCostSnapshot {
79 pub total_agents: usize,
81 pub total_usage: TokenUsage,
83 pub total_cost: CostBreakdown,
85 pub global_budget_remaining: Option<f64>,
87 pub per_agent: Vec<CostSnapshot>,
89}
90
91#[derive(Debug, Clone, Default)]
93pub struct CostTrackerConfig {
94 pub per_agent_budget: Option<f64>,
96 pub global_budget: Option<f64>,
98}
99
100pub 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 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 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 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 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 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 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 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 pub fn reset(&self, agent_id: &str) {
246 self.usage.write().remove(agent_id);
247 self.costs.write().remove(agent_id);
248 }
249
250 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; m.cost.output = 10.0; 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 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}