From cb5e7085bffc010aa4eae7541229be244e99e067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Wed, 12 Aug 2026 23:43:30 +0200 Subject: [PATCH] Support OpenRouter's real per-request cost reporting Send usage: { include: true } on every chat completion request (an OpenRouter-specific extension - requires Usage Accounting enabled on the OpenRouter account; other OpenAI-compatible providers generally ignore unknown top-level request fields). When the response includes OpenRouter's own usage.cost, forward it to Langfuse as costDetails.total, so cost is the real per-request figure OpenRouter billed rather than a guess from Langfuse's model price list (which has no entry for proxied/aggregated OpenRouter model names anyway). Verified: full suite (84/84) passing, tsc/eslint clean, docker compose build succeeds. Per Langfuse's OpenRouter integration guide: https://langfuse.com/integrations/gateways/openrouter --- src/core/langfuse/LangfuseTracer.ts | 1 + src/core/llm/LlmClient.ts | 32 ++++++++++++++++++----- src/core/llm/Tracer.ts | 1 + test/core/langfuse/LangfuseTracer.test.ts | 15 +++++++++++ test/core/llm/LlmClient.test.ts | 18 +++++++++++++ 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/core/langfuse/LangfuseTracer.ts b/src/core/langfuse/LangfuseTracer.ts index 2bb2d47..fcac13e 100644 --- a/src/core/langfuse/LangfuseTracer.ts +++ b/src/core/langfuse/LangfuseTracer.ts @@ -73,6 +73,7 @@ export class LangfuseTracer extends Tracer { generation.update({ output: output.output, usageDetails: toUsageDetails(output.usage) as Record | undefined, + costDetails: output.usage?.cost !== undefined ? { total: output.usage.cost } : undefined, metadata: output.metadata, }); generation.end(); diff --git a/src/core/llm/LlmClient.ts b/src/core/llm/LlmClient.ts index f346a6d..e2db67f 100644 --- a/src/core/llm/LlmClient.ts +++ b/src/core/llm/LlmClient.ts @@ -1,4 +1,6 @@ import OpenAI from 'openai'; +import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions'; +import type { CompletionUsage } from 'openai/resources/completions'; export interface ChatMessage { role: 'system' | 'user' | 'assistant'; @@ -18,9 +20,23 @@ export interface ChatResult { completionTokens?: number; totalTokens?: number; cachedTokens?: number; + cost?: number; }; } +/** + * OpenRouter-specific request/response extensions, not part of OpenAI's + * official schema. `usage.include` asks OpenRouter to report its own real + * per-request cost (requires Usage Accounting enabled on the OpenRouter + * account); other OpenAI-compatible providers generally ignore unknown + * top-level request fields. See: + * https://langfuse.com/integrations/gateways/openrouter + */ +type OpenRouterChatCompletionCreateParams = ChatCompletionCreateParamsNonStreaming & { + usage?: { include: boolean }; +}; +type OpenRouterCompletionUsage = CompletionUsage & { cost?: number }; + export class LlmClient { private readonly client: OpenAI; @@ -34,21 +50,25 @@ export class LlmClient { temperature: options.temperature, max_tokens: options.maxTokens, messages, - }); + usage: { include: true }, + } as OpenRouterChatCompletionCreateParams); const choice = response.choices[0]; if (!choice) { throw new Error('LLM returned no choices'); } + const usage = response.usage as OpenRouterCompletionUsage | undefined; + return { content: choice.message?.content ?? '', - usage: response.usage + usage: usage ? { - promptTokens: response.usage.prompt_tokens, - completionTokens: response.usage.completion_tokens, - totalTokens: response.usage.total_tokens, - cachedTokens: response.usage.prompt_tokens_details?.cached_tokens, + promptTokens: usage.prompt_tokens, + completionTokens: usage.completion_tokens, + totalTokens: usage.total_tokens, + cachedTokens: usage.prompt_tokens_details?.cached_tokens, + cost: usage.cost, } : undefined, }; diff --git a/src/core/llm/Tracer.ts b/src/core/llm/Tracer.ts index 1ebae57..5e589a3 100644 --- a/src/core/llm/Tracer.ts +++ b/src/core/llm/Tracer.ts @@ -12,6 +12,7 @@ export interface GenerationOutput { completionTokens?: number; totalTokens?: number; cachedTokens?: number; + cost?: number; }; metadata?: Record; } diff --git a/test/core/langfuse/LangfuseTracer.test.ts b/test/core/langfuse/LangfuseTracer.test.ts index c1f8784..b29f2bf 100644 --- a/test/core/langfuse/LangfuseTracer.test.ts +++ b/test/core/langfuse/LangfuseTracer.test.ts @@ -73,11 +73,26 @@ describe('LangfuseTracer', () => { expect.objectContaining({ output: 'done', usageDetails: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + costDetails: undefined, }) ); expect(generationEnd).toHaveBeenCalledTimes(1); }); + it('forwards an explicit cost (e.g. from OpenRouter) as costDetails.total instead of relying on price-list lookup', () => { + 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: 'openrouter/auto', input: ['hi'] }); + gen.end({ + output: 'done', + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15, cost: 0.00042 }, + }); + + expect(generationUpdate).toHaveBeenCalledWith( + expect.objectContaining({ costDetails: { total: 0.00042 } }) + ); + }); + 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'); diff --git a/test/core/llm/LlmClient.test.ts b/test/core/llm/LlmClient.test.ts index 7854cd4..630c7ce 100644 --- a/test/core/llm/LlmClient.test.ts +++ b/test/core/llm/LlmClient.test.ts @@ -29,6 +29,24 @@ describe('LlmClient', () => { temperature: 0.7, max_tokens: undefined, messages: [{ role: 'user', content: 'hi' }], + usage: { include: true }, + }); + }); + + it('extracts OpenRouter cost from the response usage when present', async () => { + createMock.mockResolvedValue({ + choices: [{ message: { content: 'hello' } }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15, cost: 0.00042 }, + }); + + const client = new LlmClient({ baseUrl: 'https://openrouter.ai/api/v1', apiKey: 'key' }); + const result = await client.chat([{ role: 'user', content: 'hi' }], { model: 'openrouter/auto' }); + + expect(result.usage).toEqual({ + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + cost: 0.00042, }); });