diff --git a/src/core/langfuse/LangfuseTracer.ts b/src/core/langfuse/LangfuseTracer.ts index be531b8..881be79 100644 --- a/src/core/langfuse/LangfuseTracer.ts +++ b/src/core/langfuse/LangfuseTracer.ts @@ -11,6 +11,21 @@ import type { TraceHandle, } from '../llm/Tracer'; +/** + * 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. + */ +function toUsageDetails(usage: GenerationOutput['usage']): Record | undefined { + if (!usage) return undefined; + const details: Record = {}; + 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; + return Object.keys(details).length > 0 ? details : undefined; +} + export class LangfuseTracer extends Tracer { private readonly processor: LangfuseSpanProcessor; private readonly sdk: NodeSDK; @@ -40,7 +55,7 @@ export class LangfuseTracer extends Tracer { end(output: GenerationOutput) { generation.update({ output: output.output, - usageDetails: output.usage as Record | undefined, + usageDetails: toUsageDetails(output.usage), metadata: output.metadata, }); generation.end(); diff --git a/test/core/langfuse/LangfuseTracer.test.ts b/test/core/langfuse/LangfuseTracer.test.ts index 2dbcf9a..0396941 100644 --- a/test/core/langfuse/LangfuseTracer.test.ts +++ b/test/core/langfuse/LangfuseTracer.test.ts @@ -59,7 +59,10 @@ describe('LangfuseTracer', () => { 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: 'llama3', input: ['hi'] }); - gen.end({ output: 'done', usage: { totalTokens: 5 } }); + gen.end({ + output: 'done', + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + }); expect(childStartObservation).toHaveBeenCalledWith( 'step-1', @@ -67,11 +70,23 @@ describe('LangfuseTracer', () => { { asType: 'generation' } ); expect(generationUpdate).toHaveBeenCalledWith( - expect.objectContaining({ output: 'done', usageDetails: { totalTokens: 5 } }) + expect.objectContaining({ + output: 'done', + usageDetails: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }) ); expect(generationEnd).toHaveBeenCalledTimes(1); }); + it('omits usageDetails entirely when no usage is reported', () => { + 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: 'llama3', input: ['hi'] }); + gen.end({ output: 'done' }); + + expect(generationUpdate).toHaveBeenCalledWith(expect.objectContaining({ usageDetails: undefined })); + }); + it('flush() force-flushes the span processor without shutting down the SDK', async () => { const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' }); await tracer.flush();