1use std::time::Instant;
24
25pub const SERVICE_NAME: &str = "a3s-code";
31
32pub const SPAN_AGENT_EXECUTE: &str = "a3s.agent.execute";
34pub const SPAN_AGENT_TURN: &str = "a3s.agent.turn";
35pub const SPAN_LLM_COMPLETION: &str = "a3s.llm.completion";
36pub const SPAN_TOOL_EXECUTE: &str = "a3s.tool.execute";
37pub const SPAN_CONTEXT_RESOLVE: &str = "a3s.agent.context_resolve";
38
39pub const ATTR_SESSION_ID: &str = "a3s.session.id";
41pub const ATTR_TURN_NUMBER: &str = "a3s.agent.turn_number";
42pub const ATTR_MAX_TURNS: &str = "a3s.agent.max_turns";
43pub const ATTR_TOOL_CALLS_COUNT: &str = "a3s.agent.tool_calls_count";
44
45pub const ATTR_LLM_MODEL: &str = "a3s.llm.model";
46pub const ATTR_LLM_PROVIDER: &str = "a3s.llm.provider";
47pub const ATTR_LLM_STREAMING: &str = "a3s.llm.streaming";
48pub const ATTR_LLM_PROMPT_TOKENS: &str = "a3s.llm.prompt_tokens";
49pub const ATTR_LLM_COMPLETION_TOKENS: &str = "a3s.llm.completion_tokens";
50pub const ATTR_LLM_TOTAL_TOKENS: &str = "a3s.llm.total_tokens";
51pub const ATTR_LLM_STOP_REASON: &str = "a3s.llm.stop_reason";
52
53pub const ATTR_TOOL_NAME: &str = "a3s.tool.name";
54pub const ATTR_TOOL_ID: &str = "a3s.tool.id";
55pub const ATTR_TOOL_EXIT_CODE: &str = "a3s.tool.exit_code";
56pub const ATTR_TOOL_SUCCESS: &str = "a3s.tool.success";
57pub const ATTR_TOOL_DURATION_MS: &str = "a3s.tool.duration_ms";
58pub const ATTR_TOOL_PERMISSION: &str = "a3s.tool.permission";
59
60pub const ATTR_CONTEXT_PROVIDERS: &str = "a3s.context.providers";
61pub const ATTR_CONTEXT_ITEMS: &str = "a3s.context.items";
62pub const ATTR_CONTEXT_TOKENS: &str = "a3s.context.tokens";
63
64pub fn record_llm_usage(
70 prompt_tokens: usize,
71 completion_tokens: usize,
72 total_tokens: usize,
73 stop_reason: Option<&str>,
74) {
75 let span = tracing::Span::current();
76 span.record(ATTR_LLM_PROMPT_TOKENS, prompt_tokens as i64);
77 span.record(ATTR_LLM_COMPLETION_TOKENS, completion_tokens as i64);
78 span.record(ATTR_LLM_TOTAL_TOKENS, total_tokens as i64);
79 if let Some(reason) = stop_reason {
80 span.record(ATTR_LLM_STOP_REASON, reason);
81 }
82}
83
84pub fn record_tool_result(exit_code: i32, duration: std::time::Duration) {
86 let span = tracing::Span::current();
87 span.record(ATTR_TOOL_EXIT_CODE, exit_code as i64);
88 span.record(ATTR_TOOL_SUCCESS, exit_code == 0);
89 span.record(ATTR_TOOL_DURATION_MS, duration.as_millis() as i64);
90}
91
92pub struct TimedSpan {
94 start: Instant,
95 span_field: &'static str,
96}
97
98impl TimedSpan {
99 pub fn new(span_field: &'static str) -> Self {
100 Self {
101 start: Instant::now(),
102 span_field,
103 }
104 }
105
106 pub fn elapsed_ms(&self) -> u64 {
107 self.start.elapsed().as_millis() as u64
108 }
109}
110
111impl Drop for TimedSpan {
112 fn drop(&mut self) {
113 let elapsed_ms = self.elapsed_ms();
114 let span = tracing::Span::current();
115 span.record(self.span_field, elapsed_ms as i64);
116 }
117}
118
119#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
125pub struct LlmCostRecord {
126 pub model: String,
128 pub provider: String,
130 pub prompt_tokens: usize,
132 pub completion_tokens: usize,
134 pub total_tokens: usize,
136 pub cost_usd: Option<f64>,
138 pub timestamp: chrono::DateTime<chrono::Utc>,
140 pub session_id: Option<String>,
142}
143
144#[derive(Debug, Clone)]
146pub struct ModelPricing {
147 pub input_per_million: f64,
149 pub output_per_million: f64,
151}
152
153impl ModelPricing {
154 pub fn new(input_per_million: f64, output_per_million: f64) -> Self {
155 Self {
156 input_per_million,
157 output_per_million,
158 }
159 }
160
161 pub fn calculate_cost(&self, prompt_tokens: usize, completion_tokens: usize) -> f64 {
163 let input_cost = (prompt_tokens as f64 / 1_000_000.0) * self.input_per_million;
164 let output_cost = (completion_tokens as f64 / 1_000_000.0) * self.output_per_million;
165 input_cost + output_cost
166 }
167}
168
169pub fn default_model_pricing() -> std::collections::HashMap<String, ModelPricing> {
171 let mut pricing = std::collections::HashMap::new();
172
173 pricing.insert(
175 "claude-sonnet-4-20250514".to_string(),
176 ModelPricing::new(3.0, 15.0),
177 );
178 pricing.insert(
179 "claude-3-5-sonnet-20241022".to_string(),
180 ModelPricing::new(3.0, 15.0),
181 );
182 pricing.insert(
183 "claude-3-haiku-20240307".to_string(),
184 ModelPricing::new(0.25, 1.25),
185 );
186 pricing.insert(
187 "claude-3-opus-20240229".to_string(),
188 ModelPricing::new(15.0, 75.0),
189 );
190
191 pricing.insert("gpt-4o".to_string(), ModelPricing::new(2.5, 10.0));
193 pricing.insert("gpt-4o-mini".to_string(), ModelPricing::new(0.15, 0.6));
194 pricing.insert("gpt-4-turbo".to_string(), ModelPricing::new(10.0, 30.0));
195
196 pricing
197}
198
199#[derive(Debug, Clone, Default)]
205pub struct ToolMetrics {
206 stats: std::collections::HashMap<String, ToolStats>,
208 total_calls: u64,
210 total_duration_ms: u64,
212}
213
214#[derive(Debug, Clone)]
216pub struct ToolStats {
217 pub tool_name: String,
218 pub total_calls: u64,
219 pub success_count: u64,
220 pub failure_count: u64,
221 pub total_duration_ms: u64,
222 pub min_duration_ms: u64,
223 pub max_duration_ms: u64,
224 pub avg_duration_ms: u64,
225 pub last_called_at: Option<chrono::DateTime<chrono::Utc>>,
226}
227
228impl ToolMetrics {
229 pub fn new() -> Self {
230 Self::default()
231 }
232
233 pub fn record(&mut self, tool_name: &str, success: bool, duration_ms: u64) {
235 self.total_calls += 1;
236 self.total_duration_ms += duration_ms;
237
238 let entry = self
239 .stats
240 .entry(tool_name.to_string())
241 .or_insert_with(|| ToolStats {
242 tool_name: tool_name.to_string(),
243 total_calls: 0,
244 success_count: 0,
245 failure_count: 0,
246 total_duration_ms: 0,
247 min_duration_ms: u64::MAX,
248 max_duration_ms: 0,
249 avg_duration_ms: 0,
250 last_called_at: None,
251 });
252
253 entry.total_calls += 1;
254 if success {
255 entry.success_count += 1;
256 } else {
257 entry.failure_count += 1;
258 }
259 entry.total_duration_ms += duration_ms;
260 entry.min_duration_ms = entry.min_duration_ms.min(duration_ms);
261 entry.max_duration_ms = entry.max_duration_ms.max(duration_ms);
262 entry.avg_duration_ms = entry.total_duration_ms / entry.total_calls;
263 entry.last_called_at = Some(chrono::Utc::now());
264 }
265
266 pub fn stats(&self) -> Vec<ToolStats> {
268 self.stats.values().cloned().collect()
269 }
270
271 pub fn stats_for(&self, tool_name: &str) -> Vec<ToolStats> {
273 self.stats
274 .get(tool_name)
275 .map(|s| vec![s.clone()])
276 .unwrap_or_default()
277 }
278
279 pub fn total_calls(&self) -> u64 {
281 self.total_calls
282 }
283
284 pub fn total_duration_ms(&self) -> u64 {
286 self.total_duration_ms
287 }
288}
289
290#[derive(Debug, Clone)]
296pub struct CostSummary {
297 pub total_cost_usd: f64,
298 pub total_prompt_tokens: usize,
299 pub total_completion_tokens: usize,
300 pub total_tokens: usize,
301 pub call_count: usize,
302 pub by_model: Vec<ModelCostBreakdown>,
303 pub by_day: Vec<DayCostBreakdown>,
304}
305
306#[derive(Debug, Clone)]
308pub struct ModelCostBreakdown {
309 pub model: String,
310 pub prompt_tokens: usize,
311 pub completion_tokens: usize,
312 pub total_tokens: usize,
313 pub cost_usd: f64,
314 pub call_count: usize,
315}
316
317#[derive(Debug, Clone)]
319pub struct DayCostBreakdown {
320 pub date: String,
321 pub cost_usd: f64,
322 pub call_count: usize,
323 pub total_tokens: usize,
324}
325
326pub fn aggregate_cost_records(
328 records: &[LlmCostRecord],
329 model_filter: Option<&str>,
330 start_date: Option<&str>,
331 end_date: Option<&str>,
332) -> CostSummary {
333 let filtered: Vec<&LlmCostRecord> = records
334 .iter()
335 .filter(|r| {
336 if let Some(model) = model_filter {
337 if r.model != model {
338 return false;
339 }
340 }
341 if let Some(start) = start_date {
342 let date_str = r.timestamp.format("%Y-%m-%d").to_string();
343 if date_str.as_str() < start {
344 return false;
345 }
346 }
347 if let Some(end) = end_date {
348 let date_str = r.timestamp.format("%Y-%m-%d").to_string();
349 if date_str.as_str() > end {
350 return false;
351 }
352 }
353 true
354 })
355 .collect();
356
357 let mut by_model_map: std::collections::HashMap<String, ModelCostBreakdown> =
358 std::collections::HashMap::new();
359 let mut by_day_map: std::collections::HashMap<String, DayCostBreakdown> =
360 std::collections::HashMap::new();
361
362 let mut total_cost_usd = 0.0;
363 let mut total_prompt_tokens = 0usize;
364 let mut total_completion_tokens = 0usize;
365 let mut total_tokens = 0usize;
366
367 for record in &filtered {
368 let cost = record.cost_usd.unwrap_or(0.0);
369 total_cost_usd += cost;
370 total_prompt_tokens += record.prompt_tokens;
371 total_completion_tokens += record.completion_tokens;
372 total_tokens += record.total_tokens;
373
374 let model_entry =
376 by_model_map
377 .entry(record.model.clone())
378 .or_insert_with(|| ModelCostBreakdown {
379 model: record.model.clone(),
380 prompt_tokens: 0,
381 completion_tokens: 0,
382 total_tokens: 0,
383 cost_usd: 0.0,
384 call_count: 0,
385 });
386 model_entry.prompt_tokens += record.prompt_tokens;
387 model_entry.completion_tokens += record.completion_tokens;
388 model_entry.total_tokens += record.total_tokens;
389 model_entry.cost_usd += cost;
390 model_entry.call_count += 1;
391
392 let date_str = record.timestamp.format("%Y-%m-%d").to_string();
394 let day_entry = by_day_map
395 .entry(date_str.clone())
396 .or_insert_with(|| DayCostBreakdown {
397 date: date_str,
398 cost_usd: 0.0,
399 call_count: 0,
400 total_tokens: 0,
401 });
402 day_entry.cost_usd += cost;
403 day_entry.call_count += 1;
404 day_entry.total_tokens += record.total_tokens;
405 }
406
407 let mut by_model: Vec<ModelCostBreakdown> = by_model_map.into_values().collect();
408 by_model.sort_by(|a, b| {
409 b.cost_usd
410 .partial_cmp(&a.cost_usd)
411 .unwrap_or(std::cmp::Ordering::Equal)
412 });
413
414 let mut by_day: Vec<DayCostBreakdown> = by_day_map.into_values().collect();
415 by_day.sort_by(|a, b| a.date.cmp(&b.date));
416
417 CostSummary {
418 total_cost_usd,
419 total_prompt_tokens,
420 total_completion_tokens,
421 total_tokens,
422 call_count: filtered.len(),
423 by_model,
424 by_day,
425 }
426}
427
428pub fn record_llm_metrics(
434 model: &str,
435 prompt_tokens: usize,
436 completion_tokens: usize,
437 cost_usd: f64,
438 duration_secs: f64,
439) {
440 tracing::info!(
441 model = model,
442 prompt_tokens = prompt_tokens,
443 completion_tokens = completion_tokens,
444 total_tokens = prompt_tokens + completion_tokens,
445 cost_usd = cost_usd,
446 duration_secs = duration_secs,
447 "llm.metrics"
448 );
449}
450
451#[cfg(test)]
456mod tests {
457 use super::*;
458
459 #[test]
460 fn test_model_pricing_calculation() {
461 let pricing = ModelPricing::new(3.0, 15.0); let cost = pricing.calculate_cost(1000, 500);
465 let expected = (1000.0 / 1_000_000.0) * 3.0 + (500.0 / 1_000_000.0) * 15.0;
466 assert!((cost - expected).abs() < f64::EPSILON);
467 }
468
469 #[test]
470 fn test_model_pricing_zero_tokens() {
471 let pricing = ModelPricing::new(3.0, 15.0);
472 let cost = pricing.calculate_cost(0, 0);
473 assert_eq!(cost, 0.0);
474 }
475
476 #[test]
477 fn test_model_pricing_large_tokens() {
478 let pricing = ModelPricing::new(3.0, 15.0);
479 let cost = pricing.calculate_cost(1_000_000, 1_000_000);
481 assert!((cost - 18.0).abs() < f64::EPSILON); }
483
484 #[test]
485 fn test_default_model_pricing_has_known_models() {
486 let pricing = default_model_pricing();
487 assert!(pricing.contains_key("claude-sonnet-4-20250514"));
488 assert!(pricing.contains_key("claude-3-5-sonnet-20241022"));
489 assert!(pricing.contains_key("claude-3-haiku-20240307"));
490 assert!(pricing.contains_key("gpt-4o"));
491 assert!(pricing.contains_key("gpt-4o-mini"));
492 }
493
494 #[test]
495 fn test_llm_cost_record_serialize() {
496 let record = LlmCostRecord {
497 model: "claude-sonnet-4-20250514".to_string(),
498 provider: "anthropic".to_string(),
499 prompt_tokens: 1000,
500 completion_tokens: 500,
501 total_tokens: 1500,
502 cost_usd: Some(0.0105),
503 timestamp: chrono::Utc::now(),
504 session_id: Some("sess-123".to_string()),
505 };
506
507 let json = serde_json::to_string(&record).unwrap();
508 assert!(json.contains("claude-sonnet-4-20250514"));
509 assert!(json.contains("anthropic"));
510 assert!(json.contains("1000"));
511
512 let deserialized: LlmCostRecord = serde_json::from_str(&json).unwrap();
514 assert_eq!(deserialized.model, "claude-sonnet-4-20250514");
515 assert_eq!(deserialized.prompt_tokens, 1000);
516 }
517
518 #[test]
519 fn test_timed_span_elapsed() {
520 let timer = TimedSpan::new(ATTR_TOOL_DURATION_MS);
521 std::thread::sleep(std::time::Duration::from_millis(10));
522 assert!(timer.elapsed_ms() >= 10);
523 }
524
525 #[test]
526 fn test_span_name_constants() {
527 assert!(SPAN_AGENT_EXECUTE.starts_with("a3s."));
529 assert!(SPAN_AGENT_TURN.starts_with("a3s."));
530 assert!(SPAN_LLM_COMPLETION.starts_with("a3s."));
531 assert!(SPAN_TOOL_EXECUTE.starts_with("a3s."));
532 assert!(SPAN_CONTEXT_RESOLVE.starts_with("a3s."));
533 }
534
535 #[test]
536 fn test_attribute_key_constants() {
537 assert!(ATTR_SESSION_ID.starts_with("a3s."));
539 assert!(ATTR_LLM_MODEL.starts_with("a3s."));
540 assert!(ATTR_TOOL_NAME.starts_with("a3s."));
541 assert!(ATTR_CONTEXT_PROVIDERS.starts_with("a3s."));
542 }
543
544 #[test]
545 fn test_record_llm_usage_does_not_panic() {
546 record_llm_usage(100, 50, 150, Some("end_turn"));
548 record_llm_usage(0, 0, 0, None);
549 record_llm_usage(1_000_000, 500_000, 1_500_000, Some("max_tokens"));
550 }
551
552 #[test]
553 fn test_record_tool_result_does_not_panic() {
554 record_tool_result(0, std::time::Duration::from_millis(100));
556 record_tool_result(1, std::time::Duration::from_secs(0));
557 record_tool_result(-1, std::time::Duration::from_secs(30));
558 }
559
560 #[test]
561 fn test_timed_span_measures_duration() {
562 let timer = TimedSpan::new(ATTR_TOOL_DURATION_MS);
563 assert!(timer.elapsed_ms() < 1000);
565 }
566
567 #[test]
568 fn test_model_pricing_registry_completeness() {
569 let pricing = default_model_pricing();
570 let anthropic_models: Vec<&str> = pricing
572 .keys()
573 .filter(|k| k.starts_with("claude"))
574 .map(|k| k.as_str())
575 .collect();
576 assert!(
577 anthropic_models.len() >= 3,
578 "Expected at least 3 Anthropic models, got {}",
579 anthropic_models.len()
580 );
581
582 let openai_models: Vec<&str> = pricing
583 .keys()
584 .filter(|k| k.starts_with("gpt"))
585 .map(|k| k.as_str())
586 .collect();
587 assert!(
588 openai_models.len() >= 2,
589 "Expected at least 2 OpenAI models, got {}",
590 openai_models.len()
591 );
592 }
593
594 #[test]
595 fn test_model_pricing_cost_ordering() {
596 let pricing = default_model_pricing();
597 let haiku = pricing.get("claude-3-haiku-20240307").unwrap();
599 let sonnet = pricing.get("claude-sonnet-4-20250514").unwrap();
600 assert!(
601 haiku.input_per_million < sonnet.input_per_million,
602 "Haiku should be cheaper than Sonnet"
603 );
604
605 let mini = pricing.get("gpt-4o-mini").unwrap();
607 let full = pricing.get("gpt-4o").unwrap();
608 assert!(
609 mini.input_per_million < full.input_per_million,
610 "GPT-4o-mini should be cheaper than GPT-4o"
611 );
612 }
613
614 #[test]
615 fn test_llm_cost_record_fields() {
616 let record = LlmCostRecord {
617 model: "gpt-4o".to_string(),
618 provider: "openai".to_string(),
619 prompt_tokens: 500,
620 completion_tokens: 200,
621 total_tokens: 700,
622 cost_usd: None,
623 timestamp: chrono::Utc::now(),
624 session_id: None,
625 };
626 assert_eq!(
627 record.total_tokens,
628 record.prompt_tokens + record.completion_tokens
629 );
630 assert!(record.cost_usd.is_none());
631 assert!(record.session_id.is_none());
632 }
633
634 #[test]
635 fn test_attribute_keys_are_unique() {
636 let keys = vec![
638 ATTR_SESSION_ID,
639 ATTR_TURN_NUMBER,
640 ATTR_MAX_TURNS,
641 ATTR_TOOL_CALLS_COUNT,
642 ATTR_LLM_MODEL,
643 ATTR_LLM_PROVIDER,
644 ATTR_LLM_STREAMING,
645 ATTR_LLM_PROMPT_TOKENS,
646 ATTR_LLM_COMPLETION_TOKENS,
647 ATTR_LLM_TOTAL_TOKENS,
648 ATTR_LLM_STOP_REASON,
649 ATTR_TOOL_NAME,
650 ATTR_TOOL_ID,
651 ATTR_TOOL_EXIT_CODE,
652 ATTR_TOOL_SUCCESS,
653 ATTR_TOOL_DURATION_MS,
654 ATTR_TOOL_PERMISSION,
655 ATTR_CONTEXT_PROVIDERS,
656 ATTR_CONTEXT_ITEMS,
657 ATTR_CONTEXT_TOKENS,
658 ];
659 let unique: std::collections::HashSet<&str> = keys.iter().copied().collect();
660 assert_eq!(keys.len(), unique.len(), "Attribute keys must be unique");
661 }
662
663 #[test]
664 fn test_model_pricing_new() {
665 let pricing = ModelPricing::new(3.0, 15.0);
666 assert_eq!(pricing.input_per_million, 3.0);
667 assert_eq!(pricing.output_per_million, 15.0);
668 }
669
670 #[test]
671 fn test_model_pricing_calculate_cost_zero() {
672 let pricing = ModelPricing::new(3.0, 15.0);
673 let cost = pricing.calculate_cost(0, 0);
674 assert_eq!(cost, 0.0);
675 }
676
677 #[test]
678 fn test_model_pricing_calculate_cost_large() {
679 let pricing = ModelPricing::new(3.0, 15.0);
680 let cost = pricing.calculate_cost(1_000_000, 1_000_000);
681 assert!((cost - 18.0).abs() < f64::EPSILON);
682 }
683
684 #[test]
685 fn test_model_pricing_calculate_cost_fractional() {
686 let pricing = ModelPricing::new(3.0, 15.0);
687 let cost = pricing.calculate_cost(500, 250);
688 let expected = (500.0 / 1_000_000.0) * 3.0 + (250.0 / 1_000_000.0) * 15.0;
689 assert!((cost - expected).abs() < f64::EPSILON);
690 }
691
692 #[test]
693 fn test_model_pricing_clone() {
694 let pricing = ModelPricing::new(3.0, 15.0);
695 let cloned = pricing.clone();
696 assert_eq!(cloned.input_per_million, 3.0);
697 assert_eq!(cloned.output_per_million, 15.0);
698 }
699
700 #[test]
701 fn test_model_pricing_debug() {
702 let pricing = ModelPricing::new(3.0, 15.0);
703 let debug_str = format!("{:?}", pricing);
704 assert!(debug_str.contains("ModelPricing"));
705 assert!(debug_str.contains("3.0"));
706 assert!(debug_str.contains("15.0"));
707 }
708
709 #[test]
710 fn test_default_model_pricing_all_positive() {
711 let pricing = default_model_pricing();
712 for (model, price) in pricing.iter() {
713 assert!(
714 price.input_per_million > 0.0,
715 "Model {} has non-positive input cost",
716 model
717 );
718 assert!(
719 price.output_per_million > 0.0,
720 "Model {} has non-positive output cost",
721 model
722 );
723 }
724 }
725
726 #[test]
727 fn test_default_model_pricing_output_greater_than_input() {
728 let pricing = default_model_pricing();
729 for (model, price) in pricing.iter() {
730 assert!(
731 price.output_per_million > price.input_per_million,
732 "Model {} output cost should be greater than input cost",
733 model
734 );
735 }
736 }
737
738 #[test]
739 fn test_default_model_pricing_claude_sonnet_4() {
740 let pricing = default_model_pricing();
741 let sonnet = pricing.get("claude-sonnet-4-20250514").unwrap();
742 assert_eq!(sonnet.input_per_million, 3.0);
743 assert_eq!(sonnet.output_per_million, 15.0);
744 }
745
746 #[test]
747 fn test_default_model_pricing_claude_haiku() {
748 let pricing = default_model_pricing();
749 let haiku = pricing.get("claude-3-haiku-20240307").unwrap();
750 assert_eq!(haiku.input_per_million, 0.25);
751 assert_eq!(haiku.output_per_million, 1.25);
752 }
753
754 #[test]
755 fn test_default_model_pricing_gpt4o() {
756 let pricing = default_model_pricing();
757 let gpt4o = pricing.get("gpt-4o").unwrap();
758 assert_eq!(gpt4o.input_per_million, 2.5);
759 assert_eq!(gpt4o.output_per_million, 10.0);
760 }
761
762 #[test]
763 fn test_llm_cost_record_with_cost() {
764 let record = LlmCostRecord {
765 model: "claude-sonnet-4-20250514".to_string(),
766 provider: "anthropic".to_string(),
767 prompt_tokens: 1000,
768 completion_tokens: 500,
769 total_tokens: 1500,
770 cost_usd: Some(0.0105),
771 timestamp: chrono::Utc::now(),
772 session_id: Some("sess-123".to_string()),
773 };
774 assert_eq!(record.cost_usd, Some(0.0105));
775 }
776
777 #[test]
778 fn test_llm_cost_record_without_cost() {
779 let record = LlmCostRecord {
780 model: "unknown-model".to_string(),
781 provider: "unknown".to_string(),
782 prompt_tokens: 100,
783 completion_tokens: 50,
784 total_tokens: 150,
785 cost_usd: None,
786 timestamp: chrono::Utc::now(),
787 session_id: None,
788 };
789 assert!(record.cost_usd.is_none());
790 }
791
792 #[test]
793 fn test_llm_cost_record_with_session() {
794 let record = LlmCostRecord {
795 model: "gpt-4o".to_string(),
796 provider: "openai".to_string(),
797 prompt_tokens: 500,
798 completion_tokens: 200,
799 total_tokens: 700,
800 cost_usd: Some(0.003),
801 timestamp: chrono::Utc::now(),
802 session_id: Some("session-abc".to_string()),
803 };
804 assert_eq!(record.session_id, Some("session-abc".to_string()));
805 }
806
807 #[test]
808 fn test_llm_cost_record_without_session() {
809 let record = LlmCostRecord {
810 model: "gpt-4o-mini".to_string(),
811 provider: "openai".to_string(),
812 prompt_tokens: 100,
813 completion_tokens: 50,
814 total_tokens: 150,
815 cost_usd: Some(0.00006),
816 timestamp: chrono::Utc::now(),
817 session_id: None,
818 };
819 assert!(record.session_id.is_none());
820 }
821
822 #[test]
823 fn test_llm_cost_record_serialization() {
824 let record = LlmCostRecord {
825 model: "claude-sonnet-4-20250514".to_string(),
826 provider: "anthropic".to_string(),
827 prompt_tokens: 1000,
828 completion_tokens: 500,
829 total_tokens: 1500,
830 cost_usd: Some(0.0105),
831 timestamp: chrono::Utc::now(),
832 session_id: Some("sess-123".to_string()),
833 };
834 let json = serde_json::to_string(&record).unwrap();
835 assert!(json.contains("claude-sonnet-4-20250514"));
836 assert!(json.contains("anthropic"));
837 assert!(json.contains("1000"));
838 assert!(json.contains("500"));
839 }
840
841 #[test]
842 fn test_llm_cost_record_zero_tokens() {
843 let record = LlmCostRecord {
844 model: "test-model".to_string(),
845 provider: "test".to_string(),
846 prompt_tokens: 0,
847 completion_tokens: 0,
848 total_tokens: 0,
849 cost_usd: Some(0.0),
850 timestamp: chrono::Utc::now(),
851 session_id: None,
852 };
853 assert_eq!(record.prompt_tokens, 0);
854 assert_eq!(record.completion_tokens, 0);
855 assert_eq!(record.total_tokens, 0);
856 }
857
858 #[test]
859 fn test_llm_cost_record_clone() {
860 let record = LlmCostRecord {
861 model: "gpt-4o".to_string(),
862 provider: "openai".to_string(),
863 prompt_tokens: 100,
864 completion_tokens: 50,
865 total_tokens: 150,
866 cost_usd: Some(0.001),
867 timestamp: chrono::Utc::now(),
868 session_id: Some("sess-xyz".to_string()),
869 };
870 let cloned = record.clone();
871 assert_eq!(cloned.model, "gpt-4o");
872 assert_eq!(cloned.provider, "openai");
873 assert_eq!(cloned.prompt_tokens, 100);
874 }
875
876 #[test]
877 fn test_timed_span_new() {
878 let timer = TimedSpan::new(ATTR_TOOL_DURATION_MS);
879 assert!(timer.elapsed_ms() < 100);
880 }
881
882 #[test]
883 fn test_timed_span_elapsed_sleep() {
884 let timer = TimedSpan::new(ATTR_TOOL_DURATION_MS);
885 std::thread::sleep(std::time::Duration::from_millis(10));
886 assert!(timer.elapsed_ms() >= 10);
887 }
888
889 #[test]
890 fn test_service_name_constant() {
891 assert_eq!(SERVICE_NAME, "a3s-code");
892 }
893
894 #[test]
895 fn test_span_constants() {
896 assert_eq!(SPAN_AGENT_EXECUTE, "a3s.agent.execute");
897 assert_eq!(SPAN_AGENT_TURN, "a3s.agent.turn");
898 assert_eq!(SPAN_LLM_COMPLETION, "a3s.llm.completion");
899 assert_eq!(SPAN_TOOL_EXECUTE, "a3s.tool.execute");
900 assert_eq!(SPAN_CONTEXT_RESOLVE, "a3s.agent.context_resolve");
901 }
902
903 #[test]
904 fn test_attribute_constants_session() {
905 assert_eq!(ATTR_SESSION_ID, "a3s.session.id");
906 assert_eq!(ATTR_TURN_NUMBER, "a3s.agent.turn_number");
907 assert_eq!(ATTR_MAX_TURNS, "a3s.agent.max_turns");
908 assert_eq!(ATTR_TOOL_CALLS_COUNT, "a3s.agent.tool_calls_count");
909 }
910
911 #[test]
912 fn test_attribute_constants_llm() {
913 assert_eq!(ATTR_LLM_MODEL, "a3s.llm.model");
914 assert_eq!(ATTR_LLM_PROVIDER, "a3s.llm.provider");
915 assert_eq!(ATTR_LLM_STREAMING, "a3s.llm.streaming");
916 assert_eq!(ATTR_LLM_PROMPT_TOKENS, "a3s.llm.prompt_tokens");
917 assert_eq!(ATTR_LLM_COMPLETION_TOKENS, "a3s.llm.completion_tokens");
918 assert_eq!(ATTR_LLM_TOTAL_TOKENS, "a3s.llm.total_tokens");
919 assert_eq!(ATTR_LLM_STOP_REASON, "a3s.llm.stop_reason");
920 }
921
922 #[test]
923 fn test_attribute_constants_tool() {
924 assert_eq!(ATTR_TOOL_NAME, "a3s.tool.name");
925 assert_eq!(ATTR_TOOL_ID, "a3s.tool.id");
926 assert_eq!(ATTR_TOOL_EXIT_CODE, "a3s.tool.exit_code");
927 assert_eq!(ATTR_TOOL_SUCCESS, "a3s.tool.success");
928 assert_eq!(ATTR_TOOL_DURATION_MS, "a3s.tool.duration_ms");
929 assert_eq!(ATTR_TOOL_PERMISSION, "a3s.tool.permission");
930 }
931
932 #[test]
933 fn test_attribute_constants_context() {
934 assert_eq!(ATTR_CONTEXT_PROVIDERS, "a3s.context.providers");
935 assert_eq!(ATTR_CONTEXT_ITEMS, "a3s.context.items");
936 assert_eq!(ATTR_CONTEXT_TOKENS, "a3s.context.tokens");
937 }
938
939 #[test]
940 fn test_record_llm_usage_basic() {
941 record_llm_usage(100, 50, 150, Some("end_turn"));
942 }
943
944 #[test]
945 fn test_record_llm_usage_no_stop_reason() {
946 record_llm_usage(100, 50, 150, None);
947 }
948
949 #[test]
950 fn test_record_tool_result_success() {
951 record_tool_result(0, std::time::Duration::from_millis(100));
952 }
953
954 #[test]
955 fn test_record_tool_result_failure() {
956 record_tool_result(1, std::time::Duration::from_secs(5));
957 }
958}