1use std::time::Duration;
2
3use relay_conventions::attributes::*;
4use relay_event_schema::protocol::{Attribute, AttributeType, Attributes};
5use relay_protocol::{Annotated, Value};
6
7use crate::ModelMetadata;
8use crate::span::ai;
9use crate::statsd::{Counters, map_origin_to_integration, platform_tag};
10
11pub fn normalize_ai(
19 attributes: &mut Annotated<Attributes>,
20 duration: Option<Duration>,
21 model_metadata: Option<&ModelMetadata>,
22) {
23 let Some(attributes) = attributes.value_mut() else {
24 return;
25 };
26
27 if !is_ai_item(attributes) {
30 return;
31 }
32
33 normalize_model(attributes);
34 normalize_ai_type(attributes);
35 normalize_total_tokens(attributes);
36 normalize_tokens_per_second(attributes, duration);
37 normalize_context_utilization(attributes, model_metadata);
38 normalize_ai_costs(attributes, model_metadata);
39 normalize_tool_call_result_size(attributes);
40}
41
42fn is_ai_item(attributes: &mut Attributes) -> bool {
44 if attributes.get_value(GEN_AI__OPERATION__TYPE).is_some() {
46 return true;
47 }
48
49 if attributes.get_value(GEN_AI__OPERATION__NAME).is_some() {
51 return true;
52 }
53
54 let op = attributes.get_value(SENTRY__OP).and_then(|op| op.as_str());
56 op.is_some_and(|op| op.starts_with("gen_ai.") || op.starts_with("ai."))
57}
58
59fn normalize_model(attributes: &mut Attributes) {
61 if attributes.contains_key(GEN_AI__RESPONSE__MODEL) {
62 return;
63 }
64 let Some(model) = attributes
65 .get_value(GEN_AI__REQUEST__MODEL)
66 .and_then(|v| v.as_str())
67 else {
68 return;
69 };
70 attributes.insert(GEN_AI__RESPONSE__MODEL, model.to_owned());
71}
72
73fn normalize_ai_type(attributes: &mut Attributes) {
75 let op_name = attributes
76 .get_value(GEN_AI__OPERATION__NAME)
77 .or_else(|| attributes.get_value(SENTRY__OP))
78 .and_then(|op| op.as_str())
79 .and_then(|op| ai::infer_ai_operation_type(op))
80 .unwrap_or_else(|| ai::default_ai_operation_type(attributes));
81
82 attributes.insert(GEN_AI__OPERATION__TYPE, op_name.to_owned());
83}
84
85fn normalize_total_tokens(attributes: &mut Attributes) {
87 let input_tokens = attributes
88 .get_value(GEN_AI__USAGE__INPUT_TOKENS)
89 .and_then(|v| v.as_f64());
90
91 let output_tokens = attributes
92 .get_value(GEN_AI__USAGE__OUTPUT_TOKENS)
93 .and_then(|v| v.as_f64());
94
95 if input_tokens.is_none() && output_tokens.is_none() {
96 return;
97 }
98
99 let total_tokens = input_tokens.unwrap_or(0.0) + output_tokens.unwrap_or(0.0);
100 attributes.insert(GEN_AI__USAGE__TOTAL_TOKENS, total_tokens);
101}
102
103fn normalize_tokens_per_second(attributes: &mut Attributes, duration: Option<Duration>) {
105 let Some(duration) = duration.filter(|d| !d.is_zero()) else {
106 return;
107 };
108
109 let output_tokens = attributes
110 .get_value(GEN_AI__USAGE__OUTPUT_TOKENS)
111 .and_then(|v| v.as_f64())
112 .filter(|v| *v > 0.0);
113
114 if let Some(output_tokens) = output_tokens {
115 let tps = output_tokens / duration.as_secs_f64();
116 attributes.insert(GEN_AI__RESPONSE__TOKENS_PER_SECOND, tps);
117 }
118}
119
120fn normalize_context_utilization(
122 attributes: &mut Attributes,
123 model_metadata: Option<&ModelMetadata>,
124) {
125 let model_id = attributes
126 .get_value(GEN_AI__RESPONSE__MODEL)
127 .and_then(|v| v.as_str());
128
129 let context_size = model_id.and_then(|id| model_metadata.and_then(|m| m.context_size(id)));
130
131 let Some(context_size) = context_size else {
132 return;
133 };
134
135 attributes.insert(GEN_AI__CONTEXT__WINDOW_SIZE, context_size as i64);
136
137 let total_tokens = attributes
138 .get_value(GEN_AI__USAGE__TOTAL_TOKENS)
139 .and_then(|v| v.as_f64());
140
141 if let Some(total_tokens) = total_tokens {
142 attributes.insert(
143 GEN_AI__CONTEXT__UTILIZATION,
144 total_tokens / context_size as f64,
145 );
146 }
147}
148
149fn normalize_ai_costs(attributes: &mut Attributes, model_metadata: Option<&ModelMetadata>) {
151 if ai::has_valid_total_cost(attributes) {
154 return;
155 }
156
157 let origin = extract_string_value(attributes, SENTRY__ORIGIN);
158 let platform = extract_string_value(attributes, SENTRY__PLATFORM);
159
160 let integration = map_origin_to_integration(origin);
161 let platform_tag = platform_tag(platform);
162
163 let Some(model_id) = attributes
164 .get_value(GEN_AI__RESPONSE__MODEL)
165 .and_then(|v| v.as_str())
166 else {
167 relay_statsd::metric!(
168 counter(Counters::GenAiCostCalculationResult) += 1,
169 result = "calculation_no_model_id_available",
170 integration = integration,
171 platform = platform_tag,
172 );
173 return;
174 };
175
176 let Some(model_cost) = model_metadata.and_then(|m| m.cost_per_token(model_id)) else {
177 relay_statsd::metric!(
178 counter(Counters::GenAiCostCalculationResult) += 1,
179 result = "calculation_no_model_cost_available",
180 integration = integration,
181 platform = platform_tag,
182 );
183 return;
184 };
185
186 let get_tokens = |key| {
187 attributes
188 .get_value(key)
189 .and_then(|v| v.as_f64())
190 .unwrap_or(0.0)
191 };
192
193 let tokens = ai::UsedTokens {
194 input_tokens: get_tokens(GEN_AI__USAGE__INPUT_TOKENS),
195 input_cached_tokens: get_tokens(GEN_AI__USAGE__CACHE_READ__INPUT_TOKENS),
196 input_cache_write_tokens: get_tokens(GEN_AI__USAGE__CACHE_CREATION__INPUT_TOKENS),
197 output_tokens: get_tokens(GEN_AI__USAGE__OUTPUT_TOKENS),
198 output_reasoning_tokens: get_tokens(GEN_AI__USAGE__REASONING__OUTPUT_TOKENS),
199 };
200
201 let Some(costs) = ai::calculate_costs(model_cost, tokens, integration, platform_tag) else {
202 return;
203 };
204
205 attributes.insert(GEN_AI__COST__INPUT_TOKENS, costs.input);
207 attributes.insert(
208 GEN_AI__COST__CACHE_READ__INPUT_TOKENS,
209 costs.cache_read_input,
210 );
211 attributes.insert(
212 GEN_AI__COST__CACHE_CREATION__INPUT_TOKENS,
213 costs.cache_creation_input,
214 );
215
216 attributes.insert(GEN_AI__COST__OUTPUT_TOKENS, costs.output);
217 attributes.insert(
218 GEN_AI__COST__REASONING__OUTPUT_TOKENS,
219 costs.reasoning_output,
220 );
221
222 attributes.insert(GEN_AI__COST__TOTAL_TOKENS, costs.total());
223}
224
225fn normalize_tool_call_result_size(attributes: &mut Attributes) {
227 if extract_string_value(attributes, GEN_AI__OPERATION__TYPE) != Some("tool") {
228 return;
229 }
230
231 let has_valid_size = matches!(
232 attributes.get_value(GEN_AI__TOOL__CALL__RESULT__SIZE),
233 Some(Value::U64(_)) | Some(Value::I64(0..))
234 );
235
236 if has_valid_size {
237 return;
238 }
239
240 let Some(result) = extract_string_value(attributes, GEN_AI__TOOL__CALL__RESULT) else {
241 return;
242 };
243
244 let size = Attribute::new(AttributeType::Integer, Value::U64(result.len() as u64));
245 attributes.0.insert(
246 GEN_AI__TOOL__CALL__RESULT__SIZE.to_owned(),
247 Annotated::new(size),
248 );
249}
250
251fn extract_string_value<'a>(attributes: &'a Attributes, key: &str) -> Option<&'a str> {
252 attributes.get_value(key).and_then(|v| v.as_str())
253}
254
255#[cfg(test)]
256mod tests {
257 use std::collections::HashMap;
258
259 use relay_protocol::{Empty, assert_annotated_snapshot};
260
261 use crate::{ModelCostV2, ModelMetadataEntry};
262
263 use super::*;
264
265 macro_rules! attributes {
266 ($($key:expr => $value:expr),* $(,)?) => {
267 Attributes::from([
268 $(($key.into(), Annotated::new($value.into())),)*
269 ])
270 };
271 }
272
273 fn model_metadata() -> ModelMetadata {
274 ModelMetadata {
275 version: 1,
276 models: HashMap::from([
277 (
278 "claude-2.1".parse().unwrap(),
279 ModelMetadataEntry {
280 costs: Some(ModelCostV2 {
281 input_per_token: 0.01,
282 output_per_token: 0.02,
283 output_reasoning_per_token: 0.03,
284 input_cached_per_token: 0.04,
285 input_cache_write_per_token: 0.0,
286 }),
287 context_size: None,
288 },
289 ),
290 (
291 "gpt4-21-04".parse().unwrap(),
292 ModelMetadataEntry {
293 costs: Some(ModelCostV2 {
294 input_per_token: 0.09,
295 output_per_token: 0.05,
296 output_reasoning_per_token: 0.0,
297 input_cached_per_token: 0.0,
298 input_cache_write_per_token: 0.0,
299 }),
300 context_size: None,
301 },
302 ),
303 ]),
304 }
305 }
306
307 fn model_metadata_with_context_size() -> ModelMetadata {
308 ModelMetadata {
309 version: 1,
310 models: HashMap::from([(
311 "claude-2.1".parse().unwrap(),
312 ModelMetadataEntry {
313 costs: Some(ModelCostV2 {
314 input_per_token: 0.01,
315 output_per_token: 0.02,
316 output_reasoning_per_token: 0.03,
317 input_cached_per_token: 0.04,
318 input_cache_write_per_token: 0.0,
319 }),
320 context_size: Some(100_000),
321 },
322 )]),
323 }
324 }
325
326 #[test]
327 fn test_normalize_ai_type_fallback() {
328 for model_key in [
329 None,
330 Some(GEN_AI__REQUEST__MODEL),
331 Some(GEN_AI__RESPONSE__MODEL),
332 ] {
333 let mut attributes = attributes! {
334 "gen_ai.operation.name" => "embeddings".to_owned(),
335 };
336 if let Some(key) = model_key {
337 attributes.insert(key, "gpt-4".to_owned());
338 }
339 let mut attributes = Annotated::new(attributes);
340
341 normalize_ai(&mut attributes, None, None);
342
343 assert_eq!(
344 attributes
345 .value()
346 .unwrap()
347 .get_value(GEN_AI__OPERATION__TYPE)
348 .and_then(|v| v.as_str()),
349 Some(if model_key.is_some() {
350 "ai_client"
351 } else {
352 "other"
353 }),
354 );
355 }
356 }
357
358 #[test]
359 fn test_normalize_ai_all_tokens() {
360 let mut attributes = Annotated::new(attributes! {
361 "gen_ai.operation.type" => "ai_client".to_owned(),
362 "gen_ai.usage.input_tokens" => 1000,
363 "gen_ai.usage.output_tokens" => 2000,
364 "gen_ai.usage.reasoning.output_tokens" => 1000,
365 "gen_ai.usage.cache_read.input_tokens" => 500,
366 "gen_ai.request.model" => "claude-2.1".to_owned(),
367 });
368
369 normalize_ai(
370 &mut attributes,
371 Some(Duration::from_secs(1)),
372 Some(&model_metadata()),
373 );
374
375 assert_annotated_snapshot!(attributes, @r#"
376 {
377 "gen_ai.cost.cache_creation.input_tokens": {
378 "type": "double",
379 "value": 0.0
380 },
381 "gen_ai.cost.cache_read.input_tokens": {
382 "type": "double",
383 "value": 20.0
384 },
385 "gen_ai.cost.input_tokens": {
386 "type": "double",
387 "value": 25.0
388 },
389 "gen_ai.cost.output_tokens": {
390 "type": "double",
391 "value": 50.0
392 },
393 "gen_ai.cost.reasoning.output_tokens": {
394 "type": "double",
395 "value": 30.0
396 },
397 "gen_ai.cost.total_tokens": {
398 "type": "double",
399 "value": 75.0
400 },
401 "gen_ai.operation.type": {
402 "type": "string",
403 "value": "ai_client"
404 },
405 "gen_ai.request.model": {
406 "type": "string",
407 "value": "claude-2.1"
408 },
409 "gen_ai.response.model": {
410 "type": "string",
411 "value": "claude-2.1"
412 },
413 "gen_ai.response.tokens_per_second": {
414 "type": "double",
415 "value": 2000.0
416 },
417 "gen_ai.usage.cache_read.input_tokens": {
418 "type": "integer",
419 "value": 500
420 },
421 "gen_ai.usage.input_tokens": {
422 "type": "integer",
423 "value": 1000
424 },
425 "gen_ai.usage.output_tokens": {
426 "type": "integer",
427 "value": 2000
428 },
429 "gen_ai.usage.reasoning.output_tokens": {
430 "type": "integer",
431 "value": 1000
432 },
433 "gen_ai.usage.total_tokens": {
434 "type": "double",
435 "value": 3000.0
436 }
437 }
438 "#);
439 }
440
441 #[test]
442 fn test_normalize_ai_basic_tokens() {
443 let mut attributes = Annotated::new(attributes! {
444 "gen_ai.operation.type" => "ai_client".to_owned(),
445 "gen_ai.usage.input_tokens" => 1000,
446 "gen_ai.usage.output_tokens" => 2000,
447 "gen_ai.request.model" => "gpt4-21-04".to_owned(),
448 });
449
450 normalize_ai(
451 &mut attributes,
452 Some(Duration::from_millis(500)),
453 Some(&model_metadata()),
454 );
455
456 assert_annotated_snapshot!(attributes, @r#"
457 {
458 "gen_ai.cost.cache_creation.input_tokens": {
459 "type": "double",
460 "value": 0.0
461 },
462 "gen_ai.cost.cache_read.input_tokens": {
463 "type": "double",
464 "value": 0.0
465 },
466 "gen_ai.cost.input_tokens": {
467 "type": "double",
468 "value": 90.0
469 },
470 "gen_ai.cost.output_tokens": {
471 "type": "double",
472 "value": 100.0
473 },
474 "gen_ai.cost.reasoning.output_tokens": {
475 "type": "double",
476 "value": 0.0
477 },
478 "gen_ai.cost.total_tokens": {
479 "type": "double",
480 "value": 190.0
481 },
482 "gen_ai.operation.type": {
483 "type": "string",
484 "value": "ai_client"
485 },
486 "gen_ai.request.model": {
487 "type": "string",
488 "value": "gpt4-21-04"
489 },
490 "gen_ai.response.model": {
491 "type": "string",
492 "value": "gpt4-21-04"
493 },
494 "gen_ai.response.tokens_per_second": {
495 "type": "double",
496 "value": 4000.0
497 },
498 "gen_ai.usage.input_tokens": {
499 "type": "integer",
500 "value": 1000
501 },
502 "gen_ai.usage.output_tokens": {
503 "type": "integer",
504 "value": 2000
505 },
506 "gen_ai.usage.total_tokens": {
507 "type": "double",
508 "value": 3000.0
509 }
510 }
511 "#);
512 }
513
514 #[test]
515 fn test_normalize_ai_basic_tokens_no_duration_no_cost() {
516 let mut attributes = Annotated::new(attributes! {
517 "gen_ai.operation.type" => "ai_client".to_owned(),
518 "gen_ai.usage.input_tokens" => 1000,
519 "gen_ai.usage.output_tokens" => 2000,
520 "gen_ai.request.model" => "unknown".to_owned(),
521 });
522
523 normalize_ai(
524 &mut attributes,
525 Some(Duration::ZERO),
526 Some(&model_metadata()),
527 );
528
529 assert_annotated_snapshot!(attributes, @r#"
530 {
531 "gen_ai.operation.type": {
532 "type": "string",
533 "value": "ai_client"
534 },
535 "gen_ai.request.model": {
536 "type": "string",
537 "value": "unknown"
538 },
539 "gen_ai.response.model": {
540 "type": "string",
541 "value": "unknown"
542 },
543 "gen_ai.usage.input_tokens": {
544 "type": "integer",
545 "value": 1000
546 },
547 "gen_ai.usage.output_tokens": {
548 "type": "integer",
549 "value": 2000
550 },
551 "gen_ai.usage.total_tokens": {
552 "type": "double",
553 "value": 3000.0
554 }
555 }
556 "#);
557 }
558
559 #[test]
560 fn test_normalize_ai_does_not_overwrite() {
561 let mut attributes = Annotated::new(attributes! {
562 "gen_ai.operation.type" => "ai_client".to_owned(),
563 "gen_ai.usage.input_tokens" => 1000,
564 "gen_ai.usage.output_tokens" => 2000,
565 "gen_ai.request.model" => "gpt4".to_owned(),
566 "gen_ai.response.model" => "gpt4-21-04".to_owned(),
567
568 "gen_ai.cost.input_tokens" => 999.0,
569 });
570
571 normalize_ai(
572 &mut attributes,
573 Some(Duration::from_millis(500)),
574 Some(&model_metadata()),
575 );
576
577 assert_annotated_snapshot!(attributes, @r#"
578 {
579 "gen_ai.cost.cache_creation.input_tokens": {
580 "type": "double",
581 "value": 0.0
582 },
583 "gen_ai.cost.cache_read.input_tokens": {
584 "type": "double",
585 "value": 0.0
586 },
587 "gen_ai.cost.input_tokens": {
588 "type": "double",
589 "value": 90.0
590 },
591 "gen_ai.cost.output_tokens": {
592 "type": "double",
593 "value": 100.0
594 },
595 "gen_ai.cost.reasoning.output_tokens": {
596 "type": "double",
597 "value": 0.0
598 },
599 "gen_ai.cost.total_tokens": {
600 "type": "double",
601 "value": 190.0
602 },
603 "gen_ai.operation.type": {
604 "type": "string",
605 "value": "ai_client"
606 },
607 "gen_ai.request.model": {
608 "type": "string",
609 "value": "gpt4"
610 },
611 "gen_ai.response.model": {
612 "type": "string",
613 "value": "gpt4-21-04"
614 },
615 "gen_ai.response.tokens_per_second": {
616 "type": "double",
617 "value": 4000.0
618 },
619 "gen_ai.usage.input_tokens": {
620 "type": "integer",
621 "value": 1000
622 },
623 "gen_ai.usage.output_tokens": {
624 "type": "integer",
625 "value": 2000
626 },
627 "gen_ai.usage.total_tokens": {
628 "type": "double",
629 "value": 3000.0
630 }
631 }
632 "#);
633 }
634
635 #[test]
636 fn test_normalize_ai_preserves_costs() {
637 let mut attributes = Annotated::new(attributes! {
638 "gen_ai.operation.type" => "ai_client".to_owned(),
639 "gen_ai.usage.input_tokens" => 1000,
640 "gen_ai.usage.output_tokens" => 2000,
641 "gen_ai.request.model" => "gpt4-21-04".to_owned(),
642
643 "gen_ai.usage.total_tokens" => 1337,
644
645 "gen_ai.cost.input_tokens" => 99.0,
646 "gen_ai.cost.output_tokens" => 99.0,
647 "gen_ai.cost.total_tokens" => 123.0,
648
649 "gen_ai.response.tokens_per_second" => 42.0,
650 });
651
652 normalize_ai(
653 &mut attributes,
654 Some(Duration::from_millis(500)),
655 Some(&model_metadata()),
656 );
657
658 assert_annotated_snapshot!(attributes, @r#"
659 {
660 "gen_ai.cost.input_tokens": {
661 "type": "double",
662 "value": 99.0
663 },
664 "gen_ai.cost.output_tokens": {
665 "type": "double",
666 "value": 99.0
667 },
668 "gen_ai.cost.total_tokens": {
669 "type": "double",
670 "value": 123.0
671 },
672 "gen_ai.operation.type": {
673 "type": "string",
674 "value": "ai_client"
675 },
676 "gen_ai.request.model": {
677 "type": "string",
678 "value": "gpt4-21-04"
679 },
680 "gen_ai.response.model": {
681 "type": "string",
682 "value": "gpt4-21-04"
683 },
684 "gen_ai.response.tokens_per_second": {
685 "type": "double",
686 "value": 4000.0
687 },
688 "gen_ai.usage.input_tokens": {
689 "type": "integer",
690 "value": 1000
691 },
692 "gen_ai.usage.output_tokens": {
693 "type": "integer",
694 "value": 2000
695 },
696 "gen_ai.usage.total_tokens": {
697 "type": "double",
698 "value": 3000.0
699 }
700 }
701 "#);
702 }
703
704 #[test]
705 fn test_normalize_ai_no_ai_attributes() {
706 let mut attributes = Annotated::new(attributes! {
707 "gen_ai.usage.input_tokens" => 1000,
708 "gen_ai.usage.output_tokens" => 2000,
709 });
710
711 normalize_ai(
712 &mut attributes,
713 Some(Duration::from_millis(500)),
714 Some(&model_metadata()),
715 );
716
717 assert_annotated_snapshot!(&mut attributes, @r#"
718 {
719 "gen_ai.usage.input_tokens": {
720 "type": "integer",
721 "value": 1000
722 },
723 "gen_ai.usage.output_tokens": {
724 "type": "integer",
725 "value": 2000
726 }
727 }
728 "#);
729 }
730
731 #[test]
732 fn test_normalize_ai_no_ai_indicator_attribute() {
733 let mut attributes = Annotated::new(attributes! {
734 "foo" => 123,
735 });
736
737 normalize_ai(
738 &mut attributes,
739 Some(Duration::from_millis(500)),
740 Some(&model_metadata()),
741 );
742
743 assert_annotated_snapshot!(&mut attributes, @r#"
744 {
745 "foo": {
746 "type": "integer",
747 "value": 123
748 }
749 }
750 "#);
751 }
752
753 #[test]
754 fn test_normalize_ai_empty() {
755 let mut attributes = Annotated::empty();
756
757 normalize_ai(
758 &mut attributes,
759 Some(Duration::from_millis(500)),
760 Some(&model_metadata()),
761 );
762
763 assert!(attributes.is_empty());
764 }
765
766 #[test]
767 fn test_context_utilization_with_total_tokens() {
768 let mut attributes = Annotated::new(attributes! {
769 "gen_ai.operation.type" => "ai_client".to_owned(),
770 "gen_ai.usage.input_tokens" => 30000,
771 "gen_ai.usage.output_tokens" => 12000,
772 "gen_ai.request.model" => "claude-2.1".to_owned(),
773 });
774
775 normalize_ai(
776 &mut attributes,
777 Some(Duration::from_secs(1)),
778 Some(&model_metadata_with_context_size()),
779 );
780
781 assert_annotated_snapshot!(attributes, @r#"
782 {
783 "gen_ai.context.utilization": {
784 "type": "double",
785 "value": 0.42
786 },
787 "gen_ai.context.window_size": {
788 "type": "integer",
789 "value": 100000
790 },
791 "gen_ai.cost.cache_creation.input_tokens": {
792 "type": "double",
793 "value": 0.0
794 },
795 "gen_ai.cost.cache_read.input_tokens": {
796 "type": "double",
797 "value": 0.0
798 },
799 "gen_ai.cost.input_tokens": {
800 "type": "double",
801 "value": 300.0
802 },
803 "gen_ai.cost.output_tokens": {
804 "type": "double",
805 "value": 240.0
806 },
807 "gen_ai.cost.reasoning.output_tokens": {
808 "type": "double",
809 "value": 0.0
810 },
811 "gen_ai.cost.total_tokens": {
812 "type": "double",
813 "value": 540.0
814 },
815 "gen_ai.operation.type": {
816 "type": "string",
817 "value": "ai_client"
818 },
819 "gen_ai.request.model": {
820 "type": "string",
821 "value": "claude-2.1"
822 },
823 "gen_ai.response.model": {
824 "type": "string",
825 "value": "claude-2.1"
826 },
827 "gen_ai.response.tokens_per_second": {
828 "type": "double",
829 "value": 12000.0
830 },
831 "gen_ai.usage.input_tokens": {
832 "type": "integer",
833 "value": 30000
834 },
835 "gen_ai.usage.output_tokens": {
836 "type": "integer",
837 "value": 12000
838 },
839 "gen_ai.usage.total_tokens": {
840 "type": "double",
841 "value": 42000.0
842 }
843 }
844 "#);
845 }
846
847 #[test]
848 fn test_context_utilization_no_context_size() {
849 let mut attributes = Annotated::new(attributes! {
850 "gen_ai.operation.type" => "ai_client".to_owned(),
851 "gen_ai.usage.input_tokens" => 1000,
852 "gen_ai.usage.output_tokens" => 2000,
853 "gen_ai.request.model" => "claude-2.1".to_owned(),
854 });
855
856 normalize_ai(
858 &mut attributes,
859 Some(Duration::from_secs(1)),
860 Some(&model_metadata()),
861 );
862
863 let attrs = attributes.value().unwrap();
864 assert!(attrs.get_value("gen_ai.context.window_size").is_none());
865 assert!(attrs.get_value("gen_ai.context.utilization").is_none());
866 }
867
868 #[test]
869 fn test_context_utilization_no_total_tokens() {
870 let mut attributes = Annotated::new(attributes! {
872 "gen_ai.operation.type" => "ai_client".to_owned(),
873 "gen_ai.request.model" => "claude-2.1".to_owned(),
874 });
875
876 normalize_ai(
877 &mut attributes,
878 Some(Duration::from_secs(1)),
879 Some(&model_metadata_with_context_size()),
880 );
881
882 let attrs = attributes.value().unwrap();
883 assert_eq!(
885 attrs
886 .get_value("gen_ai.context.window_size")
887 .unwrap()
888 .as_f64(),
889 Some(100_000.0)
890 );
891 assert!(attrs.get_value("gen_ai.context.utilization").is_none());
893 }
894
895 #[test]
896 fn test_context_utilization_unknown_model() {
897 let mut attributes = Annotated::new(attributes! {
898 "gen_ai.operation.type" => "ai_client".to_owned(),
899 "gen_ai.usage.input_tokens" => 1000,
900 "gen_ai.usage.output_tokens" => 2000,
901 "gen_ai.request.model" => "unknown-model".to_owned(),
902 });
903
904 normalize_ai(
905 &mut attributes,
906 Some(Duration::from_secs(1)),
907 Some(&model_metadata_with_context_size()),
908 );
909
910 let attrs = attributes.value().unwrap();
911 assert!(attrs.get_value("gen_ai.context.window_size").is_none());
912 assert!(attrs.get_value("gen_ai.context.utilization").is_none());
913 }
914}