Skip to content

Commit 5e9dc33

Browse files
authored
expose costs in ai chat response (#2921)
* expose costs * microcents
1 parent 1e271f1 commit 5e9dc33

2 files changed

Lines changed: 130 additions & 23 deletions

File tree

src/backend/drivers/ai-chat/ChatCompletionDriver.ts

Lines changed: 128 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -357,21 +357,23 @@ export class ChatCompletionDriver extends PuterDriver {
357357
const init = res.init_chat_stream;
358358
const cleanup = res.finally_fn;
359359

360-
// Intercept `chatStream.end(usage)` so we can fire the same
361-
// complete + cost-calculated events the non-streaming branch
362-
// emits. Providers always terminate streams via `.end(usage)`;
363-
// if they skip it, we just lose the cost event (no worse than
364-
// not emitting).
360+
// Intercept `chatStream.end(usage)` to fire complete + cost events
361+
// (mirrors the non-streaming branch). Clone usage so providers that
362+
// meter after this call (e.g. Claude) don't pick up `usd_cents`.
365363
const originalEnd = chatStream.end.bind(chatStream);
366364
chatStream.end = (usage?: Record<string, number>) => {
365+
const enrichedUsage = usage ? { ...usage } : usage;
366+
if (enrichedUsage) {
367+
this.#injectUsdCents(enrichedUsage, model);
368+
}
367369
this.clients.event.emit(
368370
'ai.prompt.complete',
369371
{
370372
username,
371373
completionId,
372374
intended_service: intendedProvider,
373375
parameters: args,
374-
result: { usage, stream: true },
376+
result: { usage: enrichedUsage, stream: true },
375377
model_used: model.id,
376378
service_used: model.provider,
377379
},
@@ -386,7 +388,7 @@ export class ChatCompletionDriver extends PuterDriver {
386388
intendedProvider,
387389
});
388390
}
389-
return originalEnd(usage!);
391+
return originalEnd(enrichedUsage!);
390392
};
391393

392394
// Fire-and-forget — the stream writes happen async while the
@@ -435,6 +437,7 @@ export class ChatCompletionDriver extends PuterDriver {
435437
);
436438

437439
if ('usage' in res && res.usage) {
440+
this.#injectUsdCents(res.usage, model);
438441
this.#emitCostCalculated({
439442
completionId,
440443
username,
@@ -461,6 +464,116 @@ export class ChatCompletionDriver extends PuterDriver {
461464
return { ...res, via_ai_chat_service: true };
462465
}
463466

467+
// Compute per-token cost in microcents (1 cent = 1_000_000 microCents).
468+
// Shape-agnostic: multiplies every usage key by its matching rate in
469+
// `model.costs`. Returns `null` when cost data is unavailable.
470+
#computeCost(
471+
usage: Record<string, number>,
472+
model: IChatModel,
473+
): {
474+
inputKey: string;
475+
outputKey: string;
476+
inputTokens: number;
477+
outputTokens: number;
478+
inputMicroCents: number;
479+
outputMicroCents: number;
480+
totalMicroCents: number;
481+
} | null {
482+
const inputKey =
483+
(model.input_cost_key as string | undefined) ?? 'input_tokens';
484+
const outputKey =
485+
(model.output_cost_key as string | undefined) ?? 'output_tokens';
486+
487+
const costs = model.costs;
488+
if (!costs) return null;
489+
490+
const outputRateRaw = costs[outputKey];
491+
const outputRate =
492+
typeof outputRateRaw === 'number' && Number.isFinite(outputRateRaw)
493+
? outputRateRaw
494+
: undefined;
495+
496+
const isOutputKey = (key: string) =>
497+
key === outputKey ||
498+
key === 'output_tokens' ||
499+
key === 'completion_tokens' ||
500+
key === 'thinking_tokens';
501+
502+
let inputMicroCents = 0;
503+
let outputMicroCents = 0;
504+
let sawAnyRate = false;
505+
506+
for (const [key, rawAmount] of Object.entries(usage)) {
507+
if (typeof rawAmount !== 'number' || !Number.isFinite(rawAmount)) {
508+
continue;
509+
}
510+
511+
if (key === 'usd_cents') continue;
512+
if (key === 'tokens') continue;
513+
514+
// thinking_tokens → output rate fallback
515+
let rate = costs[key];
516+
if (typeof rate !== 'number' || !Number.isFinite(rate)) {
517+
if (key === 'thinking_tokens' && outputRate !== undefined) {
518+
rate = outputRate;
519+
} else {
520+
continue;
521+
}
522+
}
523+
524+
const sub = rawAmount * rate;
525+
sawAnyRate = true;
526+
if (isOutputKey(key)) {
527+
outputMicroCents += sub;
528+
} else {
529+
inputMicroCents += sub;
530+
}
531+
}
532+
533+
if (!sawAnyRate) return null;
534+
535+
inputMicroCents = Math.max(0, Math.round(inputMicroCents));
536+
outputMicroCents = Math.max(0, Math.round(outputMicroCents));
537+
538+
const inputTokens = Number(
539+
usage[inputKey] ?? usage.prompt_tokens ?? usage.input_tokens ?? 0,
540+
);
541+
const outputTokens = Number(
542+
usage[outputKey] ??
543+
usage.completion_tokens ??
544+
usage.output_tokens ??
545+
0,
546+
);
547+
548+
return {
549+
inputKey,
550+
outputKey,
551+
inputTokens,
552+
outputTokens,
553+
inputMicroCents,
554+
outputMicroCents,
555+
totalMicroCents: inputMicroCents + outputMicroCents,
556+
};
557+
}
558+
559+
// Add `usd_cents` to the usage object. Skips if the provider already
560+
// set an authoritative value (e.g. OpenRouter's `usage.cost`).
561+
// Sets `null` when cost data is unavailable for the model.
562+
#injectUsdCents(usage: Record<string, number>, model: IChatModel): void {
563+
if (
564+
typeof usage.usd_cents === 'number' &&
565+
Number.isFinite(usage.usd_cents)
566+
) {
567+
return;
568+
}
569+
const cost = this.#computeCost(usage, model);
570+
if (!cost) {
571+
(usage as Record<string, number | null>).usd_cents = null;
572+
return;
573+
}
574+
usage.usd_cents = cost.totalMicroCents / 1_000_000;
575+
}
576+
464577
// Compute per-token cost in microcents using the model's cost map,
465578
// then emit `ai.prompt.cost-calculated` for listeners that persist
466579
// billing/abuse rows keyed on the completion id.
@@ -474,23 +587,15 @@ export class ChatCompletionDriver extends PuterDriver {
474587
const { completionId, username, usage, model, intendedProvider } =
475588
params;
476589

590+
const cost = this.#computeCost(usage, model);
477591
const inputKey =
478592
(model.input_cost_key as string | undefined) ?? 'input_tokens';
479593
const outputKey =
480594
(model.output_cost_key as string | undefined) ?? 'output_tokens';
481-
const inputCostPer = Number(model.costs?.[inputKey] ?? 0);
482-
const outputCostPer = Number(model.costs?.[outputKey] ?? 0);
483-
const inputTokens = Number(
484-
usage[inputKey] ?? usage.prompt_tokens ?? usage.input_tokens ?? 0,
485-
);
486-
const outputTokens = Number(
487-
usage[outputKey] ??
488-
usage.completion_tokens ??
489-
usage.output_tokens ??
490-
0,
491-
);
492-
const inputUcents = Math.round(inputTokens * inputCostPer);
493-
const outputUcents = Math.round(outputTokens * outputCostPer);
595+
const inputTokens = cost?.inputTokens ?? 0;
596+
const outputTokens = cost?.outputTokens ?? 0;
597+
const inputMicroCents = cost?.inputMicroCents ?? 0;
598+
const outputMicroCents = cost?.outputMicroCents ?? 0;
494599

495600
this.clients.event.emit(
496601
'ai.prompt.cost-calculated',
@@ -500,9 +605,9 @@ export class ChatCompletionDriver extends PuterDriver {
500605
usage,
501606
input_tokens: inputTokens,
502607
output_tokens: outputTokens,
503-
input_ucents: inputUcents,
504-
output_ucents: outputUcents,
505-
total_ucents: inputUcents + outputUcents,
608+
input_ucents: inputMicroCents,
609+
output_ucents: outputMicroCents,
610+
total_ucents: inputMicroCents + outputMicroCents,
506611
costs_currency: model.costs_currency,
507612
model_used: model.id,
508613
service_used: model.provider,

src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ export class OpenRouterProvider implements IChatProvider {
185185
modelUsed.id,
186186
costOverwrites,
187187
);
188+
(trackedUsage as Record<string, number>).usd_cents =
189+
usage.cost * 100;
188190
return trackedUsage;
189191
} else {
190192
// custom open router logic because they're pricing are weird

0 commit comments

Comments
 (0)