Skip to main content

relay_event_normalization/normalize/span/
ai.rs

1//! AI cost calculation.
2
3use crate::eap::AttributesLike;
4use crate::statsd::{Counters, map_origin_to_integration, platform_tag};
5use crate::{ModelCostV2, ModelMetadata};
6use relay_conventions::attributes::*;
7use relay_event_schema::protocol::{
8    Event, Measurements, OperationType, Span, SpanData, TraceContext,
9};
10use relay_protocol::{Annotated, Getter, Value};
11
12/// Amount of used tokens for a model call.
13#[derive(Debug, Copy, Clone)]
14pub struct UsedTokens {
15    /// Total amount of input tokens used.
16    pub input_tokens: f64,
17    /// Amount of cached tokens used.
18    ///
19    /// This is a subset of [`Self::input_tokens`].
20    pub input_cached_tokens: f64,
21    /// Amount of cache write tokens used.
22    ///
23    /// This is a subset of [`Self::input_tokens`].
24    pub input_cache_write_tokens: f64,
25    /// Total amount of output tokens.
26    pub output_tokens: f64,
27    /// Total amount of reasoning tokens.
28    ///
29    /// This is a subset of [`Self::output_tokens`].
30    pub output_reasoning_tokens: f64,
31}
32
33impl UsedTokens {
34    /// Extracts [`UsedTokens`] from [`SpanData`] attributes.
35    pub fn from_span_data(data: &SpanData) -> Self {
36        macro_rules! get_value {
37            ($e:expr) => {
38                data.get_value($e).and_then(|v| v.as_f64()).unwrap_or(0.0)
39            };
40        }
41
42        Self {
43            input_tokens: get_value!(GEN_AI__USAGE__INPUT_TOKENS),
44            output_tokens: get_value!(GEN_AI__USAGE__OUTPUT_TOKENS),
45            output_reasoning_tokens: get_value!(GEN_AI__USAGE__REASONING__OUTPUT_TOKENS),
46            input_cached_tokens: get_value!(GEN_AI__USAGE__CACHE_READ__INPUT_TOKENS),
47            input_cache_write_tokens: get_value!(GEN_AI__USAGE__CACHE_CREATION__INPUT_TOKENS),
48        }
49    }
50
51    /// Returns `true` if any tokens were used.
52    pub fn has_usage(&self) -> bool {
53        self.input_tokens > 0.0 || self.output_tokens > 0.0
54    }
55
56    /// Calculates the total amount of input tokens billed at the standard rate.
57    ///
58    /// Both [`Self::input_cached_tokens`] and [`Self::input_cache_write_tokens`] are
59    /// subsets of [`Self::input_tokens`] and are billed separately at their own
60    /// (cached / cache-write) rates, so both are subtracted here to avoid charging
61    /// them twice.
62    pub fn raw_input_tokens(&self) -> f64 {
63        self.input_tokens - self.input_cached_tokens - self.input_cache_write_tokens
64    }
65
66    /// Calculates the total amount of raw, non-reasoning output tokens.
67    ///
68    /// Subtracts reasoning tokens from the total token count.
69    pub fn raw_output_tokens(&self) -> f64 {
70        self.output_tokens - self.output_reasoning_tokens
71    }
72}
73
74/// Calculated model call costs.
75#[derive(Debug, Copy, Clone)]
76pub struct CalculatedCost {
77    /// The total cost of all input tokens (raw + cached + cache_write).
78    pub input: f64,
79    /// The total cost of all output tokens (raw + reasoning).
80    pub output: f64,
81    /// The cost of cached input tokens only (subset of `input`).
82    pub cache_read_input: f64,
83    /// The cost of cache-write input tokens only (subset of `input`).
84    pub cache_creation_input: f64,
85    /// The cost of reasoning output tokens only (subset of `output`).
86    pub reasoning_output: f64,
87}
88
89impl CalculatedCost {
90    /// The total, input and output, cost.
91    pub fn total(&self) -> f64 {
92        self.input + self.output
93    }
94}
95
96/// Calculates the total cost for a model call.
97///
98/// Returns `None` if no tokens were used.
99pub fn calculate_costs(
100    model_cost: &ModelCostV2,
101    tokens: UsedTokens,
102    integration: &str,
103    platform: &str,
104) -> Option<CalculatedCost> {
105    if !tokens.has_usage() {
106        relay_statsd::metric!(
107            counter(Counters::GenAiCostCalculationResult) += 1,
108            result = "calculation_no_tokens",
109            integration = integration,
110            platform = platform,
111        );
112        return None;
113    }
114
115    let cache_read_input = tokens.input_cached_tokens * model_cost.input_cached_per_token;
116    let cache_creation_input =
117        tokens.input_cache_write_tokens * model_cost.input_cache_write_per_token;
118    let input = (tokens.raw_input_tokens() * model_cost.input_per_token)
119        + cache_read_input
120        + cache_creation_input;
121
122    // For now most of the models do not differentiate between reasoning and output token cost,
123    // it costs the same.
124    let reasoning_per_token = match model_cost.output_reasoning_per_token {
125        r if r > 0.0 => r,
126        _ => model_cost.output_per_token,
127    };
128    let reasoning_output = tokens.output_reasoning_tokens * reasoning_per_token;
129    let output = (tokens.raw_output_tokens() * model_cost.output_per_token) + reasoning_output;
130
131    let metric_label = match (input, output) {
132        (x, y) if x < 0.0 || y < 0.0 => "calculation_negative",
133        (0.0, 0.0) => "calculation_zero",
134        _ => "calculation_positive",
135    };
136
137    relay_statsd::metric!(
138        counter(Counters::GenAiCostCalculationResult) += 1,
139        result = metric_label,
140        integration = integration,
141        platform = platform,
142    );
143
144    Some(CalculatedCost {
145        input,
146        output,
147        cache_read_input,
148        cache_creation_input,
149        reasoning_output,
150    })
151}
152
153/// Returns the fallback AI operation type based on whether model info is available.
154pub fn default_ai_operation_type(attributes: &impl AttributesLike) -> &'static str {
155    if [GEN_AI__REQUEST__MODEL, GEN_AI__RESPONSE__MODEL]
156        .iter()
157        .any(|key| attributes.get_value(key).and_then(Value::as_str).is_some())
158    {
159        "ai_client"
160    } else {
161        "other"
162    }
163}
164
165/// Infers the AI operation from an AI operation name.
166///
167/// The operation name is usually inferred from the
168/// [`GEN_AI__OPERATION__NAME`] span attribute and the span
169/// operation.
170///
171/// Sentry expects the operation type in the [`GEN_AI__OPERATION__TYPE`] attribute.
172///
173/// The function returns `None` when the op is not a well known AI operation, callers likely want to default
174/// the value using [`default_ai_operation_type`] for AI spans.
175pub fn infer_ai_operation_type(op_name: &str) -> Option<&'static str> {
176    let ai_op = match op_name {
177        // Full matches:
178        "ai.run.generateText"
179        | "ai.run.generateObject"
180        | "gen_ai.invoke_agent"
181        | "ai.pipeline.generate_text"
182        | "ai.pipeline.generate_object"
183        | "ai.pipeline.stream_text"
184        | "ai.pipeline.stream_object"
185        | "gen_ai.create_agent"
186        | "invoke_agent"
187        | "create_agent" => "agent",
188        "gen_ai.execute_tool" | "execute_tool" => "tool",
189        "gen_ai.handoff" | "handoff" => "handoff",
190        "gen_ai.memory.client"
191        | "create_memory"
192        | "create_memory_store"
193        | "delete_memory"
194        | "delete_memory_store"
195        | "search_memory"
196        | "update_memory"
197        | "upsert_memory" => "memory",
198        "ai.processor" | "processor_run" => "other",
199        // Prefix matches:
200        op if op.starts_with("ai.streamText.doStream") => "ai_client",
201        op if op.starts_with("ai.streamText") => "agent",
202
203        op if op.starts_with("ai.generateText.doGenerate") => "ai_client",
204        op if op.starts_with("ai.generateText") => "agent",
205
206        op if op.starts_with("ai.generateObject.doGenerate") => "ai_client",
207        op if op.starts_with("ai.generateObject") => "agent",
208
209        op if op.starts_with("ai.toolCall") => "tool",
210        // No match:
211        _ => return None,
212    };
213
214    Some(ai_op)
215}
216
217/// Returns whether a valid total cost is attached.
218pub fn has_valid_total_cost(attributes: &impl AttributesLike) -> bool {
219    attributes
220        .get_value(GEN_AI__COST__TOTAL_TOKENS)
221        .and_then(Value::as_f64)
222        .is_some()
223}
224
225/// Calculates the cost of an AI model based on the model cost and the tokens used.
226/// Calculated cost is in US dollars.
227fn extract_ai_model_cost_data(
228    model_cost: Option<&ModelCostV2>,
229    data: &mut SpanData,
230    origin: Option<&str>,
231    platform: Option<&str>,
232) {
233    // Preserve existing total cost instead of recalculating and overwriting it.
234    if has_valid_total_cost(data) {
235        return;
236    }
237
238    let integration = map_origin_to_integration(origin);
239    let platform = platform_tag(platform);
240
241    let Some(model_cost) = model_cost else {
242        relay_statsd::metric!(
243            counter(Counters::GenAiCostCalculationResult) += 1,
244            result = "calculation_no_model_cost_available",
245            integration = integration,
246            platform = platform,
247        );
248        return;
249    };
250
251    let used_tokens = UsedTokens::from_span_data(&*data);
252    let Some(costs) = calculate_costs(model_cost, used_tokens, integration, platform) else {
253        return;
254    };
255
256    data.other
257        .entry(GEN_AI__COST__TOTAL_TOKENS.to_owned())
258        .or_default()
259        .set_value(Value::F64(costs.total()).into());
260
261    // Set individual cost components
262    data.other
263        .entry(GEN_AI__COST__INPUT_TOKENS.to_owned())
264        .or_default()
265        .set_value(Value::F64(costs.input).into());
266    data.other
267        .entry(GEN_AI__COST__CACHE_READ__INPUT_TOKENS.to_owned())
268        .or_default()
269        .set_value(Value::F64(costs.cache_read_input).into());
270    data.other
271        .entry(GEN_AI__COST__CACHE_CREATION__INPUT_TOKENS.to_owned())
272        .or_default()
273        .set_value(Value::F64(costs.cache_creation_input).into());
274
275    data.other
276        .entry(GEN_AI__COST__OUTPUT_TOKENS.to_owned())
277        .or_default()
278        .set_value(Value::F64(costs.output).into());
279
280    data.other
281        .entry(GEN_AI__COST__REASONING__OUTPUT_TOKENS.to_owned())
282        .or_default()
283        .set_value(Value::F64(costs.reasoning_output).into());
284}
285
286/// Maps AI-related measurements (legacy) to span data.
287fn map_ai_measurements_to_data(data: &mut SpanData, measurements: Option<&Measurements>) {
288    let set_field_from_measurement = |target_field: &mut Annotated<Value>,
289                                      measurement_key: &str| {
290        if let Some(measurements) = measurements
291            && target_field.value().is_none()
292            && let Some(value) = measurements.get_value(measurement_key)
293        {
294            target_field.set_value(Value::F64(value.to_f64()).into());
295        }
296    };
297
298    set_field_from_measurement(
299        data.other
300            .entry(GEN_AI__USAGE__TOTAL_TOKENS.to_owned())
301            .or_default(),
302        "ai_total_tokens_used",
303    );
304    set_field_from_measurement(
305        data.other
306            .entry(GEN_AI__USAGE__INPUT_TOKENS.to_owned())
307            .or_default(),
308        "ai_prompt_tokens_used",
309    );
310    set_field_from_measurement(
311        data.other
312            .entry(GEN_AI__USAGE__OUTPUT_TOKENS.to_owned())
313            .or_default(),
314        "ai_completion_tokens_used",
315    );
316}
317
318fn set_total_tokens(data: &mut SpanData) {
319    // It might be that 'total_tokens' is not set in which case we need to calculate it
320    if data.get_value(GEN_AI__USAGE__TOTAL_TOKENS).is_none() {
321        let input_tokens = data
322            .get_value(GEN_AI__USAGE__INPUT_TOKENS)
323            .and_then(Value::as_f64);
324        let output_tokens = data
325            .get_value(GEN_AI__USAGE__OUTPUT_TOKENS)
326            .and_then(Value::as_f64);
327
328        if input_tokens.is_none() && output_tokens.is_none() {
329            // don't set total_tokens if there are no input nor output tokens
330            return;
331        }
332
333        data.other
334            .entry(GEN_AI__USAGE__TOTAL_TOKENS.to_owned())
335            .or_default()
336            .set_value(
337                Value::F64(input_tokens.unwrap_or(0.0) + output_tokens.unwrap_or(0.0)).into(),
338            );
339    }
340}
341
342/// Sets the context window size and utilization for the model.
343fn extract_context_utilization(data: &mut SpanData, model_metadata: &ModelMetadata) {
344    let model_id = data.get_str(GEN_AI__RESPONSE__MODEL);
345
346    let context_size = model_id.and_then(|id| model_metadata.context_size(id));
347
348    let Some(context_size) = context_size else {
349        return;
350    };
351
352    data.other
353        .entry(GEN_AI__CONTEXT__WINDOW_SIZE.to_owned())
354        .or_default()
355        .set_value(Value::U64(context_size).into());
356
357    let total_tokens = data
358        .get_value(GEN_AI__USAGE__TOTAL_TOKENS)
359        .and_then(Value::as_f64);
360
361    if let Some(total_tokens) = total_tokens {
362        data.other
363            .entry(GEN_AI__CONTEXT__UTILIZATION.to_owned())
364            .or_default()
365            .set_value(Value::F64(total_tokens / context_size as f64).into());
366    }
367}
368
369/// Extract the additional data into the span
370fn extract_ai_data(
371    data: &mut SpanData,
372    duration: f64,
373    model_metadata: &ModelMetadata,
374    origin: Option<&str>,
375    platform: Option<&str>,
376) {
377    // Extracts the response tokens per second
378    if data
379        .get_value(GEN_AI__RESPONSE__TOKENS_PER_SECOND)
380        .is_none()
381        && duration > 0.0
382        && let Some(output_tokens) = data
383            .get_value(GEN_AI__USAGE__OUTPUT_TOKENS)
384            .and_then(Value::as_f64)
385    {
386        data.other
387            .entry(GEN_AI__RESPONSE__TOKENS_PER_SECOND.to_owned())
388            .or_default()
389            .set_value(Value::F64(output_tokens / (duration / 1000.0)).into());
390    }
391
392    extract_context_utilization(data, model_metadata);
393
394    // Extracts the total cost of the AI model used
395    if let Some(model_id) = data.get_str(GEN_AI__RESPONSE__MODEL) {
396        extract_ai_model_cost_data(
397            model_metadata.cost_per_token(model_id),
398            data,
399            origin,
400            platform,
401        )
402    } else {
403        relay_statsd::metric!(
404            counter(Counters::GenAiCostCalculationResult) += 1,
405            result = "calculation_no_model_id_available",
406            integration = map_origin_to_integration(origin),
407            platform = platform_tag(platform),
408        );
409    }
410}
411
412/// Enrich the AI span data
413fn enrich_ai_span_data(
414    span_data: &mut Annotated<SpanData>,
415    span_op: &Annotated<OperationType>,
416    measurements: &Annotated<Measurements>,
417    duration: f64,
418    model_metadata: Option<&ModelMetadata>,
419    origin: Option<&str>,
420    platform: Option<&str>,
421) {
422    if !is_ai_span(span_data, span_op.value()) {
423        return;
424    }
425
426    let data = span_data.get_or_insert_with(SpanData::default);
427
428    map_ai_measurements_to_data(data, measurements.value());
429
430    set_total_tokens(data);
431
432    // Default response model to request model if not set.
433    if data.get_value(GEN_AI__RESPONSE__MODEL).is_none()
434        && let Some(request_model) = data.get_value(GEN_AI__REQUEST__MODEL).cloned()
435    {
436        data.other
437            .entry(GEN_AI__RESPONSE__MODEL.to_owned())
438            .or_default()
439            .set_value(Some(request_model));
440    }
441
442    // Default agent name to function_id if not set.
443    if data.get_value(GEN_AI__AGENT__NAME).is_none()
444        && let Some(function_id) = data.get_value(GEN_AI__FUNCTION_ID).cloned()
445    {
446        data.other
447            .entry(GEN_AI__AGENT__NAME.to_owned())
448            .or_default()
449            .set_value(Some(function_id));
450    }
451
452    if let Some(model_metadata) = model_metadata {
453        extract_ai_data(data, duration, model_metadata, origin, platform);
454    } else {
455        relay_statsd::metric!(
456            counter(Counters::GenAiCostCalculationResult) += 1,
457            result = "calculation_no_model_cost_available",
458            integration = map_origin_to_integration(origin),
459            platform = platform_tag(platform),
460        );
461    }
462
463    let ai_op_type = data
464        .get_str(GEN_AI__OPERATION__NAME)
465        .or(span_op.value().map(String::as_str))
466        .and_then(infer_ai_operation_type)
467        .unwrap_or_else(|| default_ai_operation_type(data));
468
469    data.other
470        .entry(GEN_AI__OPERATION__TYPE.to_owned())
471        .or_default()
472        .set_value(Some(Value::String(ai_op_type.to_owned())));
473
474    // Preserve sizes measured by the SDK before truncating its output.
475    if ai_op_type == "tool"
476        && !matches!(
477            data.get_value(GEN_AI__TOOL__CALL__RESULT__SIZE),
478            Some(Value::U64(_)) | Some(Value::I64(0..))
479        )
480        && let Some(result) = data.get_str(GEN_AI__TOOL__CALL__RESULT)
481    {
482        let size = result.len() as u64;
483        data.other
484            .entry(GEN_AI__TOOL__CALL__RESULT__SIZE.to_owned())
485            .or_default()
486            .set_value(Some(Value::U64(size)));
487    }
488}
489
490/// Enrich the AI span data
491pub fn enrich_ai_span(span: &mut Span, model_metadata: Option<&ModelMetadata>) {
492    let duration = span
493        .get_value("span.duration")
494        .and_then(|v| v.as_f64())
495        .unwrap_or(0.0);
496
497    enrich_ai_span_data(
498        &mut span.data,
499        &span.op,
500        &span.measurements,
501        duration,
502        model_metadata,
503        span.origin.as_str(),
504        span.platform.as_str(),
505    );
506}
507
508/// Extract the ai data from all of an event's spans
509pub fn enrich_ai_event_data(event: &mut Event, model_metadata: Option<&ModelMetadata>) {
510    let event_duration = event
511        .get_value("event.duration")
512        .and_then(|v| v.as_f64())
513        .unwrap_or(0.0);
514
515    if let Some(trace_context) = event
516        .contexts
517        .value_mut()
518        .as_mut()
519        .and_then(|c| c.get_mut::<TraceContext>())
520    {
521        enrich_ai_span_data(
522            &mut trace_context.data,
523            &trace_context.op,
524            &event.measurements,
525            event_duration,
526            model_metadata,
527            trace_context.origin.as_str(),
528            event.platform.as_str(),
529        );
530    }
531    let spans = event.spans.value_mut().iter_mut().flatten();
532    let spans = spans.filter_map(|span| span.value_mut().as_mut());
533
534    for span in spans {
535        let span_duration = span
536            .get_value("span.duration")
537            .and_then(|v| v.as_f64())
538            .unwrap_or(0.0);
539        let span_platform = span.platform.as_str().or_else(|| event.platform.as_str());
540
541        enrich_ai_span_data(
542            &mut span.data,
543            &span.op,
544            &span.measurements,
545            span_duration,
546            model_metadata,
547            span.origin.as_str(),
548            span_platform,
549        );
550    }
551}
552
553/// Returns true if the span is an AI span.
554/// AI spans are spans with either a gen_ai.operation.name attribute or op starting with "ai."
555/// (legacy) or "gen_ai." (new).
556fn is_ai_span(span_data: &Annotated<SpanData>, span_op: Option<&OperationType>) -> bool {
557    let has_ai_op = span_data
558        .value()
559        .and_then(|data| data.get_value(GEN_AI__OPERATION__NAME))
560        .is_some();
561
562    let is_ai_span_op =
563        span_op.is_some_and(|op| op.starts_with("ai.") || op.starts_with("gen_ai."));
564
565    has_ai_op || is_ai_span_op
566}
567
568#[cfg(test)]
569mod tests {
570    use std::collections::HashMap;
571
572    use relay_protocol::{FromValue, assert_annotated_snapshot};
573    use serde_json::json;
574
575    use super::*;
576    use crate::ModelMetadataEntry;
577
578    fn ai_span_with_data(data: serde_json::Value) -> Span {
579        Span {
580            op: "gen_ai.test".to_owned().into(),
581            data: SpanData::from_value(data.into()),
582            ..Default::default()
583        }
584    }
585
586    #[test]
587    fn test_tool_call_result_size_both_pipelines() {
588        use relay_event_schema::protocol::{Attribute, Attributes};
589
590        for (op, result, size, expected) in [
591            ("gen_ai.execute_tool", Some("hello"), None, Some(5)),
592            ("gen_ai.execute_tool", Some("雨🌧️"), None, Some(10)),
593            ("gen_ai.execute_tool", Some(""), None, Some(0)),
594            ("gen_ai.execute_tool", None, None, None),
595            ("gen_ai.execute_tool", Some("short"), Some(1000), Some(1000)),
596            ("gen_ai.chat", Some("hello"), None, None),
597        ] {
598            let mut span = ai_span_with_data(json!({
599                "gen_ai.tool.call.result": result,
600                "gen_ai.tool.call.result.size": size,
601            }));
602            span.op = op.to_owned().into();
603            enrich_ai_span(&mut span, None);
604            assert_eq!(
605                span.data
606                    .value()
607                    .unwrap()
608                    .get_value(GEN_AI__TOOL__CALL__RESULT__SIZE)
609                    .and_then(Value::as_f64),
610                expected.map(f64::from),
611                "legacy: {op}"
612            );
613
614            let mut attributes = Attributes::new();
615            attributes.insert(SENTRY__OP, op.to_owned());
616            if let Some(result) = result {
617                attributes.0.insert(
618                    GEN_AI__TOOL__CALL__RESULT.to_owned(),
619                    Annotated::new(Attribute::from(result)),
620                );
621            }
622            if let Some(size) = size {
623                attributes.insert(GEN_AI__TOOL__CALL__RESULT__SIZE, i64::from(size));
624            }
625            let mut attributes = Annotated::new(attributes);
626            crate::eap::normalize_ai(&mut attributes, None, None);
627            assert_eq!(
628                attributes
629                    .value()
630                    .unwrap()
631                    .get_value(GEN_AI__TOOL__CALL__RESULT__SIZE)
632                    .and_then(Value::as_f64),
633                expected.map(f64::from),
634                "EAP: {op}"
635            );
636        }
637    }
638
639    #[test]
640    fn test_has_valid_total_cost() {
641        let missing = ai_span_with_data(json!({}));
642        let invalid = ai_span_with_data(json!({"gen_ai.cost.total_tokens": false}));
643        let valid = ai_span_with_data(json!({"gen_ai.cost.total_tokens": 1.0}));
644
645        assert!(!has_valid_total_cost(missing.data.value().unwrap()));
646        assert!(!has_valid_total_cost(invalid.data.value().unwrap()));
647        assert!(has_valid_total_cost(valid.data.value().unwrap()));
648    }
649
650    #[test]
651    fn test_calculate_cost_no_tokens() {
652        let cost = calculate_costs(
653            &ModelCostV2 {
654                input_per_token: 1.0,
655                output_per_token: 1.0,
656                output_reasoning_per_token: 1.0,
657                input_cached_per_token: 1.0,
658                input_cache_write_per_token: 1.0,
659            },
660            UsedTokens::from_span_data(&SpanData::default()),
661            "test",
662            "test",
663        );
664        assert!(cost.is_none());
665    }
666
667    #[test]
668    fn test_calculate_cost_full() {
669        let cost = calculate_costs(
670            &ModelCostV2 {
671                input_per_token: 1.0,
672                output_per_token: 2.0,
673                output_reasoning_per_token: 3.0,
674                input_cached_per_token: 0.5,
675                input_cache_write_per_token: 0.75,
676            },
677            UsedTokens {
678                input_tokens: 8.0,
679                input_cached_tokens: 5.0,
680                input_cache_write_tokens: 0.0,
681                output_tokens: 15.0,
682                output_reasoning_tokens: 9.0,
683            },
684            "test",
685            "test",
686        )
687        .unwrap();
688
689        insta::assert_debug_snapshot!(cost, @r"
690        CalculatedCost {
691            input: 5.5,
692            output: 39.0,
693            cache_read_input: 2.5,
694            cache_creation_input: 0.0,
695            reasoning_output: 27.0,
696        }
697        ");
698    }
699
700    #[test]
701    fn test_calculate_cost_no_reasoning_cost() {
702        let cost = calculate_costs(
703            &ModelCostV2 {
704                input_per_token: 1.0,
705                output_per_token: 2.0,
706                // Should fallback to output token cost for reasoning.
707                output_reasoning_per_token: 0.0,
708                input_cached_per_token: 0.5,
709                input_cache_write_per_token: 0.0,
710            },
711            UsedTokens {
712                input_tokens: 8.0,
713                input_cached_tokens: 5.0,
714                input_cache_write_tokens: 0.0,
715                output_tokens: 15.0,
716                output_reasoning_tokens: 9.0,
717            },
718            "test",
719            "test",
720        )
721        .unwrap();
722
723        insta::assert_debug_snapshot!(cost, @r"
724        CalculatedCost {
725            input: 5.5,
726            output: 30.0,
727            cache_read_input: 2.5,
728            cache_creation_input: 0.0,
729            reasoning_output: 18.0,
730        }
731        ");
732    }
733
734    /// This test shows it is possible to produce negative costs if tokens are not aligned properly.
735    ///
736    /// The behaviour was desired when initially implemented.
737    #[test]
738    fn test_calculate_cost_negative() {
739        let cost = calculate_costs(
740            &ModelCostV2 {
741                input_per_token: 2.0,
742                output_per_token: 2.0,
743                output_reasoning_per_token: 1.0,
744                input_cached_per_token: 1.0,
745                input_cache_write_per_token: 1.5,
746            },
747            UsedTokens {
748                input_tokens: 1.0,
749                input_cached_tokens: 11.0,
750                input_cache_write_tokens: 0.0,
751                output_tokens: 1.0,
752                output_reasoning_tokens: 9.0,
753            },
754            "test",
755            "test",
756        )
757        .unwrap();
758
759        insta::assert_debug_snapshot!(cost, @r"
760        CalculatedCost {
761            input: -9.0,
762            output: -7.0,
763            cache_read_input: 11.0,
764            cache_creation_input: 0.0,
765            reasoning_output: 9.0,
766        }
767        ");
768    }
769
770    #[test]
771    fn test_calculate_cost_with_cache_writes() {
772        let cost = calculate_costs(
773            &ModelCostV2 {
774                input_per_token: 1.0,
775                output_per_token: 2.0,
776                output_reasoning_per_token: 3.0,
777                input_cached_per_token: 0.5,
778                input_cache_write_per_token: 0.75,
779            },
780            UsedTokens {
781                input_tokens: 100.0,
782                input_cached_tokens: 20.0,
783                input_cache_write_tokens: 30.0,
784                output_tokens: 50.0,
785                output_reasoning_tokens: 10.0,
786            },
787            "test",
788            "test",
789        )
790        .unwrap();
791
792        // input: (100 - 20 - 30) * 1.0 + 20 * 0.5 + 30 * 0.75 = 50 + 10 + 22.5 = 82.5
793        //   (cache-write tokens are billed once at the cache-write rate, not also at
794        //    the standard input rate). output: 40 * 2.0 + 10 * 3.0 = 110.0
795        insta::assert_debug_snapshot!(cost, @r"
796        CalculatedCost {
797            input: 82.5,
798            output: 110.0,
799            cache_read_input: 10.0,
800            cache_creation_input: 22.5,
801            reasoning_output: 30.0,
802        }
803        ");
804    }
805
806    #[test]
807    fn test_existing_cost_is_not_overwritten() {
808        let mut span = ai_span_with_data(json!({
809            "gen_ai.response.model": "claude-2.1",
810            "gen_ai.usage.input_tokens": 1000.0,
811            "gen_ai.cost.input_tokens": 99.0,
812            "gen_ai.cost.total_tokens": 123.0,
813        }));
814
815        enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
816
817        let data = span.data.value().unwrap();
818        assert_eq!(
819            data.get_value(GEN_AI__COST__TOTAL_TOKENS)
820                .and_then(Value::as_f64),
821            Some(123.0)
822        );
823        assert!(data.get_value(GEN_AI__COST__OUTPUT_TOKENS).is_none());
824    }
825
826    #[test]
827    fn test_calculate_cost_backward_compatibility_no_cache_write() {
828        // Test that cost calculation works when cache_write field is missing (backward compatibility)
829        let span_data = SpanData::from([
830            (
831                GEN_AI__USAGE__INPUT_TOKENS.to_owned(),
832                Annotated::new(100.0.into()),
833            ),
834            (
835                GEN_AI__USAGE__CACHE_READ__INPUT_TOKENS.to_owned(),
836                Annotated::new(20.0.into()),
837            ),
838            (
839                GEN_AI__USAGE__OUTPUT_TOKENS.to_owned(),
840                Annotated::new(50.0.into()),
841            ),
842        ]);
843
844        let tokens = UsedTokens::from_span_data(&span_data);
845
846        // Verify cache_write_tokens defaults to 0.0
847        assert_eq!(tokens.input_cache_write_tokens, 0.0);
848
849        let cost = calculate_costs(
850            &ModelCostV2 {
851                input_per_token: 1.0,
852                output_per_token: 2.0,
853                output_reasoning_per_token: 0.0,
854                input_cached_per_token: 0.5,
855                input_cache_write_per_token: 0.75,
856            },
857            tokens,
858            "test",
859            "test",
860        )
861        .unwrap();
862
863        // Cost should be calculated without cache_write_tokens
864        // input: (100 - 20) * 1.0 + 20 * 0.5 + 0 * 0.75 = 80 + 10 + 0 = 90
865        // output: 50 * 2.0 = 100
866        insta::assert_debug_snapshot!(cost, @r"
867        CalculatedCost {
868            input: 90.0,
869            output: 100.0,
870            cache_read_input: 10.0,
871            cache_creation_input: 0.0,
872            reasoning_output: 0.0,
873        }
874        ");
875    }
876
877    /// Test that the AI operation type is inferred from a gen_ai.operation.name attribute.
878    #[test]
879    fn test_infer_ai_operation_type_from_gen_ai_operation_name() {
880        let mut span = ai_span_with_data(json!({
881            "gen_ai.operation.name": "invoke_agent"
882        }));
883
884        enrich_ai_span(&mut span, None);
885
886        assert_annotated_snapshot!(&span.data, @r#"
887        {
888          "gen_ai.operation.name": "invoke_agent",
889          "gen_ai.operation.type": "agent"
890        }
891        "#);
892    }
893
894    /// Test that the AI operation type is inferred from a span.op attribute.
895    #[test]
896    fn test_infer_ai_operation_type_from_span_op() {
897        let mut span = Span {
898            op: "gen_ai.invoke_agent".to_owned().into(),
899            ..Default::default()
900        };
901
902        enrich_ai_span(&mut span, None);
903
904        assert_annotated_snapshot!(span.data, @r#"
905        {
906          "gen_ai.operation.type": "agent"
907        }
908        "#);
909    }
910
911    /// Test that the memory operation type is inferred from a gen_ai.operation.name attribute.
912    #[test]
913    fn test_infer_ai_operation_type_memory_from_gen_ai_operation_name() {
914        let mut span = ai_span_with_data(json!({
915            "gen_ai.operation.name": "upsert_memory"
916        }));
917
918        enrich_ai_span(&mut span, None);
919
920        assert_annotated_snapshot!(&span.data, @r#"
921        {
922          "gen_ai.operation.name": "upsert_memory",
923          "gen_ai.operation.type": "memory"
924        }
925        "#);
926    }
927
928    /// Test that the memory operation type is inferred from the gen_ai.memory.client span op.
929    #[test]
930    fn test_infer_ai_operation_type_memory_from_span_op() {
931        let mut span = Span {
932            op: "gen_ai.memory.client".to_owned().into(),
933            ..Default::default()
934        };
935
936        enrich_ai_span(&mut span, None);
937
938        assert_annotated_snapshot!(span.data, @r#"
939        {
940          "gen_ai.operation.type": "memory"
941        }
942        "#);
943    }
944
945    /// Test that the AI operation type is inferred from a fallback.
946    #[test]
947    fn test_infer_ai_operation_type_from_fallback() {
948        let mut span = ai_span_with_data(json!({
949            "gen_ai.operation.name": "embeddings"
950        }));
951
952        enrich_ai_span(&mut span, None);
953
954        assert_annotated_snapshot!(&span.data, @r#"
955        {
956          "gen_ai.operation.name": "embeddings",
957          "gen_ai.operation.type": "other"
958        }
959        "#);
960    }
961
962    #[test]
963    fn test_infer_ai_operation_type_fallback_with_model() {
964        for model_key in [GEN_AI__REQUEST__MODEL, GEN_AI__RESPONSE__MODEL] {
965            let mut span = ai_span_with_data(json!({
966                "gen_ai.operation.name": "embeddings",
967                (model_key): "gpt-4",
968            }));
969
970            enrich_ai_span(&mut span, None);
971
972            assert_eq!(
973                span.data.value().unwrap().get_str(GEN_AI__OPERATION__TYPE),
974                Some("ai_client"),
975            );
976        }
977    }
978
979    /// Test that the response model is defaulted to the request model if not set.
980    #[test]
981    fn test_default_response_model_from_request_model() {
982        let mut span = ai_span_with_data(json!({
983            "gen_ai.request.model": "gpt-4",
984        }));
985
986        enrich_ai_span(&mut span, None);
987
988        assert_annotated_snapshot!(&span.data, @r#"
989        {
990          "gen_ai.operation.type": "ai_client",
991          "gen_ai.request.model": "gpt-4",
992          "gen_ai.response.model": "gpt-4"
993        }
994        "#);
995    }
996
997    /// Test that the response model is defaulted to the request model if not set.
998    #[test]
999    fn test_default_response_model_not_overridden() {
1000        let mut span = ai_span_with_data(json!({
1001            "gen_ai.request.model": "gpt-4",
1002            "gen_ai.response.model": "gpt-4-abcd",
1003        }));
1004
1005        enrich_ai_span(&mut span, None);
1006
1007        assert_annotated_snapshot!(&span.data, @r#"
1008        {
1009          "gen_ai.operation.type": "ai_client",
1010          "gen_ai.request.model": "gpt-4",
1011          "gen_ai.response.model": "gpt-4-abcd"
1012        }
1013        "#);
1014    }
1015
1016    /// Test that gen_ai.agent.name is defaulted from gen_ai.function_id.
1017    #[test]
1018    fn test_default_agent_name_from_function_id() {
1019        let mut span = ai_span_with_data(json!({
1020            "gen_ai.function_id": "my-agent",
1021        }));
1022
1023        enrich_ai_span(&mut span, None);
1024
1025        assert_annotated_snapshot!(&span.data, @r#"
1026        {
1027          "gen_ai.agent.name": "my-agent",
1028          "gen_ai.function_id": "my-agent",
1029          "gen_ai.operation.type": "other"
1030        }
1031        "#);
1032    }
1033
1034    /// Test that gen_ai.agent.name is not overridden when already set.
1035    #[test]
1036    fn test_default_agent_name_not_overridden() {
1037        let mut span = ai_span_with_data(json!({
1038            "gen_ai.function_id": "my-function",
1039            "gen_ai.agent.name": "my-agent",
1040        }));
1041
1042        enrich_ai_span(&mut span, None);
1043
1044        assert_annotated_snapshot!(&span.data, @r#"
1045        {
1046          "gen_ai.agent.name": "my-agent",
1047          "gen_ai.function_id": "my-function",
1048          "gen_ai.operation.type": "other"
1049        }
1050        "#);
1051    }
1052
1053    /// Test that an AI span is detected from a gen_ai.operation.name attribute.
1054    #[test]
1055    fn test_is_ai_span_from_gen_ai_operation_name() {
1056        let mut span_data = Annotated::default();
1057        span_data
1058            .get_or_insert_with(SpanData::default)
1059            .other
1060            .insert(
1061                GEN_AI__OPERATION__NAME.to_owned(),
1062                Annotated::new(Value::String("chat".into())),
1063            );
1064        assert!(is_ai_span(&span_data, None));
1065    }
1066
1067    /// Test that an AI span is detected from a span.op starting with "ai.".
1068    #[test]
1069    fn test_is_ai_span_from_span_op_ai() {
1070        let span_op: OperationType = "ai.chat".into();
1071        assert!(is_ai_span(&Annotated::default(), Some(&span_op)));
1072    }
1073
1074    /// Test that an AI span is detected from a span.op starting with "gen_ai.".
1075    #[test]
1076    fn test_is_ai_span_from_span_op_gen_ai() {
1077        let span_op: OperationType = "gen_ai.chat".into();
1078        assert!(is_ai_span(&Annotated::default(), Some(&span_op)));
1079    }
1080
1081    /// Test that a non-AI span is detected.
1082    #[test]
1083    fn test_is_ai_span_negative() {
1084        assert!(!is_ai_span(&Annotated::default(), None));
1085    }
1086
1087    /// Test enrich_ai_event_data with invoke_agent in trace context and a chat child span.
1088    #[test]
1089    fn test_enrich_ai_event_data_invoke_agent_trace_with_chat_span() {
1090        let event_json = r#"{
1091            "type": "transaction",
1092            "timestamp": 1234567892.0,
1093            "start_timestamp": 1234567889.0,
1094            "contexts": {
1095                "trace": {
1096                    "op": "gen_ai.invoke_agent",
1097                    "trace_id": "12345678901234567890123456789012",
1098                    "span_id": "1234567890123456",
1099                    "data": {
1100                        "gen_ai.operation.name": "gen_ai.invoke_agent",
1101                        "gen_ai.usage.input_tokens": 500,
1102                        "gen_ai.usage.output_tokens": 200
1103                    }
1104                }
1105            },
1106            "spans": [
1107                {
1108                    "op": "gen_ai.chat.completions",
1109                    "span_id": "1234567890123457",
1110                    "start_timestamp": 1234567889.5,
1111                    "timestamp": 1234567890.5,
1112                    "data": {
1113                        "gen_ai.operation.name": "chat",
1114                        "gen_ai.usage.input_tokens": 100,
1115                        "gen_ai.usage.output_tokens": 50
1116                    }
1117                }
1118            ]
1119        }"#;
1120
1121        let mut annotated_event: Annotated<Event> = Annotated::from_json(event_json).unwrap();
1122        let event = annotated_event.value_mut().as_mut().unwrap();
1123
1124        enrich_ai_event_data(event, None);
1125
1126        assert_annotated_snapshot!(&annotated_event, @r#"
1127        {
1128          "type": "transaction",
1129          "timestamp": 1234567892.0,
1130          "start_timestamp": 1234567889.0,
1131          "contexts": {
1132            "trace": {
1133              "trace_id": "12345678901234567890123456789012",
1134              "span_id": "1234567890123456",
1135              "op": "gen_ai.invoke_agent",
1136              "data": {
1137                "gen_ai.operation.name": "gen_ai.invoke_agent",
1138                "gen_ai.operation.type": "agent",
1139                "gen_ai.usage.input_tokens": 500,
1140                "gen_ai.usage.output_tokens": 200,
1141                "gen_ai.usage.total_tokens": 700.0
1142              },
1143              "type": "trace"
1144            }
1145          },
1146          "spans": [
1147            {
1148              "timestamp": 1234567890.5,
1149              "start_timestamp": 1234567889.5,
1150              "op": "gen_ai.chat.completions",
1151              "span_id": "1234567890123457",
1152              "data": {
1153                "gen_ai.operation.name": "chat",
1154                "gen_ai.operation.type": "other",
1155                "gen_ai.usage.input_tokens": 100,
1156                "gen_ai.usage.output_tokens": 50,
1157                "gen_ai.usage.total_tokens": 150.0
1158              }
1159            }
1160          ]
1161        }
1162        "#);
1163    }
1164
1165    /// Test enrich_ai_event_data with non-AI trace context, invoke_agent parent span, and chat child span.
1166    #[test]
1167    fn test_enrich_ai_event_data_nested_agent_and_chat_spans() {
1168        let event_json = r#"{
1169            "type": "transaction",
1170            "timestamp": 1234567892.0,
1171            "start_timestamp": 1234567889.0,
1172            "contexts": {
1173                "trace": {
1174                    "op": "http.server",
1175                    "trace_id": "12345678901234567890123456789012",
1176                    "span_id": "1234567890123456"
1177                }
1178            },
1179            "spans": [
1180                {
1181                    "op": "gen_ai.invoke_agent",
1182                    "span_id": "1234567890123457",
1183                    "parent_span_id": "1234567890123456",
1184                    "start_timestamp": 1234567889.5,
1185                    "timestamp": 1234567891.5,
1186                    "data": {
1187                        "gen_ai.operation.name": "invoke_agent",
1188                        "gen_ai.usage.input_tokens": 500,
1189                        "gen_ai.usage.output_tokens": 200
1190                    }
1191                },
1192                {
1193                    "op": "gen_ai.chat.completions",
1194                    "span_id": "1234567890123458",
1195                    "parent_span_id": "1234567890123457",
1196                    "start_timestamp": 1234567890.0,
1197                    "timestamp": 1234567891.0,
1198                    "data": {
1199                        "gen_ai.operation.name": "chat",
1200                        "gen_ai.usage.input_tokens": 100,
1201                        "gen_ai.usage.output_tokens": 50
1202                    }
1203                }
1204            ]
1205        }"#;
1206
1207        let mut annotated_event: Annotated<Event> = Annotated::from_json(event_json).unwrap();
1208        let event = annotated_event.value_mut().as_mut().unwrap();
1209
1210        enrich_ai_event_data(event, None);
1211
1212        assert_annotated_snapshot!(&annotated_event, @r#"
1213        {
1214          "type": "transaction",
1215          "timestamp": 1234567892.0,
1216          "start_timestamp": 1234567889.0,
1217          "contexts": {
1218            "trace": {
1219              "trace_id": "12345678901234567890123456789012",
1220              "span_id": "1234567890123456",
1221              "op": "http.server",
1222              "type": "trace"
1223            }
1224          },
1225          "spans": [
1226            {
1227              "timestamp": 1234567891.5,
1228              "start_timestamp": 1234567889.5,
1229              "op": "gen_ai.invoke_agent",
1230              "span_id": "1234567890123457",
1231              "parent_span_id": "1234567890123456",
1232              "data": {
1233                "gen_ai.operation.name": "invoke_agent",
1234                "gen_ai.operation.type": "agent",
1235                "gen_ai.usage.input_tokens": 500,
1236                "gen_ai.usage.output_tokens": 200,
1237                "gen_ai.usage.total_tokens": 700.0
1238              }
1239            },
1240            {
1241              "timestamp": 1234567891.0,
1242              "start_timestamp": 1234567890.0,
1243              "op": "gen_ai.chat.completions",
1244              "span_id": "1234567890123458",
1245              "parent_span_id": "1234567890123457",
1246              "data": {
1247                "gen_ai.operation.name": "chat",
1248                "gen_ai.operation.type": "other",
1249                "gen_ai.usage.input_tokens": 100,
1250                "gen_ai.usage.output_tokens": 50,
1251                "gen_ai.usage.total_tokens": 150.0
1252              }
1253            }
1254          ]
1255        }
1256        "#);
1257    }
1258
1259    /// Test enrich_ai_event_data with legacy measurements and span op for operation type.
1260    #[test]
1261    fn test_enrich_ai_event_data_legacy_measurements_and_span_op() {
1262        let event_json = r#"{
1263            "type": "transaction",
1264            "timestamp": 1234567892.0,
1265            "start_timestamp": 1234567889.0,
1266            "contexts": {
1267                "trace": {
1268                    "op": "http.server",
1269                    "trace_id": "12345678901234567890123456789012",
1270                    "span_id": "1234567890123456"
1271                }
1272            },
1273            "spans": [
1274                {
1275                    "op": "gen_ai.invoke_agent",
1276                    "span_id": "1234567890123457",
1277                    "parent_span_id": "1234567890123456",
1278                    "start_timestamp": 1234567889.5,
1279                    "timestamp": 1234567891.5,
1280                    "measurements": {
1281                        "ai_prompt_tokens_used": {"value": 500.0},
1282                        "ai_completion_tokens_used": {"value": 200.0}
1283                    }
1284                },
1285                {
1286                    "op": "ai.chat_completions.create.langchain.ChatOpenAI",
1287                    "span_id": "1234567890123458",
1288                    "parent_span_id": "1234567890123457",
1289                    "start_timestamp": 1234567890.0,
1290                    "timestamp": 1234567891.0,
1291                    "measurements": {
1292                        "ai_prompt_tokens_used": {"value": 100.0},
1293                        "ai_completion_tokens_used": {"value": 50.0}
1294                    }
1295                }
1296            ]
1297        }"#;
1298
1299        let mut annotated_event: Annotated<Event> = Annotated::from_json(event_json).unwrap();
1300        let event = annotated_event.value_mut().as_mut().unwrap();
1301
1302        enrich_ai_event_data(event, None);
1303
1304        assert_annotated_snapshot!(&annotated_event, @r#"
1305        {
1306          "type": "transaction",
1307          "timestamp": 1234567892.0,
1308          "start_timestamp": 1234567889.0,
1309          "contexts": {
1310            "trace": {
1311              "trace_id": "12345678901234567890123456789012",
1312              "span_id": "1234567890123456",
1313              "op": "http.server",
1314              "type": "trace"
1315            }
1316          },
1317          "spans": [
1318            {
1319              "timestamp": 1234567891.5,
1320              "start_timestamp": 1234567889.5,
1321              "op": "gen_ai.invoke_agent",
1322              "span_id": "1234567890123457",
1323              "parent_span_id": "1234567890123456",
1324              "data": {
1325                "gen_ai.operation.type": "agent",
1326                "gen_ai.usage.input_tokens": 500.0,
1327                "gen_ai.usage.output_tokens": 200.0,
1328                "gen_ai.usage.total_tokens": 700.0
1329              },
1330              "measurements": {
1331                "ai_completion_tokens_used": {
1332                  "value": 200.0
1333                },
1334                "ai_prompt_tokens_used": {
1335                  "value": 500.0
1336                }
1337              }
1338            },
1339            {
1340              "timestamp": 1234567891.0,
1341              "start_timestamp": 1234567890.0,
1342              "op": "ai.chat_completions.create.langchain.ChatOpenAI",
1343              "span_id": "1234567890123458",
1344              "parent_span_id": "1234567890123457",
1345              "data": {
1346                "gen_ai.operation.type": "other",
1347                "gen_ai.usage.input_tokens": 100.0,
1348                "gen_ai.usage.output_tokens": 50.0,
1349                "gen_ai.usage.total_tokens": 150.0
1350              },
1351              "measurements": {
1352                "ai_completion_tokens_used": {
1353                  "value": 50.0
1354                },
1355                "ai_prompt_tokens_used": {
1356                  "value": 100.0
1357                }
1358              }
1359            }
1360          ]
1361        }
1362        "#);
1363    }
1364
1365    fn metadata_with_context_size() -> ModelMetadata {
1366        ModelMetadata {
1367            version: 1,
1368            models: HashMap::from([(
1369                "claude-2.1".parse().unwrap(),
1370                ModelMetadataEntry {
1371                    costs: Some(ModelCostV2 {
1372                        input_per_token: 0.01,
1373                        output_per_token: 0.02,
1374                        output_reasoning_per_token: 0.0,
1375                        input_cached_per_token: 0.0,
1376                        input_cache_write_per_token: 0.0,
1377                    }),
1378                    context_size: Some(100_000),
1379                },
1380            )]),
1381        }
1382    }
1383
1384    #[test]
1385    fn test_context_utilization_with_total_tokens() {
1386        let mut span = Span {
1387            op: "gen_ai.test".to_owned().into(),
1388            data: SpanData::from_value(
1389                json!({
1390                    "gen_ai.response.model": "claude-2.1",
1391                    "gen_ai.usage.input_tokens": 30000.0,
1392                    "gen_ai.usage.output_tokens": 12000.0,
1393                    "gen_ai.usage.total_tokens": 42000.0,
1394                })
1395                .into(),
1396            ),
1397            ..Default::default()
1398        };
1399
1400        enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
1401
1402        let data = span.data.value().unwrap();
1403        assert_eq!(
1404            data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE)
1405                .and_then(Value::as_f64),
1406            Some(100_000.0)
1407        );
1408        assert_eq!(
1409            data.get_value(GEN_AI__CONTEXT__UTILIZATION)
1410                .and_then(Value::as_f64),
1411            Some(0.42)
1412        );
1413    }
1414
1415    #[test]
1416    fn test_context_utilization_no_context_size() {
1417        let metadata = ModelMetadata {
1418            version: 1,
1419            models: HashMap::from([(
1420                "claude-2.1".parse().unwrap(),
1421                ModelMetadataEntry {
1422                    costs: None,
1423                    context_size: None,
1424                },
1425            )]),
1426        };
1427
1428        let mut span = Span {
1429            op: "gen_ai.test".to_owned().into(),
1430            data: SpanData::from_value(
1431                json!({
1432                    "gen_ai.response.model": "claude-2.1",
1433                    "gen_ai.usage.total_tokens": 1000.0,
1434                })
1435                .into(),
1436            ),
1437            ..Default::default()
1438        };
1439
1440        enrich_ai_span(&mut span, Some(&metadata));
1441
1442        let data = span.data.value().unwrap();
1443        assert!(data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE).is_none());
1444        assert!(data.get_value(GEN_AI__CONTEXT__UTILIZATION).is_none());
1445    }
1446
1447    #[test]
1448    fn test_context_utilization_no_total_tokens() {
1449        let mut span = Span {
1450            op: "gen_ai.test".to_owned().into(),
1451            data: SpanData::from_value(
1452                json!({
1453                    "gen_ai.response.model": "claude-2.1",
1454                })
1455                .into(),
1456            ),
1457            ..Default::default()
1458        };
1459
1460        enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
1461
1462        let data = span.data.value().unwrap();
1463        // window_size should still be set even without tokens.
1464        assert_eq!(
1465            data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE)
1466                .and_then(Value::as_f64),
1467            Some(100_000.0)
1468        );
1469        // But utilization cannot be computed without total_tokens.
1470        assert!(data.get_value(GEN_AI__CONTEXT__UTILIZATION).is_none());
1471    }
1472
1473    #[test]
1474    fn test_context_utilization_unknown_model() {
1475        let mut span = Span {
1476            op: "gen_ai.test".to_owned().into(),
1477            data: SpanData::from_value(
1478                json!({
1479                    "gen_ai.response.model": "unknown-model",
1480                    "gen_ai.usage.total_tokens": 1000.0,
1481                })
1482                .into(),
1483            ),
1484            ..Default::default()
1485        };
1486
1487        enrich_ai_span(&mut span, Some(&metadata_with_context_size()));
1488
1489        let data = span.data.value().unwrap();
1490        assert!(data.get_value(GEN_AI__CONTEXT__WINDOW_SIZE).is_none());
1491        assert!(data.get_value(GEN_AI__CONTEXT__UTILIZATION).is_none());
1492    }
1493}