Skip to content

Codex cached input is double counted in Langfuse usage/cost #22

Description

@os-groot

Summary

The Codex tracing plugin currently maps Codex token usage into Langfuse usage details in a way that mixes two different semantics:

  • Codex/OpenAI semantics: cached_input_tokens is a subset of input_tokens
  • Langfuse cache_read_input_tokens semantics: cached input is an additional usage bucket alongside input

This causes Langfuse to display duplicated input usage and infer incorrect costs for cache-heavy Codex generations.

Current behavior

In plugins/tracing/src/parse.ts, per-generation usage is taken from:

p.info?.last_token_usage

In plugins/tracing/src/trace.ts, toUsageDetails() currently maps Codex usage approximately as:

details.input = usage.input_tokens;
details.output = usage.output_tokens;
details.total = usage.total_tokens;
details.cache_read_input_tokens = usage.cached_input_tokens;

For a Codex token_count event such as:

{
  "input_tokens": 117231,
  "cached_input_tokens": 115584,
  "output_tokens": 225,
  "total_tokens": 117456
}

the plugin sends:

{
  input: 117231,
  cache_read_input_tokens: 115584,
  output: 225,
  total: 117456
}

However, cached_input_tokens is already included in input_tokens. The actual input breakdown is:

uncached input = 117231 - 115584 = 1647
cached input   = 115584
total input    = 117231
output         = 225
total tokens   = 117456

Impact

Langfuse groups usage types containing input as input usage. As a result, the table can show:

117231 + 115584 = 232815 input-side tokens

while the explicit total remains:

117231 + 225 = 117456

This is confusing in the UI and leads to inflated inferred cost when input is charged at the full input-token rate. Cached input is effectively treated as full-price input because it is still included in input.

For the example above, the correct cost calculation should be based on:

uncached input tokens = input_tokens - cached_input_tokens
cached input tokens   = cached_input_tokens
output tokens         = output_tokens

not:

input tokens + cached input tokens + output tokens

Proposed fix

Normalize Codex usage before sending it to Langfuse:

function toUsageDetails(
  usage: TokenUsage | undefined,
): Record<string, number> | undefined {
  if (!usage) return undefined;

  const details: Record<string, number> = {};

  const inputTokens =
    typeof usage.input_tokens === "number" ? usage.input_tokens : undefined;

  const cachedInputTokens =
    typeof usage.cached_input_tokens === "number"
      ? usage.cached_input_tokens
      : 0;

  if (typeof inputTokens === "number") {
    details.input = Math.max(inputTokens - cachedInputTokens, 0);
  }

  if (cachedInputTokens > 0) {
    details.cache_read_input_tokens = cachedInputTokens;
  }

  if (typeof usage.output_tokens === "number") {
    details.output = usage.output_tokens;
  }

  if (typeof usage.total_tokens === "number") {
    details.total = usage.total_tokens;
  }

  if (typeof usage.reasoning_output_tokens === "number") {
    details.reasoning_tokens = usage.reasoning_output_tokens;
  }

  return Object.keys(details).length > 0 ? details : undefined;
}

With the same example, the plugin would send:

{
  input: 1647,
  cache_read_input_tokens: 115584,
  output: 225,
  total: 117456
}

That aligns the emitted usage with Langfuse’s additive cache_read_input_tokens model:

input + cache_read_input_tokens + output
= 1647 + 115584 + 225
= 117456

It also allows model pricing to work correctly when the Langfuse model definition has prices for:

input
cache_read_input_tokens
output

Notes

This changes the meaning of usageDetails.input emitted by the plugin from “total prompt/input tokens” to “uncached input tokens”. I think that is the right behavior when using cache_read_input_tokens, because Langfuse treats that field as a separate additive usage bucket.

Image Image

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions