Skip to content

[Bug] LiteLLM OTel: gen_ai.usage.cost (pre-computed cost) is silently dropped and ignored #5620

@dsblank

Description

@dsblank

Summary

LiteLLM computes the exact cost of each request using its own pricing engine and sends it as a float OTel attribute (gen_ai.usage.cost). Opik silently drops this value and re-derives cost from its own bundled pricing table instead. This is why other observability platforms (PostHog, Langfuse) show the correct LiteLLM cost while Opik does not — they consume gen_ai.usage.cost directly.

Originating report: https://cometml.slack.com/archives/C02HPRHR2F9/p1772947523388679

Root Cause

gen_ai.usage.* attributes route to the USAGE outcome in GenAIMappingRules.java. The usage map is typed Map<String, Integer>, so extractUsageField() only handles integer and string OTel values:

// OpenTelemetryMappingUtils.java:88-101
public static void extractUsageField(Map<String, Integer> usage, ..., AnyValue value) {
    if (value.hasIntValue()) {
        // handle integer tokens
    } else if (value.hasStringValue()) {
        // try Integer.parseInt, then JSON object parsing
    }
    // DOUBLE_VALUE (float) falls through — silently ignored
}

gen_ai.usage.cost = 0.001234 is a DOUBLE_VALUE in OTel proto. It is never read.

Even if LiteLLM sends it as a string "0.001234", Integer.parseInt("0.001234") throws NumberFormatException, and the JSON path returns cost = 0 (float truncated to int via .intValue()). Either way, the value is lost.

Opik then calls CostService.calculateCost() independently, which may produce a different (and incorrect) result.

Suggested Fix

Detect gen_ai.usage.cost (and potentially llm.usage.cost) before the integer-only usage path and set it directly as the span's totalEstimatedCost, bypassing Opik's recalculation:

  1. Add a new Outcome.COST in OpenTelemetryMappingRule (or handle it as a special case in enrichSpanWithAttributes()).
  2. Map gen_ai.usage.costOutcome.COST in GenAIMappingRules.
  3. In OpenTelemetryMapper.enrichSpanWithAttributes(), read the float value and call spanBuilder.totalEstimatedCost(BigDecimal.valueOf(value.getDoubleValue())) with totalEstimatedCostVersion = "" so it is treated as a manually-set (authoritative) cost and not overwritten by Opik's calculator.

This matches the behaviour of Langfuse and PostHog and would make costs consistent across all observability platforms for LiteLLM users.

Relevant Files

  • apps/opik-backend/src/main/java/com/comet/opik/domain/mapping/OpenTelemetryMappingUtils.javaextractUsageField() (line 88)
  • apps/opik-backend/src/main/java/com/comet/opik/domain/mapping/otel/GenAIMappingRules.javagen_ai.usage.* rule (line 49)
  • apps/opik-backend/src/main/java/com/comet/opik/domain/OpenTelemetryMapper.javaenrichSpanWithAttributes() (line 85)
  • apps/opik-backend/src/main/java/com/comet/opik/domain/mapping/OpenTelemetryMappingRule.java

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions