mirror of
https://github.com/FJurmanovic/sugarr.git
synced 2026-11-10 23:31:08 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user