-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Description
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:
- Add a new
Outcome.COSTinOpenTelemetryMappingRule(or handle it as a special case inenrichSpanWithAttributes()). - Map
gen_ai.usage.cost→Outcome.COSTinGenAIMappingRules. - In
OpenTelemetryMapper.enrichSpanWithAttributes(), read the float value and callspanBuilder.totalEstimatedCost(BigDecimal.valueOf(value.getDoubleValue()))withtotalEstimatedCostVersion = ""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.java—extractUsageField()(line 88)apps/opik-backend/src/main/java/com/comet/opik/domain/mapping/otel/GenAIMappingRules.java—gen_ai.usage.*rule (line 49)apps/opik-backend/src/main/java/com/comet/opik/domain/OpenTelemetryMapper.java—enrichSpanWithAttributes()(line 85)apps/opik-backend/src/main/java/com/comet/opik/domain/mapping/OpenTelemetryMappingRule.java