Problem/Motivation

LangFuseAiLoggingSubscriber::completeGeneration() forwards the AI module's raw provider usage array straight through to the SDK's usage_details field:

// web/modules/contrib/langfuse/modules/langfuse_ai_logging/src/EventSubscriber/LangFuseAiLoggingSubscriber.php:413
$outputData['usage_details'] = $raw['usage'];

When the upstream LLM response has no reasoning/thinking tokens, providers (confirmed with LiteLLM 1.88.1 proxying Vertex AI Gemini) return completion_tokens_details as an empty JSON object ({}). PHP's json_decode(..., true) collapses that into an empty PHP array ([]), and it stays that way through to the outbound Guzzle request, which re-encodes it as a JSON array (json_encode([]) → "[]", never "{}", without JSON_FORCE_OBJECT).

Langfuse's ingestion API expects an object there; receiving an array instead causes it to abandon structured input/output mapping for that generation entirely — the trace shows 0 prompt → 0 completion with everything dumped into an "Other usage" bucket, and the total is a naive sum of all the raw fields (e.g. prompt_tokens + completion_tokens + total_tokens) instead of the correct total.

Models where reasoning is always active (and thus completion_tokens_details is always non-empty) never trigger this — which made it look model-specific rather than a general shape bug.

Steps to reproduce

  1. Configure langfuse_ai_logging against a LiteLLM-proxied model where reasoning/thinking is disabled or unsupported for a given call (e.g. vertex_ai/gemini-3.5-flash with no reasoning_effort applied).
  2. Make a chat completion request.
  3. Compare the resulting Langfuse trace's usage breakdown against a call to a model that does return reasoning tokens.

Proposed resolution

Don't forward the raw provider usage array unmodified. At minimum, strip empty arrays:

$usage = $raw['usage'];
foreach (['completion_tokens_details', 'prompt_tokens_details'] as $key) {
  if (isset($usage[$key]) && $usage[$key] === []) {
    unset($usage[$key]);
  }
}
$outputData['usage_details'] = $usage;

Longer-term, usage_details should be built via an explicit mapping (prompt_tokens→input, completion_tokens→output, completion_tokens_details.reasoning_tokens→output_reasoning_tokens, etc.) rather than trusting the raw provider shape to match Langfuse's schema.

Pointers

  • web/modules/contrib/langfuse/modules/langfuse_ai_logging/src/EventSubscriber/LangFuseAiLoggingSubscriber.php:404-415
  • vendor/dropsolid/langfuse-php-sdk/src/Observability/Generation.php:98-115 (toArray() passes usageDetails through unchanged to the API payload)
  • vendor/dropsolid/langfuse-php-sdk/src/Client.php:106-135 (performHttpRequest where the array/object re-encoding actually happens)
  • Related LiteLLM upstream behavior/issue: BerriAI/litellm#31759 — inconsistent completion_tokens_details across providers/models is the trigger condition, though the type-mismatch bug itself is on the Drupal SDK/module side.

Release-line note, 2026-08-14

The two-part resolution above maps onto two different releases, which is worth stating so the module-side strip does not look like the permanent answer.

The explicit field mapping belongs in dropsolid/langfuse-php-sdk, which should not emit empty detail maps as [] at all. That is scheduled for SDK 1.3.1. The 1.0.x line is pinned to ~1.2.0 and cannot consume it, so for 1.0 the module strips the empty maps itself, exactly as proposed above. The 1.1.x line runs on SDK 1.3 and can revisit the workaround once the SDK fix lands.

One note on verification: confirming that Langfuse accepts the ingestion is not enough on its own, because the reported failure mode is miscounted usage rather than a rejected request. The token breakdown has to be checked against a known-good call, the way the steps to reproduce already describe.

Issue fork langfuse-3609260

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

nikro created an issue. See original summary.

nikro’s picture

Issue summary: View changes
nikro’s picture

Issue summary: View changes
Status: Active » Needs review

Pushed a fix. It is wider than the summary describes, so the reasoning is worth stating.

The empty detail maps are handled as proposed: they are dropped rather than forwarded, since an empty PHP array encodes to [] where LangFuse expects an object. But while testing that I found the usage block was not running at all on the chat path. $output->getRawOutput() returns an OpenAI\Responses\StreamResponse object there rather than an array, so the is_array($raw) guard was false and no usage was recorded for streamed responses whatsoever. Token counts showed as zero, which reads as a model that used no tokens rather than as missing data.

So usage is now taken from the AI module's own TokenUsageDto where available. That is provider agnostic and is the only source that survives streaming. The provider's raw array remains as a fallback for paths that do not populate the DTO, and that fallback is where the explicit field mapping lives: prompt_tokens and input_tokens both become input, completion_tokens and output_tokens become output, and the nested breakdowns are flattened with a prefix so they stay attributable. Forwarding the provider's own shape meant LangFuse received keys it does not recognise and mapped none of them.

abhisekmazumdar’s picture

Status: Needs review » Needs work

@Nikro can you see https://git.drupalcode.org/project/langfuse/-/merge_requests/8/diffs says it empty not sure why

nikro’s picture

Status: Needs work » Needs review

Yeah you're right, forgot to push =\

Also added another thing:

Pushed a one-line follow-up (cba4a10) to get the cspell job green. Four British spellings were tripping it — normalised and normalises in the subscriber, recognise and unrecognised in the new test — so those are now American, which is the Drupal standard the CI dictionary enforces. I also added litellm to .cspell.json, since it is a product name the dictionary does not know and it appears in the docblock of the captured-payload test. The unrecognised occurrence was a made-up array key in an assertion, so renaming it keeps the test identical — it is still an unknown key.

abhisekmazumdar’s picture

Status: Needs review » Reviewed & tested by the community

MR !8 (cba4a10) looks good. normalizeUsageDetails() prefers the AI module's TokenUsageDto, which also fixes usage going unrecorded on streamed responses, not just the empty-object bug and falls back to the raw provider array with an explicit field mapping, dropping empty nested detail maps so they never re-encode as []. Seven new unit tests cover the DTO path, the raw-array path, alternate provider key names, empty-map dropping, and unusable input.

The pipeline issues are not introduced by this MR.

Marking RTBC.

  • nikro committed 78304ed4 on 1.x
    Resolve #3609260 "Langfuseailogging mis forwards usagedetails"
    
nikro’s picture

Status: Reviewed & tested by the community » Fixed

Rebased and merged.

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.