mirror of
https://github.com/FJurmanovic/sugarr.git
synced 2026-11-10 23:31:08 +00:00
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
This commit is contained in:
@@ -73,6 +73,7 @@ export class LangfuseTracer extends Tracer {
|
|||||||
generation.update({
|
generation.update({
|
||||||
output: output.output,
|
output: output.output,
|
||||||
usageDetails: toUsageDetails(output.usage) as Record<string, number> | undefined,
|
usageDetails: toUsageDetails(output.usage) as Record<string, number> | undefined,
|
||||||
|
costDetails: output.usage?.cost !== undefined ? { total: output.usage.cost } : undefined,
|
||||||
metadata: output.metadata,
|
metadata: output.metadata,
|
||||||
});
|
});
|
||||||
generation.end();
|
generation.end();
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
|
import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions';
|
||||||
|
import type { CompletionUsage } from 'openai/resources/completions';
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: 'system' | 'user' | 'assistant';
|
role: 'system' | 'user' | 'assistant';
|
||||||
@@ -18,9 +20,23 @@ export interface ChatResult {
|
|||||||
completionTokens?: number;
|
completionTokens?: number;
|
||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
cachedTokens?: 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 {
|
export class LlmClient {
|
||||||
private readonly client: OpenAI;
|
private readonly client: OpenAI;
|
||||||
|
|
||||||
@@ -34,21 +50,25 @@ export class LlmClient {
|
|||||||
temperature: options.temperature,
|
temperature: options.temperature,
|
||||||
max_tokens: options.maxTokens,
|
max_tokens: options.maxTokens,
|
||||||
messages,
|
messages,
|
||||||
});
|
usage: { include: true },
|
||||||
|
} as OpenRouterChatCompletionCreateParams);
|
||||||
|
|
||||||
const choice = response.choices[0];
|
const choice = response.choices[0];
|
||||||
if (!choice) {
|
if (!choice) {
|
||||||
throw new Error('LLM returned no choices');
|
throw new Error('LLM returned no choices');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const usage = response.usage as OpenRouterCompletionUsage | undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: choice.message?.content ?? '',
|
content: choice.message?.content ?? '',
|
||||||
usage: response.usage
|
usage: usage
|
||||||
? {
|
? {
|
||||||
promptTokens: response.usage.prompt_tokens,
|
promptTokens: usage.prompt_tokens,
|
||||||
completionTokens: response.usage.completion_tokens,
|
completionTokens: usage.completion_tokens,
|
||||||
totalTokens: response.usage.total_tokens,
|
totalTokens: usage.total_tokens,
|
||||||
cachedTokens: response.usage.prompt_tokens_details?.cached_tokens,
|
cachedTokens: usage.prompt_tokens_details?.cached_tokens,
|
||||||
|
cost: usage.cost,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface GenerationOutput {
|
|||||||
completionTokens?: number;
|
completionTokens?: number;
|
||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
cachedTokens?: number;
|
cachedTokens?: number;
|
||||||
|
cost?: number;
|
||||||
};
|
};
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,11 +73,26 @@ describe('LangfuseTracer', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
output: 'done',
|
output: 'done',
|
||||||
usageDetails: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
usageDetails: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||||
|
costDetails: undefined,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(generationEnd).toHaveBeenCalledTimes(1);
|
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', () => {
|
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 tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' });
|
||||||
const handle = tracer.startTrace('my-pipeline');
|
const handle = tracer.startTrace('my-pipeline');
|
||||||
|
|||||||
@@ -29,6 +29,24 @@ describe('LlmClient', () => {
|
|||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
max_tokens: undefined,
|
max_tokens: undefined,
|
||||||
messages: [{ role: 'user', content: 'hi' }],
|
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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user