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