Fix input/output getting misrouted by mixed usage-schema keys

The previous cached-tokens commit added cache_read_input_tokens (a
flat-schema key) alongside prompt_tokens/completion_tokens (OpenAI's
native schema) in the same usageDetails object. Langfuse's ingestion
explicitly does not support mixing those two schemas, and doing so
broke generation.update()'s output field - confirmed live: prompt/
response content was landing in the Usage panel instead of Input/
Output.

Fixed by staying entirely within OpenAI's native schema: cached
tokens now nest under prompt_tokens_details.cached_tokens instead of
using the flat cache_read_input_tokens key. Cast past the installed
@langfuse/tracing SDK's usageDetails type (which only exposes a
3-field camelCase shape with no _details nesting, unlike the wire
schema in @langfuse/core) since the nesting is valid on the wire even
though the SDK's public type doesn't model it.

Verified: full suite (82/82) passing, tsc/eslint clean.
This commit is contained in:
2026-08-12 23:24:09 +02:00
parent df4ca0bf76
commit 9a4d254968
2 changed files with 25 additions and 17 deletions

View File

@@ -11,27 +11,35 @@ import type {
TraceHandle,
} from '../llm/Tracer';
interface OpenAiUsageDetails {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
prompt_tokens_details?: { cached_tokens: number };
}
/**
* Langfuse only recognizes usage in a flat schema (input/output/...) or
* OpenAI's native snake_case schema (prompt_tokens/completion_tokens/...);
* our own ChatResult.usage uses camelCase for our internal contract, so it
* needs translating at this boundary or Langfuse silently shows zero usage.
* Langfuse recognizes usage in either a flat generic schema (input/output/
* cache_read_input_tokens/...) or OpenAI's native nested schema
* (prompt_tokens/completion_tokens/prompt_tokens_details.cached_tokens/...)
* - but NOT a mix of the two ("do not mix formats" per Langfuse's docs).
* Mixing them previously caused the whole generation.update() payload
* (including output) to land wrong, so this stays entirely within the
* OpenAI-native schema.
*
* Cached tokens use the flat schema's cache_read_input_tokens key rather
* than OpenAI's nested prompt_tokens_details.cached_tokens: the installed
* @langfuse/tracing SDK's usageDetails type is `Record<string, number> |
* OpenAiUsage` (a 3-field camelCase shape with no _details nesting) even
* though the wire schema (@langfuse/core's OpenAiCompletionUsageSchema)
* supports the nested form — keeping everything as flat numbers avoids
* fighting that gap in the SDK's public types.
* The installed @langfuse/tracing SDK's usageDetails type is
* `Record<string, number> | OpenAiUsage` (a 3-field camelCase shape with
* no _details nesting) even though the wire schema (@langfuse/core's
* OpenAiCompletionUsageSchema) supports this nested form - a gap in the
* SDK's public types, not a real runtime restriction, hence the cast.
*/
function toUsageDetails(usage: GenerationOutput['usage']): Record<string, number> | undefined {
function toUsageDetails(usage: GenerationOutput['usage']): OpenAiUsageDetails | undefined {
if (!usage) return undefined;
const details: Record<string, number> = {};
const details: OpenAiUsageDetails = {};
if (usage.promptTokens !== undefined) details.prompt_tokens = usage.promptTokens;
if (usage.completionTokens !== undefined) details.completion_tokens = usage.completionTokens;
if (usage.totalTokens !== undefined) details.total_tokens = usage.totalTokens;
if (usage.cachedTokens !== undefined) details.cache_read_input_tokens = usage.cachedTokens;
if (usage.cachedTokens !== undefined) details.prompt_tokens_details = { cached_tokens: usage.cachedTokens };
return Object.keys(details).length > 0 ? details : undefined;
}
@@ -64,7 +72,7 @@ export class LangfuseTracer extends Tracer {
end(output: GenerationOutput) {
generation.update({
output: output.output,
usageDetails: toUsageDetails(output.usage),
usageDetails: toUsageDetails(output.usage) as Record<string, number> | undefined,
metadata: output.metadata,
});
generation.end();

View File

@@ -78,7 +78,7 @@ describe('LangfuseTracer', () => {
expect(generationEnd).toHaveBeenCalledTimes(1);
});
it('maps cachedTokens to the flat cache_read_input_tokens key', () => {
it('maps cachedTokens to the OpenAI-native nested prompt_tokens_details.cached_tokens, not a mixed flat key', () => {
const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
const handle = tracer.startTrace('my-pipeline');
const gen = handle.startGeneration({ name: 'step-1', model: 'gpt-4o-mini', input: ['hi'] });
@@ -93,7 +93,7 @@ describe('LangfuseTracer', () => {
prompt_tokens: 100,
completion_tokens: 20,
total_tokens: 120,
cache_read_input_tokens: 80,
prompt_tokens_details: { cached_tokens: 80 },
},
})
);