mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-13 23:29:50 +00:00
refactor(ai-chat): meter every OpenAI-family completion through one pricing path
meterChatUsage records a completion's usage at buildCostsOverride's rates, or as one billedUsage line when the gateway reports what it billed, and returns the per-key costs it recorded. Providers hand those to the driver through the usageCosts channel, now carried by the Chat Completions handlers too, so the usd_cents and cost-calculated figures equal the ledger. The driver's own #computeCost is replaced by pricing.ts usageCost, which shares the rate resolution with buildCostsOverride. Billing changes, all toward what the upstream charges: - DeepSeek, Moonshot, Alibaba, Groq, MiniMax, Z.AI, Hoonify, BytePlus and the Neuralwatt catalog fallback took cached reads out of prompt_tokens nowhere, so a cached token was billed at input + cache rate. splitCachedPrompt bills it once, at the cache rate; usage.prompt_tokens on those routes now excludes cached reads, as it already did for OpenAI, Gemini and Meta. - Catalog entries that listed cached_tokens: 0 (38 Alibaba, 5 Groq) relied on that double count to bill cached reads at the input rate. The zero is dropped, so they keep exactly that price through the input-rate fallback. Neuralwatt models with no published cache price likewise. - An unpriced key falls back to the input/output rate instead of NaN (0), e.g. OpenRouter cached reads on its no-cost fallback path. - A gateway-reported cost of 0 records 0 µ¢ rather than 1 µ¢. Claude, Azure and xAI keep their metering; xAI's cached-token handling waits on the open Grok decision.
This commit is contained in:
1 parent
c6b2d5dd42
commit
fc25ad21fc
36 files changed
+793
-597
No files matched your search
@@ -95,11 +95,9 @@ import { isToolChoice, toolChoiceFromWire } from './utils/openaiParams.js';
|
||||
import {
|
||||
costKeys,
|
||||
isFreeModel,
|
||||
isOutputCostKey,
|
||||
isPerCallCostKey,
|
||||
longContextMultipliers,
|
||||
trackedInputTokens,
|
||||
trackedOutputTokens,
|
||||
usageCost,
|
||||
usageDetailsFromUsage,
|
||||
} from './utils/pricing.js';
|
||||
import {
|
||||
@@ -1218,126 +1216,6 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
};
|
||||
}
|
||||
|
||||
// Compute per-token cost in microcents (1 cent = 1_000_000 microCents).
|
||||
// Shape-agnostic: multiplies every usage key by its matching rate in
|
||||
// `model.costs`. Returns `null` when cost data is unavailable.
|
||||
//
|
||||
// `usageCosts`, when given, carries the provider's own per-key µ¢ cost
|
||||
// (set from `chatStream.usageCosts` / `res.usageCosts`) — used verbatim
|
||||
// for that key so the ledger and the reported cost match exactly instead
|
||||
// of being recomputed off this model's cost table.
|
||||
#computeCost(
|
||||
usage: Record<string, number>,
|
||||
model: IChatModel,
|
||||
usageCosts?: Record<string, number>,
|
||||
): {
|
||||
inputKey: string;
|
||||
outputKey: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
inputMicroCents: number;
|
||||
outputMicroCents: number;
|
||||
totalMicroCents: number;
|
||||
} | null {
|
||||
const { inputKey, outputKey } = costKeys(model);
|
||||
|
||||
const costs = model.costs;
|
||||
if (!costs) return null;
|
||||
|
||||
const outputRateRaw = costs[outputKey];
|
||||
const outputRate =
|
||||
typeof outputRateRaw === 'number' && Number.isFinite(outputRateRaw)
|
||||
? outputRateRaw
|
||||
: undefined;
|
||||
|
||||
const isOutputKey = (key: string) => isOutputCostKey(key, outputKey);
|
||||
const multipliers = longContextMultipliers(
|
||||
model,
|
||||
trackedInputTokens(usage, model),
|
||||
);
|
||||
|
||||
let inputMicroCents = 0;
|
||||
let outputMicroCents = 0;
|
||||
let sawAnyRate = false;
|
||||
|
||||
for (const [key, rawAmount] of Object.entries(usage)) {
|
||||
if (typeof rawAmount !== 'number' || !Number.isFinite(rawAmount)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'usd_cents') continue;
|
||||
if (key === 'tokens') continue;
|
||||
|
||||
const override = usageCosts?.[key];
|
||||
if (typeof override === 'number' && Number.isFinite(override)) {
|
||||
sawAnyRate = true;
|
||||
if (isOutputKey(key)) outputMicroCents += override;
|
||||
else inputMicroCents += override;
|
||||
continue;
|
||||
}
|
||||
// Advisor usage is priced at the advisor model's own rates
|
||||
// (metered separately, under `claude:<advisor>`), never off this
|
||||
// (executor) model's cost table — without an override there is
|
||||
// simply nothing to price it at here.
|
||||
if (key.startsWith('advisor_')) continue;
|
||||
|
||||
// thinking_tokens → output rate fallback
|
||||
let rate = costs[key];
|
||||
if (typeof rate !== 'number' || !Number.isFinite(rate)) {
|
||||
if (isOutputKey(key) && outputRate !== undefined) {
|
||||
rate = outputRate;
|
||||
} else if (!isOutputKey(key)) {
|
||||
const inputRateRaw = costs[inputKey];
|
||||
if (
|
||||
typeof inputRateRaw === 'number' &&
|
||||
Number.isFinite(inputRateRaw)
|
||||
) {
|
||||
rate = inputRateRaw;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
sawAnyRate = true;
|
||||
if (isOutputKey(key)) {
|
||||
outputMicroCents += rawAmount * rate * multipliers.output;
|
||||
} else {
|
||||
inputMicroCents +=
|
||||
rawAmount *
|
||||
rate *
|
||||
(isPerCallCostKey(key) ? 1 : multipliers.input);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sawAnyRate) return null;
|
||||
|
||||
inputMicroCents = Math.max(0, Math.round(inputMicroCents));
|
||||
outputMicroCents = Math.max(0, Math.round(outputMicroCents));
|
||||
|
||||
const inputTokens = Number(
|
||||
usage[inputKey] ?? usage.prompt_tokens ?? usage.input_tokens ?? 0,
|
||||
);
|
||||
const outputTokens = Number(
|
||||
usage[outputKey] ??
|
||||
usage.completion_tokens ??
|
||||
usage.output_tokens ??
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
inputKey,
|
||||
outputKey,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
inputMicroCents,
|
||||
outputMicroCents,
|
||||
totalMicroCents: inputMicroCents + outputMicroCents,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The credit and subscription gate for one upstream attempt.
|
||||
*
|
||||
@@ -1550,7 +1428,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
[outputKey]: outputTokens,
|
||||
};
|
||||
|
||||
const cost = this.#computeCost(usage, model);
|
||||
const cost = usageCost(usage, model);
|
||||
this.#aiMetering.utilRecordUsageObject(
|
||||
{
|
||||
[`estimated_${inputKey}`]: inputTokens,
|
||||
@@ -1593,12 +1471,13 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const cost = this.#computeCost(usage, model, usageCosts);
|
||||
const cost = usageCost(usage, model, usageCosts);
|
||||
if (!cost) {
|
||||
(usage as Record<string, number | null>).usd_cents = null;
|
||||
return;
|
||||
}
|
||||
usage.usd_cents = cost.totalMicroCents / 1_000_000;
|
||||
usage.usd_cents =
|
||||
(cost.inputMicroCents + cost.outputMicroCents) / 1_000_000;
|
||||
}
|
||||
|
||||
// Compute per-token cost in microcents using the model's cost map,
|
||||
@@ -1621,10 +1500,24 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
usageCosts,
|
||||
} = params;
|
||||
|
||||
const cost = this.#computeCost(usage, model, usageCosts);
|
||||
const cost = usageCost(usage, model, usageCosts);
|
||||
const { inputKey, outputKey } = costKeys(model);
|
||||
const inputTokens = cost?.inputTokens ?? 0;
|
||||
const outputTokens = cost?.outputTokens ?? 0;
|
||||
const inputTokens = cost
|
||||
? Number(
|
||||
usage[inputKey] ??
|
||||
usage.prompt_tokens ??
|
||||
usage.input_tokens ??
|
||||
0,
|
||||
)
|
||||
: 0;
|
||||
const outputTokens = cost
|
||||
? Number(
|
||||
usage[outputKey] ??
|
||||
usage.completion_tokens ??
|
||||
usage.output_tokens ??
|
||||
0,
|
||||
)
|
||||
: 0;
|
||||
const inputMicroCents = cost?.inputMicroCents ?? 0;
|
||||
const outputMicroCents = cost?.outputMicroCents ?? 0;
|
||||
|
||||
|
||||
@@ -187,15 +187,16 @@ describe('AlibabaProvider model catalog', () => {
|
||||
['qwen3.8-flash', 15, 47, 1.6],
|
||||
['qwen3.8-omni-flash', 15, 47, 1.6],
|
||||
['qwen3.8-max-0902', 200, 600, 25],
|
||||
['qwen3.7-max-2026-05-17', 250, 750, 0],
|
||||
['deepseek-v4.1-flash', 30, 120, 0],
|
||||
// No cache discount: cached reads bill at the input rate.
|
||||
['qwen3.7-max-2026-05-17', 250, 750, undefined],
|
||||
['deepseek-v4.1-flash', 30, 120, undefined],
|
||||
])('exposes %s at its list price', (id, input, output, cached) => {
|
||||
const model = ALIBABA_MODELS.find((m) => m.id === id);
|
||||
expect(model?.costs).toMatchObject({
|
||||
prompt_tokens: input,
|
||||
completion_tokens: output,
|
||||
cached_tokens: cached,
|
||||
});
|
||||
expect(model?.costs.cached_tokens).toBe(cached);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -593,14 +594,15 @@ describe('AlibabaProvider.complete non-stream output', () => {
|
||||
(m) => m.id === 'qwen3.8-max',
|
||||
)!;
|
||||
const [usage, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
// Cached reads come out of `prompt_tokens`; they're priced once.
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 50,
|
||||
prompt_tokens: 35,
|
||||
completion_tokens: 20,
|
||||
cached_tokens: 15,
|
||||
});
|
||||
expect(prefix).toBe('alibaba:qwen3.8-max');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 50 * Number(model.costs.prompt_tokens),
|
||||
prompt_tokens: 35 * Number(model.costs.prompt_tokens),
|
||||
completion_tokens: 20 * Number(model.costs.completion_tokens),
|
||||
cached_tokens: 15 * Number(model.costs.cached_tokens ?? 0),
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
import { ALIBABA_MODELS } from './models.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
|
||||
type AlibabaConfig = {
|
||||
apiKey: string;
|
||||
@@ -100,20 +101,16 @@ export class AlibabaProvider implements IChatProvider {
|
||||
);
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage }) => {
|
||||
const trackedUsage = OpenAIUtil.extractMeteredUsage(usage);
|
||||
const costsOverride = Object.fromEntries(
|
||||
Object.entries(trackedUsage).map(([k, v]) => {
|
||||
return [k, v * modelUsed.costs[k]];
|
||||
}),
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor!,
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
costsOverride,
|
||||
modelUsed,
|
||||
OpenAIUtil.splitCachedPrompt(usage),
|
||||
);
|
||||
return trackedUsage;
|
||||
setUsageCosts(metered.costs);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
completion,
|
||||
|
||||
@@ -41,7 +41,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 160,
|
||||
completion_tokens: 640,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -65,7 +64,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
completion_tokens: 120,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -87,7 +85,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
completion_tokens: 240,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -241,7 +238,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 40,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -265,7 +261,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30,
|
||||
completion_tokens: 150,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -291,7 +286,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 60,
|
||||
completion_tokens: 360,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -315,7 +309,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
completion_tokens: 320,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -339,7 +332,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 25,
|
||||
completion_tokens: 200,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -363,7 +355,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30,
|
||||
completion_tokens: 240,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -389,7 +380,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 24.8,
|
||||
completion_tokens: 148.5,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -413,7 +403,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 60,
|
||||
completion_tokens: 360,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -437,7 +426,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 20,
|
||||
completion_tokens: 160,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
// -- Omni models (text interface, audio costs excluded) ---------
|
||||
@@ -463,7 +451,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 43,
|
||||
completion_tokens: 166,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
// -- Translation models -----------------------------------------
|
||||
@@ -486,7 +473,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 246,
|
||||
completion_tokens: 737,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -509,7 +495,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
completion_tokens: 120,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -530,7 +515,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
completion_tokens: 120,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -551,7 +535,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
completion_tokens: 120,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
// 'qwen-plus-latest' floats to the newest snapshot; kept on the newest
|
||||
@@ -574,7 +557,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
completion_tokens: 120,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
// -- Qwen3.7 Max snapshots and preview ------------------------
|
||||
@@ -596,7 +578,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 250,
|
||||
completion_tokens: 750,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -617,7 +598,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 250,
|
||||
completion_tokens: 750,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -638,7 +618,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 250,
|
||||
completion_tokens: 750,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
// No vendor-documented release date for the preview; omitted rather
|
||||
@@ -660,7 +639,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 250,
|
||||
completion_tokens: 750,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
// -- Qwen3.5 Plus snapshots -----------------------------------
|
||||
@@ -683,7 +661,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
completion_tokens: 240,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -705,7 +682,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
completion_tokens: 240,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
// -- Qwen3.5 / Qwen3.7 / Qwen3.8 Flash -----------------------
|
||||
@@ -793,7 +769,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 300,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -814,7 +789,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
completion_tokens: 600,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
// -- Omni additions (text interface, audio costs excluded) ----
|
||||
@@ -839,7 +813,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
completion_tokens: 220,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -863,7 +836,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 140,
|
||||
completion_tokens: 830,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -909,7 +881,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 16,
|
||||
completion_tokens: 49,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
// No vendor-documented release date; omitted rather than guessed.
|
||||
@@ -930,7 +901,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 12,
|
||||
completion_tokens: 36,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
// -- Third-party models hosted on Model Studio ------------------
|
||||
@@ -953,7 +923,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 140,
|
||||
completion_tokens: 440,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -973,7 +942,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 140,
|
||||
completion_tokens: 440,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
// No vendor-documented release date for the preview; omitted rather
|
||||
@@ -994,7 +962,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 280,
|
||||
completion_tokens: 880,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
// Time-of-day pricing upstream (busy $0.44/$1.32, idle $0.22/$0.66
|
||||
@@ -1018,7 +985,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 44,
|
||||
completion_tokens: 132,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
// Time-of-day pricing upstream (busy $0.30/$1.20, idle $0.15/$0.60
|
||||
@@ -1041,7 +1007,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30,
|
||||
completion_tokens: 120,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1062,7 +1027,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 240,
|
||||
completion_tokens: 480,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
// Time-of-day pricing upstream (busy $1.32/$3.96, idle $0.66/$1.98
|
||||
@@ -1085,7 +1049,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 132,
|
||||
completion_tokens: 396,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1106,7 +1069,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 95,
|
||||
completion_tokens: 400,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -524,7 +524,7 @@ describe('BytePlusProvider.complete non-stream output', () => {
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
expect((result as { usage: unknown }).usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
});
|
||||
@@ -538,14 +538,14 @@ describe('BytePlusProvider.complete non-stream output', () => {
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
});
|
||||
expect(actor).toBe(SYSTEM_ACTOR);
|
||||
expect(prefix).toBe('byteplus:seed-2-0-mini-260428');
|
||||
expect(overrides.prompt_tokens).toBeCloseTo(
|
||||
100 * Number(seed20Mini.costs.prompt_tokens),
|
||||
90 * Number(seed20Mini.costs.prompt_tokens),
|
||||
5,
|
||||
);
|
||||
expect(overrides.completion_tokens).toBeCloseTo(
|
||||
@@ -707,7 +707,7 @@ describe('BytePlusProvider.complete streaming', () => {
|
||||
|
||||
const usageEvent = events.find((e) => e.type === 'usage');
|
||||
expect(usageEvent?.usage).toEqual({
|
||||
prompt_tokens: 4,
|
||||
prompt_tokens: 3,
|
||||
completion_tokens: 2,
|
||||
cached_tokens: 1,
|
||||
});
|
||||
@@ -719,7 +719,7 @@ describe('BytePlusProvider.complete streaming', () => {
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('byteplus:seed-2-0-mini-260428');
|
||||
expect(overrides.prompt_tokens).toBeCloseTo(
|
||||
4 * Number(seed20Mini.costs.prompt_tokens),
|
||||
3 * Number(seed20Mini.costs.prompt_tokens),
|
||||
5,
|
||||
);
|
||||
expect(overrides.completion_tokens).toBeCloseTo(
|
||||
|
||||
@@ -28,6 +28,7 @@ import { openAICompatParams } from '../../utils/openaiParams.js';
|
||||
import { BYTEPLUS_MODELS } from './models.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
|
||||
type BytePlusConfig = {
|
||||
apiKey: string;
|
||||
@@ -139,26 +140,16 @@ export class BytePlusProvider implements IChatProvider {
|
||||
);
|
||||
|
||||
const result = await OpenAIUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage }) => {
|
||||
const trackedUsage = usage
|
||||
? OpenAIUtil.extractMeteredUsage(usage)
|
||||
: {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
};
|
||||
const costsOverride = Object.fromEntries(
|
||||
Object.entries(trackedUsage).map(([key, value]) => {
|
||||
return [key, value * Number(modelUsed.costs[key] ?? 0)];
|
||||
}),
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor!,
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
costsOverride,
|
||||
modelUsed,
|
||||
OpenAIUtil.splitCachedPrompt(usage),
|
||||
);
|
||||
return trackedUsage;
|
||||
setUsageCosts(metered.costs);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
completion,
|
||||
|
||||
@@ -529,8 +529,9 @@ describe('DeepSeekProvider.complete non-stream output', () => {
|
||||
message: { content: 'hi there', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
// Cached reads come out of `prompt_tokens`; they're priced once.
|
||||
expect((result as { usage: unknown }).usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
});
|
||||
@@ -539,14 +540,14 @@ describe('DeepSeekProvider.complete non-stream output', () => {
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
});
|
||||
expect(actor).toBe(SYSTEM_ACTOR);
|
||||
expect(prefix).toBe('deepseek:deepseek-v4-pro');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 100 * Number(chat.costs.prompt_tokens),
|
||||
prompt_tokens: 90 * Number(chat.costs.prompt_tokens),
|
||||
completion_tokens: 50 * Number(chat.costs.completion_tokens),
|
||||
cached_tokens: 10 * Number(chat.costs.cached_tokens ?? 0),
|
||||
});
|
||||
@@ -667,7 +668,7 @@ describe('DeepSeekProvider.complete streaming', () => {
|
||||
|
||||
const usageEvent = events.find((e) => e.type === 'usage');
|
||||
expect(usageEvent?.usage).toEqual({
|
||||
prompt_tokens: 4,
|
||||
prompt_tokens: 3,
|
||||
completion_tokens: 2,
|
||||
cached_tokens: 1,
|
||||
});
|
||||
@@ -677,7 +678,7 @@ describe('DeepSeekProvider.complete streaming', () => {
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('deepseek:deepseek-v4-pro');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 4 * Number(chat.costs.prompt_tokens),
|
||||
prompt_tokens: 3 * Number(chat.costs.prompt_tokens),
|
||||
completion_tokens: 2 * Number(chat.costs.completion_tokens),
|
||||
cached_tokens: 1 * Number(chat.costs.cached_tokens ?? 0),
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
import { DEEPSEEK_MODELS } from './models.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
|
||||
export class DeepSeekProvider implements IChatProvider {
|
||||
#openai: OpenAI;
|
||||
@@ -131,20 +132,16 @@ export class DeepSeekProvider implements IChatProvider {
|
||||
);
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage }) => {
|
||||
const trackedUsage = OpenAIUtil.extractMeteredUsage(usage);
|
||||
const costsOverrideFromModel = Object.fromEntries(
|
||||
Object.entries(trackedUsage).map(([k, v]) => {
|
||||
return [k, v * modelUsed.costs[k]];
|
||||
}),
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor!,
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
costsOverrideFromModel,
|
||||
modelUsed,
|
||||
OpenAIUtil.splitCachedPrompt(usage),
|
||||
);
|
||||
return trackedUsage;
|
||||
setUsageCosts(metered.costs);
|
||||
return metered.usage;
|
||||
},
|
||||
stream: stream,
|
||||
completion,
|
||||
|
||||
@@ -29,10 +29,10 @@ import {
|
||||
process_input_messages,
|
||||
} from '../../utils/OpenAIUtil.js';
|
||||
import { inlineHttpImageUrls } from '../../utils/inlineImages.js';
|
||||
import { buildCostsOverride } from '../../utils/pricing.js';
|
||||
import { GEMINI_MODELS } from './models.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
|
||||
export class GeminiChatProvider implements IChatProvider {
|
||||
meteringService: MeteringService;
|
||||
@@ -126,15 +126,19 @@ export class GeminiChatProvider implements IChatProvider {
|
||||
// Cast to access Gemini-specific extras passed alongside usage:
|
||||
// - choices: non-stream grounding metadata lives in choices[0].message.extra_content
|
||||
// - extra_content: streaming grounding metadata accumulated by the stream handler
|
||||
const { usage, choices, extra_content } = args as {
|
||||
usage: typeof args.usage;
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
extra_content?: { grounding_metadata?: unknown };
|
||||
};
|
||||
}>;
|
||||
extra_content?: { grounding_metadata?: unknown };
|
||||
};
|
||||
const { usage, choices, extra_content, setUsageCosts } =
|
||||
args as {
|
||||
usage: typeof args.usage;
|
||||
setUsageCosts: typeof args.setUsageCosts;
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
extra_content?: {
|
||||
grounding_metadata?: unknown;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
extra_content?: { grounding_metadata?: unknown };
|
||||
};
|
||||
|
||||
const cached_tokens =
|
||||
usage?.prompt_tokens_details?.cached_tokens ?? 0;
|
||||
@@ -160,18 +164,15 @@ export class GeminiChatProvider implements IChatProvider {
|
||||
: 0,
|
||||
};
|
||||
|
||||
const costsOverrideFromModel = buildCostsOverride(
|
||||
trackedUsage,
|
||||
const metered = meterChatUsage(
|
||||
this.meteringService,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
modelUsed,
|
||||
);
|
||||
this.meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor!,
|
||||
this.meteringModelKey(modelUsed?.id),
|
||||
costsOverrideFromModel,
|
||||
);
|
||||
|
||||
return trackedUsage;
|
||||
setUsageCosts(metered.costs);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
completion,
|
||||
|
||||
@@ -27,6 +27,7 @@ import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
import { GROQ_MODELS } from './models.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
|
||||
export class GroqAIProvider implements IChatProvider {
|
||||
#client: Groq;
|
||||
@@ -100,20 +101,16 @@ export class GroqAIProvider implements IChatProvider {
|
||||
(chunk as { x_groq?: { usage?: CompletionUsage } }).x_groq
|
||||
?.usage,
|
||||
},
|
||||
usage_calculator: ({ usage }) => {
|
||||
const trackedUsage = OpenAIUtil.extractMeteredUsage(usage);
|
||||
const costsOverride = Object.fromEntries(
|
||||
Object.entries(trackedUsage).map(([k, v]) => {
|
||||
return [k, v * modelUsed.costs[k]];
|
||||
}),
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
costsOverride,
|
||||
modelUsed,
|
||||
OpenAIUtil.splitCachedPrompt(usage),
|
||||
);
|
||||
return trackedUsage;
|
||||
setUsageCosts(metered.costs);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
completion,
|
||||
|
||||
@@ -77,7 +77,6 @@ export const GROQ_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 7.5,
|
||||
completion_tokens: 30,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
max_tokens: 65536,
|
||||
},
|
||||
@@ -97,7 +96,6 @@ export const GROQ_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 80,
|
||||
completion_tokens: 400,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
max_tokens: 16384,
|
||||
},
|
||||
@@ -117,7 +115,6 @@ export const GROQ_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 3,
|
||||
completion_tokens: 3,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
max_tokens: 512,
|
||||
},
|
||||
@@ -137,7 +134,6 @@ export const GROQ_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 4,
|
||||
completion_tokens: 4,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
max_tokens: 512,
|
||||
},
|
||||
@@ -157,7 +153,6 @@ export const GROQ_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
max_tokens: 4096,
|
||||
},
|
||||
|
||||
@@ -552,7 +552,7 @@ describe('HoonifyProvider.complete non-stream output', () => {
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
expect((result as { usage: unknown }).usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
});
|
||||
@@ -566,14 +566,14 @@ describe('HoonifyProvider.complete non-stream output', () => {
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
});
|
||||
expect(actor).toBe(SYSTEM_ACTOR);
|
||||
expect(prefix).toBe('hoonify:zai-org/glm-5.2');
|
||||
expect(overrides.prompt_tokens).toBeCloseTo(
|
||||
100 * Number(glm.costs.prompt_tokens),
|
||||
90 * Number(glm.costs.prompt_tokens),
|
||||
5,
|
||||
);
|
||||
expect(overrides.completion_tokens).toBeCloseTo(
|
||||
@@ -704,7 +704,7 @@ describe('HoonifyProvider.complete streaming', () => {
|
||||
|
||||
const usageEvent = events.find((e) => e.type === 'usage');
|
||||
expect(usageEvent?.usage).toEqual({
|
||||
prompt_tokens: 4,
|
||||
prompt_tokens: 3,
|
||||
completion_tokens: 2,
|
||||
cached_tokens: 1,
|
||||
});
|
||||
@@ -716,7 +716,7 @@ describe('HoonifyProvider.complete streaming', () => {
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('hoonify:zai-org/glm-5.2');
|
||||
expect(overrides.prompt_tokens).toBeCloseTo(
|
||||
4 * Number(glm.costs.prompt_tokens),
|
||||
3 * Number(glm.costs.prompt_tokens),
|
||||
5,
|
||||
);
|
||||
expect(overrides.completion_tokens).toBeCloseTo(
|
||||
|
||||
@@ -27,6 +27,7 @@ import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
import { openAICompatParams } from '../../utils/openaiParams.js';
|
||||
import { HOONIFY_MODELS } from './models.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
|
||||
type HoonifyConfig = {
|
||||
apiBaseUrl?: string;
|
||||
@@ -139,27 +140,16 @@ export class HoonifyProvider implements IChatProvider {
|
||||
);
|
||||
|
||||
return await OpenAIUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage }) => {
|
||||
const trackedUsage = usage
|
||||
? OpenAIUtil.extractMeteredUsage(usage)
|
||||
: {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
};
|
||||
const costsOverrideFromModel = Object.fromEntries(
|
||||
Object.entries(trackedUsage).map(([key, value]) => {
|
||||
return [key, value * Number(modelUsed.costs[key] ?? 0)];
|
||||
}),
|
||||
);
|
||||
// `modelUsed.id` already carries the `hoonify:` namespace.
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
costsOverrideFromModel,
|
||||
modelUsed,
|
||||
OpenAIUtil.splitCachedPrompt(usage),
|
||||
);
|
||||
return trackedUsage;
|
||||
setUsageCosts(metered.costs);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
completion,
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
ICompleteArguments,
|
||||
} from '../../types.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
|
||||
/**
|
||||
* One upstream offering of a model in Infron's catalog. The same model is often
|
||||
@@ -287,9 +288,11 @@ export class InfronProvider implements IChatProvider {
|
||||
const usage_calculator = ({
|
||||
usage,
|
||||
cost,
|
||||
setUsageCosts,
|
||||
}: {
|
||||
usage: InfronUsage;
|
||||
cost?: number;
|
||||
setUsageCosts: (costs: Record<string, number>) => void;
|
||||
}) => {
|
||||
// Infron reports `cost` at the top level of the response, not
|
||||
// inside `usage`. Non-streaming calls get it via the spread
|
||||
@@ -306,42 +309,20 @@ export class InfronProvider implements IChatProvider {
|
||||
usage.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
request: 1,
|
||||
};
|
||||
if (typeof authoritativeCost === 'number') {
|
||||
// Bill the gateway-reported cost as a single line item and
|
||||
// zero the per-token costs so nothing double-bills.
|
||||
const billedTrackedUsage = { ...trackedUsage, billedUsage: 1 };
|
||||
const costOverwrites = Object.fromEntries(
|
||||
Object.keys(billedTrackedUsage).map((k) => [k, 0]),
|
||||
);
|
||||
costOverwrites.billedUsage =
|
||||
authoritativeCost * 100_000_000 || 1;
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
billedTrackedUsage,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
costOverwrites,
|
||||
);
|
||||
(billedTrackedUsage as Record<string, number>).usd_cents =
|
||||
authoritativeCost * 100;
|
||||
return billedTrackedUsage;
|
||||
}
|
||||
// Fallback: per-token pricing from the model catalog.
|
||||
const costOverwrites = Object.fromEntries(
|
||||
Object.keys(trackedUsage).map((k) => {
|
||||
return [
|
||||
k,
|
||||
(modelUsed.costs[k] ?? 0) *
|
||||
trackedUsage[k as keyof typeof trackedUsage],
|
||||
];
|
||||
}),
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
// The gateway-reported cost when there is one; the catalog's
|
||||
// per-token prices otherwise.
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
costOverwrites,
|
||||
modelUsed,
|
||||
trackedUsage,
|
||||
typeof authoritativeCost === 'number'
|
||||
? { authoritativeUsd: authoritativeCost }
|
||||
: {},
|
||||
);
|
||||
return trackedUsage;
|
||||
setUsageCosts(metered.costs);
|
||||
return metered.usage;
|
||||
};
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
|
||||
@@ -29,12 +29,12 @@ import type { IChatProvider, ICompleteArguments } from '../../types.js';
|
||||
import { make_openai_tools } from '../../utils/FunctionCalling.js';
|
||||
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
import { openAICompatParams } from '../../utils/openaiParams.js';
|
||||
import { buildCostsOverride } from '../../utils/pricing.js';
|
||||
import { processPuterPathUploads } from '../openai/fileUpload.js';
|
||||
import { META_MODELS, MUSE_SPARK_DEFAULT_MODEL } from './models.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
|
||||
const DEFAULT_API_BASE_URL = 'https://api.meta.ai/v1';
|
||||
|
||||
@@ -221,7 +221,7 @@ export class MetaProvider implements IChatProvider {
|
||||
);
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage }) => {
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
const cachedTokens =
|
||||
usage?.prompt_tokens_details?.cached_tokens ?? 0;
|
||||
// Meta reports cache reads as a subset of `prompt_tokens`, so
|
||||
@@ -234,17 +234,15 @@ export class MetaProvider implements IChatProvider {
|
||||
completion_tokens: usage?.completion_tokens ?? 0,
|
||||
cached_tokens: cachedTokens,
|
||||
};
|
||||
const costsOverride = buildCostsOverride(
|
||||
trackedUsage,
|
||||
modelUsed,
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor!,
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
costsOverride,
|
||||
modelUsed,
|
||||
trackedUsage,
|
||||
);
|
||||
return trackedUsage;
|
||||
setUsageCosts(metered.costs);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
completion,
|
||||
|
||||
@@ -349,7 +349,7 @@ describe('MiniMaxProvider.complete output and metering', () => {
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
expect((result as { usage: unknown }).usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
});
|
||||
@@ -357,14 +357,14 @@ describe('MiniMaxProvider.complete output and metering', () => {
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
});
|
||||
expect(actor).toBe(SYSTEM_ACTOR);
|
||||
expect(prefix).toBe('minimax:minimax-m2.7');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 100 * 30,
|
||||
prompt_tokens: 90 * 30,
|
||||
completion_tokens: 50 * 120,
|
||||
cached_tokens: 10 * 6,
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import { openAICompatParams } from '../../utils/openaiParams.js';
|
||||
import { MINIMAX_MODELS } from './models.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
|
||||
type MiniMaxConfig = {
|
||||
apiKey: string;
|
||||
@@ -108,26 +109,16 @@ export class MiniMaxProvider implements IChatProvider {
|
||||
);
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage }) => {
|
||||
const trackedUsage = usage
|
||||
? OpenAIUtil.extractMeteredUsage(usage)
|
||||
: {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
};
|
||||
const costsOverride = Object.fromEntries(
|
||||
Object.entries(trackedUsage).map(([key, value]) => {
|
||||
return [key, value * Number(modelUsed.costs[key] ?? 0)];
|
||||
}),
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor!,
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
costsOverride,
|
||||
modelUsed,
|
||||
OpenAIUtil.splitCachedPrompt(usage),
|
||||
);
|
||||
return trackedUsage;
|
||||
setUsageCosts(metered.costs);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
completion,
|
||||
|
||||
@@ -39,6 +39,7 @@ import { MISTRAL_MODELS } from './models.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { shouldPresentAsOpenAI } from '../../utils/normalizeToOpenAI.js';
|
||||
import { withSdkTimeout } from '../../utils/sdkClient.js';
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
|
||||
/**
|
||||
* Mistral's reasoning-capable models return `content` as a chunk array rather
|
||||
@@ -402,20 +403,18 @@ export class MistralAIProvider implements IChatProvider {
|
||||
},
|
||||
completion: completion as ChatCompletionResponse,
|
||||
stream,
|
||||
usage_calculator: ({ usage }) => {
|
||||
const trackedUsage = OpenAIUtil.extractMeteredUsage(usage);
|
||||
const costsOverrideFromModel = Object.fromEntries(
|
||||
Object.entries(trackedUsage).map(([k, v]) => {
|
||||
return [k, v * selectedModel.costs[k]];
|
||||
}),
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
// `coerceMistralUsage` has already taken cached reads out of
|
||||
// `prompt_tokens`.
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
actor,
|
||||
this.meteringModelKey(selectedModel.id),
|
||||
costsOverrideFromModel,
|
||||
selectedModel,
|
||||
OpenAIUtil.extractMeteredUsage(usage),
|
||||
);
|
||||
return trackedUsage;
|
||||
setUsageCosts(metered.costs);
|
||||
return metered.usage;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -526,7 +526,7 @@ describe('MoonshotProvider.complete non-stream output', () => {
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
expect((result as { usage: unknown }).usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
});
|
||||
@@ -536,14 +536,14 @@ describe('MoonshotProvider.complete non-stream output', () => {
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
});
|
||||
expect(actor).toBe(SYSTEM_ACTOR);
|
||||
expect(prefix).toBe('moonshotai:kimi-k2.6');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 100 * Number(kimi.costs.prompt_tokens),
|
||||
prompt_tokens: 90 * Number(kimi.costs.prompt_tokens),
|
||||
completion_tokens: 50 * Number(kimi.costs.completion_tokens),
|
||||
cached_tokens: 10 * Number(kimi.costs.cached_tokens ?? 0),
|
||||
});
|
||||
@@ -667,7 +667,7 @@ describe('MoonshotProvider.complete streaming', () => {
|
||||
|
||||
const usageEvent = events.find((e) => e.type === 'usage');
|
||||
expect(usageEvent?.usage).toEqual({
|
||||
prompt_tokens: 4,
|
||||
prompt_tokens: 3,
|
||||
completion_tokens: 2,
|
||||
cached_tokens: 1,
|
||||
});
|
||||
@@ -677,7 +677,7 @@ describe('MoonshotProvider.complete streaming', () => {
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('moonshotai:kimi-k2.6');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 4 * Number(kimi.costs.prompt_tokens),
|
||||
prompt_tokens: 3 * Number(kimi.costs.prompt_tokens),
|
||||
completion_tokens: 2 * Number(kimi.costs.completion_tokens),
|
||||
cached_tokens: 1 * Number(kimi.costs.cached_tokens ?? 0),
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import { inlineHttpImageUrls } from '../../utils/inlineImages.js';
|
||||
import { MOONSHOT_MODELS } from './models.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
|
||||
export class MoonshotProvider implements IChatProvider {
|
||||
#openai: OpenAI;
|
||||
@@ -109,20 +110,16 @@ export class MoonshotProvider implements IChatProvider {
|
||||
}
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage }) => {
|
||||
const trackedUsage = OpenAIUtil.extractMeteredUsage(usage);
|
||||
const costsOverride = Object.fromEntries(
|
||||
Object.entries(trackedUsage).map(([key, value]) => {
|
||||
return [key, value * modelUsed.costs[key]];
|
||||
}),
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
costsOverride,
|
||||
modelUsed,
|
||||
OpenAIUtil.splitCachedPrompt(usage),
|
||||
);
|
||||
return trackedUsage;
|
||||
setUsageCosts(metered.costs);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
completion,
|
||||
|
||||
@@ -563,7 +563,7 @@ describe('NeuralwattProvider.complete non-stream output', () => {
|
||||
const [usage, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('neuralwatt:deepseek-v4-flash');
|
||||
expect(usage).toMatchObject({
|
||||
prompt_tokens: 100,
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
energy_kwh: 0.00000145,
|
||||
@@ -610,13 +610,14 @@ describe('NeuralwattProvider.complete non-stream output', () => {
|
||||
const completionRate = 0.28 * 100;
|
||||
const cachedRate = 0.014 * 100;
|
||||
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
// Cached reads come out of `prompt_tokens`; they're priced once.
|
||||
expect(usage).toMatchObject({
|
||||
prompt_tokens: 100,
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
});
|
||||
expect(overrides).toMatchObject({
|
||||
prompt_tokens: 100 * promptRate,
|
||||
prompt_tokens: 90 * promptRate,
|
||||
completion_tokens: 50 * completionRate,
|
||||
cached_tokens: 10 * cachedRate,
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
type NeuralwattEnergy,
|
||||
} from './models.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
|
||||
const DEFAULT_API_BASE_URL = 'https://api.neuralwatt.com/v1';
|
||||
|
||||
@@ -229,10 +230,12 @@ export class NeuralwattProvider implements IChatProvider {
|
||||
usage,
|
||||
cost,
|
||||
energy,
|
||||
setUsageCosts,
|
||||
}: {
|
||||
usage: NeuralwattUsage;
|
||||
cost?: NeuralwattCost;
|
||||
energy?: NeuralwattEnergy;
|
||||
setUsageCosts: (costs: Record<string, number>) => void;
|
||||
}) => {
|
||||
// Non-streaming spreads the full completion into this call;
|
||||
// streaming merges top-level cost/energy onto `usage` via the
|
||||
@@ -250,7 +253,7 @@ export class NeuralwattProvider implements IChatProvider {
|
||||
measurement_available: usage.measurement_available,
|
||||
};
|
||||
|
||||
const trackedTokens = OpenAIUtil.extractMeteredUsage(usage);
|
||||
const trackedTokens = OpenAIUtil.splitCachedPrompt(usage);
|
||||
const energyUnits: Record<string, number> = {};
|
||||
if (
|
||||
energyBlock.measurement_available !== false &&
|
||||
@@ -277,48 +280,22 @@ export class NeuralwattProvider implements IChatProvider {
|
||||
return out;
|
||||
};
|
||||
|
||||
if (
|
||||
typeof requestCostUsd === 'number' &&
|
||||
Number.isFinite(requestCostUsd)
|
||||
) {
|
||||
const billedTrackedUsage = {
|
||||
...trackedTokens,
|
||||
...energyUnits,
|
||||
billedUsage: 1,
|
||||
};
|
||||
const costOverwrites = Object.fromEntries(
|
||||
Object.keys(billedTrackedUsage).map((k) => [k, 0]),
|
||||
);
|
||||
costOverwrites.billedUsage = requestCostUsd * 100_000_000;
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
billedTrackedUsage,
|
||||
actor!,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
costOverwrites,
|
||||
);
|
||||
const result = annotate(billedTrackedUsage);
|
||||
result.usd_cents = requestCostUsd * 100;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fallback: catalog token rates (preflight / when Neuralwatt
|
||||
// omits request_cost_usd).
|
||||
const trackedUsage = { ...trackedTokens, ...energyUnits };
|
||||
const costOverwrites = Object.fromEntries(
|
||||
Object.entries(trackedUsage).map(([k, v]) => {
|
||||
if (k === 'energy_kwh' || k === 'energy_joules') {
|
||||
return [k, 0];
|
||||
}
|
||||
return [k, (modelUsed.costs[k] ?? 0) * v];
|
||||
}),
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor!,
|
||||
// Billed at `request_cost_usd` when Neuralwatt reports it, at
|
||||
// the catalog's token rates otherwise. Energy is recorded, never
|
||||
// priced.
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
costOverwrites,
|
||||
modelUsed,
|
||||
{ ...trackedTokens, ...energyUnits },
|
||||
typeof requestCostUsd === 'number' &&
|
||||
Number.isFinite(requestCostUsd)
|
||||
? { authoritativeUsd: requestCostUsd }
|
||||
: { costOverrides: { energy_kwh: 0, energy_joules: 0 } },
|
||||
);
|
||||
return annotate(trackedUsage);
|
||||
setUsageCosts(metered.costs);
|
||||
return annotate(metered.usage);
|
||||
};
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
|
||||
@@ -106,10 +106,13 @@ export const mapNeuralwattApiModel = (
|
||||
|
||||
const inputUsd = Number(pricing?.input_per_million ?? 0);
|
||||
const outputUsd = Number(pricing?.output_per_million ?? 0);
|
||||
const cachedUsd =
|
||||
pricing?.cached_input_per_million == null
|
||||
? 0
|
||||
: Number(pricing.cached_input_per_million);
|
||||
const costs = usdPerMToken(
|
||||
inputUsd,
|
||||
outputUsd,
|
||||
Number(pricing?.cached_input_per_million ?? 0),
|
||||
);
|
||||
// No published cache price: cached reads bill at the input rate.
|
||||
if (pricing?.cached_input_per_million == null) delete costs.cached_tokens;
|
||||
|
||||
const caps = model.metadata?.capabilities;
|
||||
const limits = model.metadata?.limits;
|
||||
@@ -138,7 +141,7 @@ export const mapNeuralwattApiModel = (
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: usdPerMToken(inputUsd, outputUsd, cachedUsd),
|
||||
costs,
|
||||
modalities: { input: inputModalities, output: ['text'] },
|
||||
tool_call: caps?.tools === true,
|
||||
// Surfaced from Neuralwatt `/models` so `complete()` can gate
|
||||
|
||||
@@ -26,6 +26,7 @@ import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
import { IChatModel, IChatProvider, ICompleteArguments } from '../../types.js';
|
||||
import { ChatCompletionCreateParams } from 'openai/resources/index.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
/**
|
||||
* OllamaService class - Provides integration with Ollama's API for chat
|
||||
* completions Extends BaseService to implement the puter-chat-completion
|
||||
@@ -151,7 +152,7 @@ export class OllamaChatProvider implements IChatProvider {
|
||||
: `ollama:ollama/${model}`
|
||||
: undefined);
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage }) => {
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
const trackedUsage = {
|
||||
prompt:
|
||||
(usage.prompt_tokens ?? 1) -
|
||||
@@ -160,20 +161,24 @@ export class OllamaChatProvider implements IChatProvider {
|
||||
input_cache_read:
|
||||
usage.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
};
|
||||
const costOverwrites = Object.fromEntries(
|
||||
Object.keys(trackedUsage).map((k) => {
|
||||
return [k, 0]; // override to 0 since local is free
|
||||
}),
|
||||
if (!modelIdForMetering) return trackedUsage;
|
||||
// Local inference is free.
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
actor,
|
||||
modelIdForMetering,
|
||||
modelDetails ?? ({ costs: {} } as unknown as IChatModel),
|
||||
trackedUsage,
|
||||
{
|
||||
costOverrides: {
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
input_cache_read: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (modelIdForMetering) {
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor,
|
||||
modelIdForMetering,
|
||||
costOverwrites,
|
||||
);
|
||||
}
|
||||
return trackedUsage;
|
||||
setUsageCosts(metered.costs);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
completion,
|
||||
|
||||
@@ -40,13 +40,13 @@ import {
|
||||
clampReasoningEffort,
|
||||
openAICompatParams,
|
||||
} from '../../utils/openaiParams.js';
|
||||
import { buildCostsOverride } from '../../utils/pricing.js';
|
||||
import { processPuterPathUploads } from './fileUpload.js';
|
||||
import { OPEN_AI_MODELS } from './models.js';
|
||||
import type { OpenAiResponsesChatProvider } from './OpenAiChatResponsesProvider.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
|
||||
const isWebSearchTool = (tool: Record<string, unknown>): boolean =>
|
||||
tool.type === 'web_search' ||
|
||||
@@ -284,7 +284,7 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
);
|
||||
|
||||
return OpenAiUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage }) => {
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
const cachedTokens =
|
||||
usage.prompt_tokens_details?.cached_tokens ?? 0;
|
||||
// GPT-5.6 and later bill cache writes at 1.25x input. They're
|
||||
@@ -307,18 +307,15 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
: {}),
|
||||
};
|
||||
|
||||
const costsOverrideFromModel = buildCostsOverride(
|
||||
trackedUsage,
|
||||
modelUsed,
|
||||
);
|
||||
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed?.id),
|
||||
costsOverrideFromModel,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
modelUsed,
|
||||
trackedUsage,
|
||||
);
|
||||
return trackedUsage;
|
||||
setUsageCosts(metered.costs);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
completion,
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
openAICompatParams,
|
||||
rejectStatefulResponsesFields,
|
||||
} from '../../utils/openaiParams.js';
|
||||
import { buildCostsOverride } from '../../utils/pricing.js';
|
||||
import { responseSamplingParams } from '../../utils/responseSampling.js';
|
||||
import { processPuterPathUploads } from './fileUpload.js';
|
||||
import { OPEN_AI_MODELS } from './models.js';
|
||||
@@ -45,6 +44,7 @@ import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
|
||||
const ANTHROPIC_WEB_SEARCH_TYPE = (type: unknown): boolean =>
|
||||
type === 'web_search_20250305' ||
|
||||
@@ -362,32 +362,30 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
: {}),
|
||||
};
|
||||
|
||||
const costsOverrideFromModel = buildCostsOverride(
|
||||
trackedUsage,
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
modelUsed,
|
||||
);
|
||||
// Priced dynamically: the rate depends on which
|
||||
// web-search tool variant was requested and whether the model
|
||||
// is a reasoning model, not a static per-model cost-table
|
||||
// entry — `buildCostsOverride`'s fallback would otherwise
|
||||
// price it (wrongly) at the input/output token rate.
|
||||
if (webSearchCalls) {
|
||||
const rate = webSearchCallRate(tools, modelUsed.id) ?? 0;
|
||||
costsOverrideFromModel.web_search_calls =
|
||||
webSearchCalls * rate;
|
||||
setUsageCosts?.({
|
||||
web_search_calls:
|
||||
costsOverrideFromModel.web_search_calls,
|
||||
});
|
||||
}
|
||||
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor!,
|
||||
this.meteringModelKey(modelUsed?.id),
|
||||
costsOverrideFromModel,
|
||||
// Priced dynamically: the rate depends on which
|
||||
// web-search tool variant was requested and whether the
|
||||
// model is a reasoning model, not a static per-model
|
||||
// cost-table entry — the model's rates would otherwise
|
||||
// price it (wrongly) at the input/output token rate.
|
||||
webSearchCalls
|
||||
? {
|
||||
costOverrides: {
|
||||
web_search_calls:
|
||||
webSearchCalls *
|
||||
(webSearchCallRate(tools, modelUsed.id) ??
|
||||
0),
|
||||
},
|
||||
}
|
||||
: {},
|
||||
);
|
||||
return trackedUsage;
|
||||
setUsageCosts?.(metered.costs);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
completion,
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
} from '../../types.js';
|
||||
import { OPEN_ROUTER_MODEL_OVERRIDES } from './modelOverrides.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
|
||||
type OpenrouterUsage = OpenAI.Completions.CompletionUsage & {
|
||||
cost?: number;
|
||||
@@ -200,62 +201,38 @@ export class OpenRouterProvider implements IChatProvider {
|
||||
}
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage }: { usage: OpenrouterUsage }) => {
|
||||
if (typeof usage.cost === 'number') {
|
||||
// custom open router logic because they're pricing are weird
|
||||
const trackedUsage = {
|
||||
prompt:
|
||||
(usage.prompt_tokens ?? 0) -
|
||||
(usage.prompt_tokens_details?.cached_tokens ?? 0),
|
||||
completion: usage.completion_tokens ?? 0,
|
||||
input_cache_read:
|
||||
usage.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
request:
|
||||
(usage as unknown as Record<string, number>)
|
||||
.request || 1,
|
||||
billedUsage: 1,
|
||||
};
|
||||
const costOverwrites = Object.fromEntries(
|
||||
Object.keys(trackedUsage).map((k) => {
|
||||
return [k, 0]; // make everything else 0 if they don't respect their own pricing
|
||||
}),
|
||||
);
|
||||
costOverwrites.billedUsage = usage.cost * 100_000_000 || 1;
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
costOverwrites,
|
||||
);
|
||||
(trackedUsage as Record<string, number>).usd_cents =
|
||||
usage.cost * 100;
|
||||
return trackedUsage;
|
||||
} else {
|
||||
// custom open router logic because they're pricing are weird
|
||||
const trackedUsage: Record<string, number> = {
|
||||
prompt:
|
||||
(usage.prompt_tokens ?? 0) -
|
||||
(usage.prompt_tokens_details?.cached_tokens ?? 0),
|
||||
completion: usage.completion_tokens ?? 0,
|
||||
input_cache_read:
|
||||
usage.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
request:
|
||||
(usage as unknown as Record<string, number>)
|
||||
.request || 1,
|
||||
};
|
||||
const costOverwrites = Object.fromEntries(
|
||||
Object.keys(trackedUsage).map((k) => {
|
||||
return [k, modelUsed.costs[k] * trackedUsage[k]];
|
||||
}),
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
costOverwrites,
|
||||
);
|
||||
return trackedUsage;
|
||||
}
|
||||
usage_calculator: ({
|
||||
usage,
|
||||
setUsageCosts,
|
||||
}: {
|
||||
usage: OpenrouterUsage;
|
||||
setUsageCosts: (costs: Record<string, number>) => void;
|
||||
}) => {
|
||||
const trackedUsage = {
|
||||
prompt:
|
||||
(usage.prompt_tokens ?? 0) -
|
||||
(usage.prompt_tokens_details?.cached_tokens ?? 0),
|
||||
completion: usage.completion_tokens ?? 0,
|
||||
input_cache_read:
|
||||
usage.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
request:
|
||||
(usage as unknown as Record<string, number>).request ||
|
||||
1,
|
||||
};
|
||||
// OpenRouter reports what it billed (`usage.cost`, USD);
|
||||
// the catalog's prices are the fallback.
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
modelUsed,
|
||||
trackedUsage,
|
||||
typeof usage.cost === 'number'
|
||||
? { authoritativeUsd: usage.cost }
|
||||
: {},
|
||||
);
|
||||
setUsageCosts(metered.costs);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
completion,
|
||||
|
||||
@@ -29,11 +29,7 @@ import {
|
||||
isContextLengthError,
|
||||
} from '../../utils/contextLimit.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
|
||||
const TOGETHER_AI_CHAT_COST_MAP: Record<string, string> = {
|
||||
prompt_tokens: 'input',
|
||||
completion_tokens: 'output',
|
||||
};
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
|
||||
export class TogetherAIProvider implements IChatProvider {
|
||||
#together: Together;
|
||||
@@ -178,22 +174,16 @@ export class TogetherAIProvider implements IChatProvider {
|
||||
}
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage }) => {
|
||||
const trackedUsage = OpenAIUtil.extractMeteredUsage(usage);
|
||||
const costsOverride = Object.fromEntries(
|
||||
Object.entries(trackedUsage).map(([k, v]) => {
|
||||
const mappedKey = TOGETHER_AI_CHAT_COST_MAP[k] || k;
|
||||
return [k, v * modelUsed.costs[mappedKey]];
|
||||
}),
|
||||
);
|
||||
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
costsOverride,
|
||||
modelUsed,
|
||||
OpenAIUtil.splitCachedPrompt(usage),
|
||||
);
|
||||
return trackedUsage;
|
||||
setUsageCosts(metered.costs);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
completion,
|
||||
|
||||
@@ -591,7 +591,7 @@ describe('ZAIProvider.complete non-stream output', () => {
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
expect((result as { usage: unknown }).usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
});
|
||||
@@ -603,14 +603,14 @@ describe('ZAIProvider.complete non-stream output', () => {
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
});
|
||||
expect(actor).toBe(SYSTEM_ACTOR);
|
||||
expect(prefix).toBe('zai:glm-4.6');
|
||||
expect(overrides.prompt_tokens).toBeCloseTo(
|
||||
100 * Number(glm46.costs.prompt_tokens),
|
||||
90 * Number(glm46.costs.prompt_tokens),
|
||||
5,
|
||||
);
|
||||
expect(overrides.completion_tokens).toBeCloseTo(
|
||||
@@ -799,7 +799,7 @@ describe('ZAIProvider.complete streaming', () => {
|
||||
|
||||
const usageEvent = events.find((e) => e.type === 'usage');
|
||||
expect(usageEvent?.usage).toEqual({
|
||||
prompt_tokens: 4,
|
||||
prompt_tokens: 3,
|
||||
completion_tokens: 2,
|
||||
cached_tokens: 1,
|
||||
});
|
||||
@@ -809,7 +809,7 @@ describe('ZAIProvider.complete streaming', () => {
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('zai:glm-4.6');
|
||||
expect(overrides.prompt_tokens).toBeCloseTo(
|
||||
4 * Number(glm46.costs.prompt_tokens),
|
||||
3 * Number(glm46.costs.prompt_tokens),
|
||||
5,
|
||||
);
|
||||
expect(overrides.completion_tokens).toBeCloseTo(
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ZAI_MODELS } from './models.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
|
||||
// Z.AI documents `user_id` as 6-128 characters.
|
||||
const USER_ID_MAX_LENGTH = 128;
|
||||
@@ -153,26 +154,16 @@ export class ZAIProvider implements IChatProvider {
|
||||
);
|
||||
|
||||
const result = await OpenAIUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage }) => {
|
||||
const trackedUsage = usage
|
||||
? OpenAIUtil.extractMeteredUsage(usage)
|
||||
: {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
};
|
||||
const costsOverrideFromModel = Object.fromEntries(
|
||||
Object.entries(trackedUsage).map(([key, value]) => {
|
||||
return [key, value * Number(modelUsed.costs[key] ?? 0)];
|
||||
}),
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor!,
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
costsOverrideFromModel,
|
||||
modelUsed,
|
||||
OpenAIUtil.splitCachedPrompt(usage),
|
||||
);
|
||||
return trackedUsage;
|
||||
setUsageCosts(metered.costs);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
completion,
|
||||
|
||||
@@ -487,6 +487,23 @@ export const extractMeteredUsage = (usage) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Chat Completions usage as it's metered: `prompt_tokens` reports cached reads
|
||||
* inside its count, so they come out of it and are priced only once, under
|
||||
* `cached_tokens`.
|
||||
*
|
||||
* @param {import('openai/resources/completions.mjs').CompletionUsage
|
||||
* | undefined} usage
|
||||
*/
|
||||
export const splitCachedPrompt = (usage) => {
|
||||
const cachedTokens = usage?.prompt_tokens_details?.cached_tokens ?? 0;
|
||||
return {
|
||||
prompt_tokens: (usage?.prompt_tokens ?? 0) - cachedTokens,
|
||||
completion_tokens: usage?.completion_tokens ?? 0,
|
||||
cached_tokens: cachedTokens,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The `{prompt_tokens, completion_tokens, cached_tokens, cache_write_tokens}`
|
||||
* shape every OpenAI-family `usage_calculator` already returns for billing,
|
||||
@@ -674,6 +691,7 @@ export const create_chat_stream_handler =
|
||||
? usage_calculator({
|
||||
usage: last_usage,
|
||||
extra_content: last_extra_content,
|
||||
setUsageCosts: (c) => chatStream.setUsageCosts(c),
|
||||
})
|
||||
: undefined;
|
||||
// The calculator just metered. Reported here, not only via `end`:
|
||||
@@ -875,6 +893,7 @@ export const handle_completion_output = async (
|
||||
* @type {Record<string, unknown> & {
|
||||
* usage_calculator: (args: {
|
||||
* usage: import('openai/resources/completions.mjs').CompletionUsage;
|
||||
* setUsageCosts: (costs: Record<string, number>) => void;
|
||||
* }) => unknown;
|
||||
* }}
|
||||
*/
|
||||
@@ -927,6 +946,9 @@ export const handle_completion_output = async (
|
||||
? usage_calculator({
|
||||
...completion,
|
||||
usage: completion_usage,
|
||||
setUsageCosts: (c) => {
|
||||
ret.usageCosts = c;
|
||||
},
|
||||
})
|
||||
: {
|
||||
input_tokens: completion_usage.prompt_tokens,
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
handle_completion_output_responses_api,
|
||||
process_input_messages,
|
||||
process_input_messages_responses_api,
|
||||
splitCachedPrompt,
|
||||
toOpenAIChatMessages,
|
||||
usageDetailsFromTrackedUsage,
|
||||
} from './OpenAIUtil.js';
|
||||
@@ -833,6 +834,73 @@ describe('extractMeteredUsage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── splitCachedPrompt ───────────────────────────────────────────────
|
||||
|
||||
describe('splitCachedPrompt', () => {
|
||||
it('takes cached reads out of prompt_tokens', () => {
|
||||
expect(
|
||||
splitCachedPrompt({
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
prompt_tokens_details: { cached_tokens: 3 },
|
||||
}),
|
||||
).toEqual({
|
||||
prompt_tokens: 7,
|
||||
completion_tokens: 5,
|
||||
cached_tokens: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a missing usage as nothing used', () => {
|
||||
expect(splitCachedPrompt(undefined)).toEqual({
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── recorded usage costs ────────────────────────────────────────────
|
||||
|
||||
describe('chat completion handlers carry the recorded usage costs', () => {
|
||||
const usage_calculator = ({
|
||||
usage,
|
||||
setUsageCosts,
|
||||
}: {
|
||||
usage: Record<string, number>;
|
||||
setUsageCosts: (costs: Record<string, number>) => void;
|
||||
}) => {
|
||||
setUsageCosts({ prompt_tokens: 42 });
|
||||
return usage;
|
||||
};
|
||||
|
||||
it('onto a non-stream result', async () => {
|
||||
const result = await handle_completion_output({
|
||||
stream: false,
|
||||
completion: {
|
||||
choices: [{ message: { content: 'hi' } }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
},
|
||||
usage_calculator,
|
||||
});
|
||||
expect(result.usageCosts).toEqual({ prompt_tokens: 42 });
|
||||
});
|
||||
|
||||
it('onto the chat stream', async () => {
|
||||
const { chatStream } = makeCapturingChatStream();
|
||||
await create_chat_stream_handler({
|
||||
completion: asAsyncIterable([
|
||||
{
|
||||
choices: [{ delta: { content: 'hi' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
},
|
||||
]),
|
||||
usage_calculator,
|
||||
})({ chatStream });
|
||||
expect(chatStream.usageCosts).toEqual({ prompt_tokens: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── create_chat_stream_handler ──────────────────────────────────────
|
||||
|
||||
describe('create_chat_stream_handler', () => {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Actor } from '../../../core/actor.js';
|
||||
import { ALIBABA_MODELS } from '../providers/alibaba/models.js';
|
||||
import { BYTEPLUS_MODELS } from '../providers/byteplus/models.js';
|
||||
import { DEEPSEEK_MODELS } from '../providers/deepseek/models.js';
|
||||
import { GROQ_MODELS } from '../providers/groq/models.js';
|
||||
import { HOONIFY_MODELS } from '../providers/hoonify/models.js';
|
||||
import { MINIMAX_MODELS } from '../providers/minimax/models.js';
|
||||
import { MISTRAL_MODELS } from '../providers/mistral/models.js';
|
||||
import { MOONSHOT_MODELS } from '../providers/moonshot/models.js';
|
||||
import { ZAI_MODELS } from '../providers/zai/models.js';
|
||||
import type { IChatModel } from '../types.js';
|
||||
import { meterChatUsage } from './meterChatUsage.js';
|
||||
import { splitCachedPrompt } from './OpenAIUtil.js';
|
||||
|
||||
type Recorded = [Record<string, number>, string, Record<string, number>];
|
||||
|
||||
const recordingMetering = () => {
|
||||
const calls: Recorded[] = [];
|
||||
return {
|
||||
calls,
|
||||
metering: {
|
||||
utilRecordUsageObject: (
|
||||
usage: Record<string, number>,
|
||||
_actor: Actor,
|
||||
key: string,
|
||||
costs?: Record<string, number>,
|
||||
) => {
|
||||
calls.push([usage, key, costs ?? {}]);
|
||||
return Promise.resolve({ total: 0 } as never);
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const actor = {} as Actor;
|
||||
const model = {
|
||||
id: 'm',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: { tokens: 1_000_000, prompt_tokens: 3, completion_tokens: 15 },
|
||||
max_tokens: 1024,
|
||||
} as IChatModel;
|
||||
|
||||
describe('meterChatUsage', () => {
|
||||
it('records the usage at the model rates and returns what it recorded', () => {
|
||||
const { calls, metering } = recordingMetering();
|
||||
const usage = { prompt_tokens: 10, completion_tokens: 2 };
|
||||
|
||||
const result = meterChatUsage(metering, actor, 'p:m', model, usage);
|
||||
|
||||
expect(calls).toEqual([
|
||||
[usage, 'p:m', { prompt_tokens: 30, completion_tokens: 30 }],
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
usage,
|
||||
costs: { prompt_tokens: 30, completion_tokens: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
it('lets a cost override win over the model rates', () => {
|
||||
const { calls, metering } = recordingMetering();
|
||||
meterChatUsage(
|
||||
metering,
|
||||
actor,
|
||||
'p:m',
|
||||
model,
|
||||
{ prompt_tokens: 10, web_search_calls: 2 },
|
||||
{ costOverrides: { web_search_calls: 1000 } },
|
||||
);
|
||||
expect(calls[0]![2]).toEqual({
|
||||
prompt_tokens: 30,
|
||||
web_search_calls: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('bills an authoritative USD cost as one line and zeroes the tokens', () => {
|
||||
const { calls, metering } = recordingMetering();
|
||||
const result = meterChatUsage(
|
||||
metering,
|
||||
actor,
|
||||
'gw:m',
|
||||
model,
|
||||
{ prompt: 10, completion: 2 },
|
||||
{ authoritativeUsd: 0.25 },
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
[
|
||||
{ prompt: 10, completion: 2, billedUsage: 1 },
|
||||
'gw:m',
|
||||
{ prompt: 0, completion: 0, billedUsage: 25_000_000 },
|
||||
],
|
||||
]);
|
||||
expect(result.usage).toEqual({
|
||||
prompt: 10,
|
||||
completion: 2,
|
||||
billedUsage: 1,
|
||||
usd_cents: 25,
|
||||
});
|
||||
expect(result.costs.billedUsage).toBe(25_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
// Every catalog the hand-rolled `amount * costs[key]` pricing used to bill.
|
||||
// A cached read is priced once, at the model's cache rate, or at its input
|
||||
// rate when it publishes none; it is never free on a paid model.
|
||||
describe('cached reads on OpenAI-compatible catalogs', () => {
|
||||
const catalogs: [string, readonly IChatModel[]][] = [
|
||||
['alibaba', ALIBABA_MODELS],
|
||||
['byteplus', BYTEPLUS_MODELS],
|
||||
['deepseek', DEEPSEEK_MODELS],
|
||||
['groq', GROQ_MODELS],
|
||||
['hoonify', HOONIFY_MODELS],
|
||||
['minimax', MINIMAX_MODELS],
|
||||
['mistral', MISTRAL_MODELS],
|
||||
['moonshotai', MOONSHOT_MODELS],
|
||||
['zai', ZAI_MODELS],
|
||||
];
|
||||
const cases = catalogs.flatMap(([provider, models]) =>
|
||||
models.map((m) => [`${provider}:${m.id}`, m] as const),
|
||||
);
|
||||
|
||||
it.each(cases)('%s', (_name, m) => {
|
||||
const { calls, metering } = recordingMetering();
|
||||
meterChatUsage(
|
||||
metering,
|
||||
actor,
|
||||
'k',
|
||||
m,
|
||||
splitCachedPrompt({
|
||||
prompt_tokens: 1_000_000,
|
||||
completion_tokens: 100_000,
|
||||
prompt_tokens_details: { cached_tokens: 200_000 },
|
||||
total_tokens: 1_100_000,
|
||||
}),
|
||||
);
|
||||
const costs = m.costs as Record<string, number>;
|
||||
const cachedRate = costs.cached_tokens ?? costs.prompt_tokens;
|
||||
expect(calls[0]![2]).toEqual({
|
||||
prompt_tokens: 800_000 * costs.prompt_tokens,
|
||||
completion_tokens: 100_000 * costs.completion_tokens,
|
||||
cached_tokens: 200_000 * cachedRate,
|
||||
});
|
||||
if (costs.prompt_tokens > 0) expect(cachedRate).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Actor } from '../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../services/metering/MeteringService.js';
|
||||
import type { IChatModel } from '../types.js';
|
||||
import { buildCostsOverride } from './pricing.js';
|
||||
|
||||
/** Microcents per US dollar. */
|
||||
const MICROCENTS_PER_USD = 100_000_000;
|
||||
|
||||
export interface MeterChatUsageOptions {
|
||||
/**
|
||||
* What the upstream says it billed, in USD. Recorded as one `billedUsage`
|
||||
* line instead of pricing the tokens, which are still recorded at zero.
|
||||
*/
|
||||
authoritativeUsd?: number;
|
||||
/** Per-key µ¢ costs that win over the model's rates. */
|
||||
costOverrides?: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records one completion's usage under `meteringKey` and returns the usage to
|
||||
* report along with the µ¢ each key was recorded at, so the driver can quote
|
||||
* the same cost the ledger holds.
|
||||
*/
|
||||
export const meterChatUsage = (
|
||||
metering: Pick<MeteringService, 'utilRecordUsageObject'>,
|
||||
actor: Actor | undefined,
|
||||
meteringKey: string,
|
||||
model: IChatModel,
|
||||
usage: Record<string, number>,
|
||||
{ authoritativeUsd, costOverrides }: MeterChatUsageOptions = {},
|
||||
): { usage: Record<string, number>; costs: Record<string, number> } => {
|
||||
if (authoritativeUsd !== undefined) {
|
||||
const billed = { ...usage, billedUsage: 1 };
|
||||
const costs: Record<string, number> = Object.fromEntries(
|
||||
Object.keys(billed).map((key) => [key, 0]),
|
||||
);
|
||||
costs.billedUsage = authoritativeUsd * MICROCENTS_PER_USD;
|
||||
metering.utilRecordUsageObject(billed, actor!, meteringKey, costs);
|
||||
return {
|
||||
usage: { ...billed, usd_cents: authoritativeUsd * 100 },
|
||||
costs,
|
||||
};
|
||||
}
|
||||
const costs = { ...buildCostsOverride(usage, model), ...costOverrides };
|
||||
metering.utilRecordUsageObject(usage, actor!, meteringKey, costs);
|
||||
return { usage, costs };
|
||||
};
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
longContextMultipliers,
|
||||
trackedInputTokens,
|
||||
trackedOutputTokens,
|
||||
usageCost,
|
||||
usageDetailsFromUsage,
|
||||
usdPerMToken,
|
||||
} from './pricing.js';
|
||||
@@ -350,3 +351,82 @@ describe('long-context pricing', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('usageCost', () => {
|
||||
const rates = model({
|
||||
prompt_tokens: 3,
|
||||
completion_tokens: 15,
|
||||
cached_tokens: 0.3,
|
||||
});
|
||||
|
||||
it('splits the priced usage into input and output sides', () => {
|
||||
expect(
|
||||
usageCost(
|
||||
{ prompt_tokens: 100, completion_tokens: 10, cached_tokens: 50 },
|
||||
rates,
|
||||
),
|
||||
).toEqual({ inputMicroCents: 315, outputMicroCents: 150 });
|
||||
});
|
||||
|
||||
it('agrees with what buildCostsOverride records', () => {
|
||||
const usage = {
|
||||
prompt_tokens: 1234,
|
||||
completion_tokens: 567,
|
||||
cached_tokens: 89,
|
||||
grounding_requests: 1,
|
||||
};
|
||||
const recorded = Object.values(buildCostsOverride(usage, rates)).reduce(
|
||||
(a, b) => a + b,
|
||||
0,
|
||||
);
|
||||
const cost = usageCost(usage, rates)!;
|
||||
expect(cost.inputMicroCents + cost.outputMicroCents).toBe(
|
||||
Math.round(recorded),
|
||||
);
|
||||
});
|
||||
|
||||
it('takes the costs a provider recorded over the model rates', () => {
|
||||
expect(
|
||||
usageCost(
|
||||
{ prompt_tokens: 100, billedUsage: 1, usd_cents: 0.5 },
|
||||
rates,
|
||||
{ prompt_tokens: 0, billedUsage: 500_000 },
|
||||
),
|
||||
).toEqual({ inputMicroCents: 500_000, outputMicroCents: 0 });
|
||||
});
|
||||
|
||||
it('prices advisor usage only through a recorded cost', () => {
|
||||
expect(
|
||||
usageCost(
|
||||
{ prompt_tokens: 10, advisor_output_tokens: 1000 },
|
||||
rates,
|
||||
),
|
||||
).toEqual({ inputMicroCents: 30, outputMicroCents: 0 });
|
||||
expect(
|
||||
usageCost(
|
||||
{ prompt_tokens: 10, advisor_output_tokens: 1000 },
|
||||
rates,
|
||||
{ advisor_output_tokens: 7 },
|
||||
),
|
||||
).toEqual({ inputMicroCents: 30, outputMicroCents: 7 });
|
||||
});
|
||||
|
||||
it('is null when nothing in the usage can be priced', () => {
|
||||
expect(
|
||||
usageCost({ prompt_tokens: 10, completion_tokens: 5 }, model({})),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('skips usd_cents and non-numeric annotations', () => {
|
||||
expect(
|
||||
usageCost(
|
||||
{
|
||||
prompt_tokens: 10,
|
||||
usd_cents: 99,
|
||||
accounting_method: 'energy',
|
||||
},
|
||||
rates,
|
||||
),
|
||||
).toEqual({ inputMicroCents: 30, outputMicroCents: 0 });
|
||||
});
|
||||
});
|
||||
@@ -161,8 +161,8 @@ export const isFreeModel = (model: IChatModel): boolean => {
|
||||
* A fallback [[UsageDetails]] for providers that haven't been taught to report
|
||||
* their own (every non-Claude provider, for now). `inputTokens` and
|
||||
* `outputTokens` reuse the same tracked-token accounting the credit gate and
|
||||
* `#computeCost` already agree on, so a provider with no detail of its own
|
||||
* still gets a usable breakdown.
|
||||
* `usageCost` already agree on, so a provider with no detail of its own still
|
||||
* gets a usable breakdown.
|
||||
*/
|
||||
export const usageDetailsFromUsage = (
|
||||
usage: Record<string, unknown>,
|
||||
@@ -172,6 +172,52 @@ export const usageDetailsFromUsage = (
|
||||
outputTokens: trackedOutputTokens(usage, model),
|
||||
});
|
||||
|
||||
/** A usage key/amount pair that carries a price, rather than a scale or a label. */
|
||||
const isBillable = (key: string, amount: unknown): amount is number =>
|
||||
key !== 'tokens' && key !== 'usd_cents' && isRate(amount);
|
||||
|
||||
/**
|
||||
* The µ¢ each usage key costs at the model's rates. A key the model doesn't
|
||||
* price falls back to the output rate when it is output-denominated and the
|
||||
* input rate otherwise; a key neither covers is left out.
|
||||
*/
|
||||
const priceUsage = (
|
||||
trackedUsage: Record<string, unknown>,
|
||||
model: IChatModel,
|
||||
): Record<string, number> => {
|
||||
const { inputKey, outputKey } = costKeys(model);
|
||||
|
||||
const costs = model.costs ?? {};
|
||||
const inputRate = isRate(costs[inputKey]) ? costs[inputKey] : undefined;
|
||||
const outputRate = isRate(costs[outputKey]) ? costs[outputKey] : undefined;
|
||||
|
||||
const multipliers = longContextMultipliers(
|
||||
model,
|
||||
trackedInputTokens(trackedUsage, model),
|
||||
);
|
||||
|
||||
const priced: Record<string, number> = {};
|
||||
for (const [key, amount] of Object.entries(trackedUsage)) {
|
||||
if (!isBillable(key, amount)) continue;
|
||||
|
||||
const isOutput = isOutputCostKey(key, outputKey);
|
||||
const rate = isRate(costs[key])
|
||||
? costs[key]
|
||||
: isOutput
|
||||
? outputRate
|
||||
: inputRate;
|
||||
if (rate === undefined) continue;
|
||||
|
||||
const multiplier = isPerCallCostKey(key)
|
||||
? 1
|
||||
: isOutput
|
||||
? multipliers.output
|
||||
: multipliers.input;
|
||||
priced[key] = amount * rate * multiplier;
|
||||
}
|
||||
return priced;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prices a tracked-usage object against a model's cost table.
|
||||
*
|
||||
@@ -187,35 +233,51 @@ export const buildCostsOverride = (
|
||||
trackedUsage: Record<string, number>,
|
||||
model: IChatModel,
|
||||
): Record<string, number> => {
|
||||
const { inputKey, outputKey } = costKeys(model);
|
||||
|
||||
const costs = model.costs ?? {};
|
||||
const inputRate = isRate(costs[inputKey]) ? costs[inputKey] : undefined;
|
||||
const outputRate = isRate(costs[outputKey]) ? costs[outputKey] : undefined;
|
||||
|
||||
const multipliers = longContextMultipliers(
|
||||
model,
|
||||
trackedInputTokens(trackedUsage, model),
|
||||
);
|
||||
|
||||
const priced = priceUsage(trackedUsage, model);
|
||||
const overrides: Record<string, number> = {};
|
||||
for (const [key, amount] of Object.entries(trackedUsage)) {
|
||||
for (const key of Object.keys(trackedUsage)) {
|
||||
// `tokens` is a scale descriptor ("costs expressed per N tokens"),
|
||||
// not a per-unit rate.
|
||||
if (key === 'tokens') continue;
|
||||
|
||||
const isOutput = isOutputCostKey(key, outputKey);
|
||||
const rate = isRate(costs[key])
|
||||
? costs[key]
|
||||
: ((isOutput ? outputRate : inputRate) ?? 0);
|
||||
|
||||
const multiplier = isPerCallCostKey(key)
|
||||
? 1
|
||||
: isOutput
|
||||
? multipliers.output
|
||||
: multipliers.input;
|
||||
overrides[key] = amount * rate * multiplier;
|
||||
overrides[key] = priced[key] ?? 0;
|
||||
}
|
||||
|
||||
return overrides;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a usage object costs in µ¢, split into its input and output sides.
|
||||
* `recordedCosts` are the per-key costs a provider already metered with and win
|
||||
* over the model's rates, so the figure matches the ledger. Null when nothing
|
||||
* in the usage could be priced.
|
||||
*/
|
||||
export const usageCost = (
|
||||
usage: Record<string, unknown>,
|
||||
model: IChatModel,
|
||||
recordedCosts?: Record<string, number>,
|
||||
): { inputMicroCents: number; outputMicroCents: number } | null => {
|
||||
const { outputKey } = costKeys(model);
|
||||
const priced = priceUsage(usage, model);
|
||||
let input = 0;
|
||||
let output = 0;
|
||||
let sawAnyCost = false;
|
||||
for (const [key, amount] of Object.entries(usage)) {
|
||||
if (!isBillable(key, amount)) continue;
|
||||
const recorded = recordedCosts?.[key];
|
||||
// Advisor usage is priced at the advisor model's own rates, so only a
|
||||
// recorded cost can price it here.
|
||||
const cost = isRate(recorded)
|
||||
? recorded
|
||||
: key.startsWith('advisor_')
|
||||
? undefined
|
||||
: priced[key];
|
||||
if (cost === undefined) continue;
|
||||
sawAnyCost = true;
|
||||
if (isOutputCostKey(key, outputKey)) output += cost;
|
||||
else input += cost;
|
||||
}
|
||||
if (!sawAnyCost) return null;
|
||||
return {
|
||||
inputMicroCents: Math.max(0, Math.round(input)),
|
||||
outputMicroCents: Math.max(0, Math.round(output)),
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user