feat(analytics): U3 — model pricing map + cost derivation
costFor() derives USD from token counts via a hand-maintained provider:model rate map with pricingAsOf + staleness flag; unknown models report unavailable rather than guessing. Cost wired additively into token-analytics per-task so it is correct for any groupBy.
This commit is contained in:
168
packages/core/src/__tests__/model-pricing.test.ts
Normal file
168
packages/core/src/__tests__/model-pricing.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
costFor,
|
||||
lookupPricing,
|
||||
MODEL_PRICING,
|
||||
pricingAsOf,
|
||||
PRICING_STALE_AFTER_MS,
|
||||
} from "../model-pricing.js";
|
||||
|
||||
const ZERO = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
};
|
||||
|
||||
describe("model-pricing", () => {
|
||||
it("exposes a pricingAsOf ISO date and a staleness threshold", () => {
|
||||
expect(pricingAsOf).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(Number.isNaN(Date.parse(pricingAsOf))).toBe(false);
|
||||
expect(PRICING_STALE_AFTER_MS).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("prices a known model + token counts to cent precision", () => {
|
||||
// claude-opus-4-8: input $5/1M, output $25/1M.
|
||||
// 1,000,000 input + 200,000 output = 5.00 + 5.00 = 10.00
|
||||
const result = costFor(
|
||||
{ ...ZERO, inputTokens: 1_000_000, outputTokens: 200_000 },
|
||||
{ provider: "anthropic", model: "claude-opus-4-8" },
|
||||
);
|
||||
expect(result.unavailable).toBe(false);
|
||||
expect(result.usd).not.toBeNull();
|
||||
expect(result.usd).toBeCloseTo(10.0, 2);
|
||||
});
|
||||
|
||||
it("returns unavailable + null usd for an unknown model (never guesses)", () => {
|
||||
const result = costFor(
|
||||
{ ...ZERO, inputTokens: 1_000_000 },
|
||||
{ provider: "acme", model: "totally-made-up-model" },
|
||||
);
|
||||
expect(result.unavailable).toBe(true);
|
||||
expect(result.usd).toBeNull();
|
||||
});
|
||||
|
||||
it("prices cache tokens at the cache rate, not the input rate", () => {
|
||||
// claude-opus-4-8: input $5/1M, cacheRead $0.5/1M, cacheWrite $6.25/1M.
|
||||
const model = { provider: "anthropic", model: "claude-opus-4-8" };
|
||||
|
||||
const cacheRead = costFor(
|
||||
{ ...ZERO, cachedTokens: 1_000_000 },
|
||||
model,
|
||||
);
|
||||
// At cache-read rate ($0.5), NOT the input rate ($5).
|
||||
expect(cacheRead.usd).toBeCloseTo(0.5, 2);
|
||||
expect(cacheRead.usd).not.toBeCloseTo(5.0, 2);
|
||||
|
||||
const cacheWrite = costFor(
|
||||
{ ...ZERO, cacheWriteTokens: 1_000_000 },
|
||||
model,
|
||||
);
|
||||
expect(cacheWrite.usd).toBeCloseTo(6.25, 2);
|
||||
|
||||
// A pure-input baseline confirms input is the more expensive rate.
|
||||
const input = costFor({ ...ZERO, inputTokens: 1_000_000 }, model);
|
||||
expect(input.usd).toBeCloseTo(5.0, 2);
|
||||
});
|
||||
|
||||
it("sums all four token kinds at their respective rates", () => {
|
||||
// 100k input(5) + 100k output(25) + 100k cacheRead(0.5) + 100k cacheWrite(6.25)
|
||||
// = 0.5 + 2.5 + 0.05 + 0.625 = 3.675
|
||||
const result = costFor(
|
||||
{
|
||||
inputTokens: 100_000,
|
||||
outputTokens: 100_000,
|
||||
cachedTokens: 100_000,
|
||||
cacheWriteTokens: 100_000,
|
||||
},
|
||||
{ provider: "anthropic", model: "claude-opus-4-8" },
|
||||
);
|
||||
expect(result.usd).toBeCloseTo(3.675, 3);
|
||||
});
|
||||
|
||||
it("flags stale when now is past the staleness threshold", () => {
|
||||
const asOf = Date.parse(pricingAsOf);
|
||||
const wayLater = asOf + PRICING_STALE_AFTER_MS + 24 * 60 * 60 * 1000;
|
||||
const result = costFor(
|
||||
{ ...ZERO, inputTokens: 1_000_000 },
|
||||
{ provider: "anthropic", model: "claude-opus-4-8" },
|
||||
wayLater,
|
||||
);
|
||||
expect(result.stale).toBe(true);
|
||||
// Cost is still computed for a stale-but-present entry.
|
||||
expect(result.usd).toBeCloseTo(5.0, 2);
|
||||
});
|
||||
|
||||
it("does not flag stale within the threshold or when now is omitted", () => {
|
||||
const asOf = Date.parse(pricingAsOf);
|
||||
const model = { provider: "anthropic", model: "claude-opus-4-8" };
|
||||
const usage = { ...ZERO, inputTokens: 1_000_000 };
|
||||
|
||||
// Just inside the window.
|
||||
const fresh = costFor(usage, model, asOf + PRICING_STALE_AFTER_MS - 1000);
|
||||
expect(fresh.stale).toBe(false);
|
||||
|
||||
// No `now` → never stale (pure: module never reads the clock).
|
||||
const noNow = costFor(usage, model);
|
||||
expect(noNow.stale).toBe(false);
|
||||
});
|
||||
|
||||
it("still reports stale for an unknown model when now is past threshold", () => {
|
||||
const asOf = Date.parse(pricingAsOf);
|
||||
const wayLater = asOf + PRICING_STALE_AFTER_MS + 1000;
|
||||
const result = costFor(
|
||||
{ ...ZERO, inputTokens: 1_000_000 },
|
||||
{ provider: "acme", model: "nope" },
|
||||
wayLater,
|
||||
);
|
||||
expect(result.unavailable).toBe(true);
|
||||
expect(result.usd).toBeNull();
|
||||
expect(result.stale).toBe(true);
|
||||
});
|
||||
|
||||
describe("lookupPricing", () => {
|
||||
it("resolves by provider:model", () => {
|
||||
expect(
|
||||
lookupPricing({ provider: "openai", model: "gpt-4o" }),
|
||||
).toBe(MODEL_PRICING["openai:gpt-4o"]);
|
||||
});
|
||||
|
||||
it("is case-insensitive and trims", () => {
|
||||
expect(
|
||||
lookupPricing({ provider: " OpenAI ", model: " GPT-4o " }),
|
||||
).toBe(MODEL_PRICING["openai:gpt-4o"]);
|
||||
});
|
||||
|
||||
it("falls back to a bare model id when provider is unset", () => {
|
||||
expect(lookupPricing({ model: "gemini-2.5-pro" })).toBe(
|
||||
MODEL_PRICING["google:gemini-2.5-pro"],
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for empty / unknown input", () => {
|
||||
expect(lookupPricing({})).toBeUndefined();
|
||||
expect(lookupPricing({ model: "" })).toBeUndefined();
|
||||
expect(lookupPricing({ provider: "x", model: "y" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds Anthropic, OpenAI, and Google providers", () => {
|
||||
const providers = new Set(
|
||||
Object.keys(MODEL_PRICING).map((k) => k.split(":")[0]),
|
||||
);
|
||||
expect(providers).toContain("anthropic");
|
||||
expect(providers).toContain("openai");
|
||||
expect(providers).toContain("google");
|
||||
});
|
||||
|
||||
it("every entry has all four rates and a source", () => {
|
||||
for (const [key, entry] of Object.entries(MODEL_PRICING)) {
|
||||
expect(typeof entry.inputPer1M, key).toBe("number");
|
||||
expect(typeof entry.outputPer1M, key).toBe("number");
|
||||
expect(typeof entry.cacheReadPer1M, key).toBe("number");
|
||||
expect(typeof entry.cacheWritePer1M, key).toBe("number");
|
||||
expect(entry.source.length, key).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -530,6 +530,19 @@ export type {
|
||||
UsageEventKind,
|
||||
UsageEventRangeQuery,
|
||||
} from "./usage-events.js";
|
||||
export {
|
||||
costFor,
|
||||
lookupPricing,
|
||||
MODEL_PRICING,
|
||||
pricingAsOf,
|
||||
PRICING_STALE_AFTER_MS,
|
||||
} from "./model-pricing.js";
|
||||
export type {
|
||||
ModelPricing,
|
||||
ModelRef,
|
||||
UsageForCost,
|
||||
CostResult,
|
||||
} from "./model-pricing.js";
|
||||
export { aggregateTokenAnalytics } from "./token-analytics.js";
|
||||
export type {
|
||||
TokenAnalytics,
|
||||
|
||||
333
packages/core/src/model-pricing.ts
Normal file
333
packages/core/src/model-pricing.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Model pricing → USD cost derivation (KTD6, U3).
|
||||
*
|
||||
* Cost is **derived at read time** from token counts × a hand-maintained
|
||||
* pricing map; it is never persisted (so historical rows stay correct when
|
||||
* prices change, and no backfill migration is needed). Unknown models surface
|
||||
* tokens with cost marked `unavailable` rather than guessing a price.
|
||||
*
|
||||
* ⚠️ HAND-MAINTAINED MAP. The `MODEL_PRICING` table below is curated by humans
|
||||
* from each provider's public pricing pages — it is NOT fetched at runtime.
|
||||
* When you update a rate, bump {@link pricingAsOf} in the same change. The UI
|
||||
* surfaces `pricingAsOf` ("prices as of <date>") and marks entries older than
|
||||
* {@link PRICING_STALE_AFTER_MS} as low-confidence, so stale-but-present rates
|
||||
* (which the unknown-model guard does not catch) are visible rather than
|
||||
* silently wrong.
|
||||
*
|
||||
* Rates are USD **per 1,000,000 tokens**.
|
||||
*
|
||||
* Pure data module: no DB, no I/O, and no `Date.now()` at import time. Callers
|
||||
* that care about staleness pass an explicit `now`; otherwise staleness is
|
||||
* judged against {@link pricingAsOf} alone (i.e. never stale).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The date the rates in {@link MODEL_PRICING} were last verified, ISO-8601.
|
||||
* Bump this whenever you edit a rate. Surfaced in the UI as "prices as of".
|
||||
*/
|
||||
export const pricingAsOf = "2026-06-15";
|
||||
|
||||
/**
|
||||
* Pricing entries older than this (relative to a caller-supplied `now`) are
|
||||
* flagged `stale: true`. 180 days ≈ two quarters — long enough that routine
|
||||
* price churn doesn't fire constantly, short enough that a long-unmaintained
|
||||
* map is surfaced. Compared against {@link pricingAsOf}, not per-entry dates.
|
||||
*/
|
||||
export const PRICING_STALE_AFTER_MS = 180 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** A single model's per-1M-token rates plus a citation. */
|
||||
export interface ModelPricing {
|
||||
/** USD per 1M uncached input tokens. */
|
||||
inputPer1M: number;
|
||||
/** USD per 1M output tokens. */
|
||||
outputPer1M: number;
|
||||
/** USD per 1M cache-read (cached) input tokens. */
|
||||
cacheReadPer1M: number;
|
||||
/** USD per 1M cache-write tokens. */
|
||||
cacheWritePer1M: number;
|
||||
/** Where the rate came from (provider pricing page / docs). */
|
||||
source: string;
|
||||
}
|
||||
|
||||
/** Token counts to price. Mirrors {@link TokenTotals} from token-analytics. */
|
||||
export interface UsageForCost {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
/** Cache-read tokens (priced at the cache-read rate, NOT the input rate). */
|
||||
cachedTokens: number;
|
||||
/** Cache-write tokens (priced at the cache-write rate). */
|
||||
cacheWriteTokens: number;
|
||||
}
|
||||
|
||||
/** Result of {@link costFor}. */
|
||||
export interface CostResult {
|
||||
/** Derived USD cost, or `null` when no price is known for the model. */
|
||||
usd: number | null;
|
||||
/** True when the model has no pricing entry (cost is a guess-free `null`). */
|
||||
unavailable: boolean;
|
||||
/** True when the pricing map is older than the staleness threshold. */
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-maintained pricing table, keyed by `provider:model`.
|
||||
*
|
||||
* Keys are lowercased `${provider}:${model}`. Lookup also falls back to the
|
||||
* bare model id (`:model`) so callers that only know the model still resolve.
|
||||
* Model ids match the strings Fusion stores in `tasks.modelId` /
|
||||
* `tasks.modelProvider` (see `runtime-provider-probes.ts` and grep for
|
||||
* `modelId`/`modelProvider`): Anthropic Claude, OpenAI, Google Gemini.
|
||||
*
|
||||
* Sources (verified 2026-06-15, see `pricingAsOf`):
|
||||
* - Anthropic: platform.claude.com/docs/en/pricing (per-MTok; cache read ≈
|
||||
* 0.1× input, 5-min cache write ≈ 1.25× input).
|
||||
* - OpenAI: openai.com/api/pricing (cached input ≈ 0.5×/0.25× input; OpenAI
|
||||
* has no separate cache-write charge, so cacheWrite = input rate).
|
||||
* - Google Gemini: ai.google.dev/gemini-api/docs/pricing (context-cache read
|
||||
* rate; no distinct cache-write token charge, so cacheWrite = input rate).
|
||||
*/
|
||||
export const MODEL_PRICING: Readonly<Record<string, ModelPricing>> = {
|
||||
// ── Anthropic Claude ────────────────────────────────────────────────
|
||||
// input / output / cacheRead(0.1×) / cacheWrite(1.25×, 5-min TTL)
|
||||
"anthropic:claude-opus-4-8": {
|
||||
inputPer1M: 5,
|
||||
outputPer1M: 25,
|
||||
cacheReadPer1M: 0.5,
|
||||
cacheWritePer1M: 6.25,
|
||||
source: "platform.claude.com/docs/en/pricing",
|
||||
},
|
||||
"anthropic:claude-opus-4-7": {
|
||||
inputPer1M: 5,
|
||||
outputPer1M: 25,
|
||||
cacheReadPer1M: 0.5,
|
||||
cacheWritePer1M: 6.25,
|
||||
source: "platform.claude.com/docs/en/pricing",
|
||||
},
|
||||
"anthropic:claude-opus-4-6": {
|
||||
inputPer1M: 5,
|
||||
outputPer1M: 25,
|
||||
cacheReadPer1M: 0.5,
|
||||
cacheWritePer1M: 6.25,
|
||||
source: "platform.claude.com/docs/en/pricing",
|
||||
},
|
||||
"anthropic:claude-opus-4-5": {
|
||||
inputPer1M: 5,
|
||||
outputPer1M: 25,
|
||||
cacheReadPer1M: 0.5,
|
||||
cacheWritePer1M: 6.25,
|
||||
source: "platform.claude.com/docs/en/pricing",
|
||||
},
|
||||
"anthropic:claude-opus-4-1": {
|
||||
inputPer1M: 15,
|
||||
outputPer1M: 75,
|
||||
cacheReadPer1M: 1.5,
|
||||
cacheWritePer1M: 18.75,
|
||||
source: "platform.claude.com/docs/en/pricing",
|
||||
},
|
||||
"anthropic:claude-opus-4-20250514": {
|
||||
inputPer1M: 15,
|
||||
outputPer1M: 75,
|
||||
cacheReadPer1M: 1.5,
|
||||
cacheWritePer1M: 18.75,
|
||||
source: "platform.claude.com/docs/en/pricing",
|
||||
},
|
||||
"anthropic:claude-sonnet-4-6": {
|
||||
inputPer1M: 3,
|
||||
outputPer1M: 15,
|
||||
cacheReadPer1M: 0.3,
|
||||
cacheWritePer1M: 3.75,
|
||||
source: "platform.claude.com/docs/en/pricing",
|
||||
},
|
||||
"anthropic:claude-sonnet-4-5": {
|
||||
inputPer1M: 3,
|
||||
outputPer1M: 15,
|
||||
cacheReadPer1M: 0.3,
|
||||
cacheWritePer1M: 3.75,
|
||||
source: "platform.claude.com/docs/en/pricing",
|
||||
},
|
||||
"anthropic:claude-sonnet-4-20250514": {
|
||||
inputPer1M: 3,
|
||||
outputPer1M: 15,
|
||||
cacheReadPer1M: 0.3,
|
||||
cacheWritePer1M: 3.75,
|
||||
source: "platform.claude.com/docs/en/pricing",
|
||||
},
|
||||
"anthropic:claude-haiku-4-5": {
|
||||
inputPer1M: 1,
|
||||
outputPer1M: 5,
|
||||
cacheReadPer1M: 0.1,
|
||||
cacheWritePer1M: 1.25,
|
||||
source: "platform.claude.com/docs/en/pricing",
|
||||
},
|
||||
"anthropic:claude-haiku-4-5-20251001": {
|
||||
inputPer1M: 1,
|
||||
outputPer1M: 5,
|
||||
cacheReadPer1M: 0.1,
|
||||
cacheWritePer1M: 1.25,
|
||||
source: "platform.claude.com/docs/en/pricing",
|
||||
},
|
||||
"anthropic:claude-fable-5": {
|
||||
inputPer1M: 10,
|
||||
outputPer1M: 50,
|
||||
cacheReadPer1M: 1,
|
||||
cacheWritePer1M: 12.5,
|
||||
source: "platform.claude.com/docs/en/pricing",
|
||||
},
|
||||
|
||||
// ── OpenAI ──────────────────────────────────────────────────────────
|
||||
// OpenAI has no separate cache-write charge → cacheWrite = input rate.
|
||||
"openai:gpt-5": {
|
||||
inputPer1M: 1.25,
|
||||
outputPer1M: 10,
|
||||
cacheReadPer1M: 0.125,
|
||||
cacheWritePer1M: 1.25,
|
||||
source: "openai.com/api/pricing",
|
||||
},
|
||||
"openai:gpt-5-mini": {
|
||||
inputPer1M: 0.25,
|
||||
outputPer1M: 2,
|
||||
cacheReadPer1M: 0.025,
|
||||
cacheWritePer1M: 0.25,
|
||||
source: "openai.com/api/pricing",
|
||||
},
|
||||
"openai:gpt-4o": {
|
||||
inputPer1M: 2.5,
|
||||
outputPer1M: 10,
|
||||
cacheReadPer1M: 1.25,
|
||||
cacheWritePer1M: 2.5,
|
||||
source: "openai.com/api/pricing",
|
||||
},
|
||||
"openai:gpt-4o-mini": {
|
||||
inputPer1M: 0.15,
|
||||
outputPer1M: 0.6,
|
||||
cacheReadPer1M: 0.075,
|
||||
cacheWritePer1M: 0.15,
|
||||
source: "openai.com/api/pricing",
|
||||
},
|
||||
"openai:gpt-4.1": {
|
||||
inputPer1M: 2,
|
||||
outputPer1M: 8,
|
||||
cacheReadPer1M: 0.5,
|
||||
cacheWritePer1M: 2,
|
||||
source: "openai.com/api/pricing",
|
||||
},
|
||||
"openai:gpt-4-turbo": {
|
||||
inputPer1M: 10,
|
||||
outputPer1M: 30,
|
||||
cacheReadPer1M: 10,
|
||||
cacheWritePer1M: 10,
|
||||
source: "openai.com/api/pricing",
|
||||
},
|
||||
"openai:o1": {
|
||||
inputPer1M: 15,
|
||||
outputPer1M: 60,
|
||||
cacheReadPer1M: 7.5,
|
||||
cacheWritePer1M: 15,
|
||||
source: "openai.com/api/pricing",
|
||||
},
|
||||
"openai:o3-mini": {
|
||||
inputPer1M: 1.1,
|
||||
outputPer1M: 4.4,
|
||||
cacheReadPer1M: 0.55,
|
||||
cacheWritePer1M: 1.1,
|
||||
source: "openai.com/api/pricing",
|
||||
},
|
||||
|
||||
// ── Google Gemini ───────────────────────────────────────────────────
|
||||
// No distinct cache-write token charge → cacheWrite = input rate.
|
||||
"google:gemini-2.5-pro": {
|
||||
inputPer1M: 1.25,
|
||||
outputPer1M: 10,
|
||||
cacheReadPer1M: 0.31,
|
||||
cacheWritePer1M: 1.25,
|
||||
source: "ai.google.dev/gemini-api/docs/pricing",
|
||||
},
|
||||
"google:gemini-2.5-flash": {
|
||||
inputPer1M: 0.3,
|
||||
outputPer1M: 2.5,
|
||||
cacheReadPer1M: 0.075,
|
||||
cacheWritePer1M: 0.3,
|
||||
source: "ai.google.dev/gemini-api/docs/pricing",
|
||||
},
|
||||
"google:gemini-2.0-flash": {
|
||||
inputPer1M: 0.1,
|
||||
outputPer1M: 0.4,
|
||||
cacheReadPer1M: 0.025,
|
||||
cacheWritePer1M: 0.1,
|
||||
source: "ai.google.dev/gemini-api/docs/pricing",
|
||||
},
|
||||
"google:gemini-2.0-pro": {
|
||||
inputPer1M: 1.25,
|
||||
outputPer1M: 10,
|
||||
cacheReadPer1M: 0.31,
|
||||
cacheWritePer1M: 1.25,
|
||||
source: "ai.google.dev/gemini-api/docs/pricing",
|
||||
},
|
||||
};
|
||||
|
||||
/** Reference to a model, by provider + id (either may be unset). */
|
||||
export interface ModelRef {
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
function normalize(s: string | null | undefined): string {
|
||||
return (s ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pricing entry for a model. Tries `provider:model` first, then the
|
||||
* bare `:model` (provider-agnostic) fallback. Returns `undefined` for unknown
|
||||
* models — callers must treat that as `unavailable`, never as a guessed price.
|
||||
*/
|
||||
export function lookupPricing(ref: ModelRef): ModelPricing | undefined {
|
||||
const provider = normalize(ref.provider);
|
||||
const model = normalize(ref.model);
|
||||
if (!model) return undefined;
|
||||
if (provider) {
|
||||
const exact = MODEL_PRICING[`${provider}:${model}`];
|
||||
if (exact) return exact;
|
||||
}
|
||||
// Provider-agnostic fallback: scan for any entry whose model id matches.
|
||||
for (const [key, entry] of Object.entries(MODEL_PRICING)) {
|
||||
if (key.endsWith(`:${model}`)) return entry;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** True when the pricing map is older than the threshold relative to `now`. */
|
||||
function isStale(now: number | undefined): boolean {
|
||||
if (now === undefined) return false;
|
||||
const asOf = Date.parse(pricingAsOf);
|
||||
if (Number.isNaN(asOf)) return false;
|
||||
return now - asOf > PRICING_STALE_AFTER_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive USD cost for `usage` under `model`'s rates.
|
||||
*
|
||||
* - Unknown model → `{ usd: null, unavailable: true, stale }` (never guessed).
|
||||
* - Cache-read tokens are priced at the cache-read rate, cache-write tokens at
|
||||
* the cache-write rate — NOT the input rate.
|
||||
* - `stale` is true when the (caller-supplied) `now` is more than
|
||||
* {@link PRICING_STALE_AFTER_MS} past {@link pricingAsOf}. With no `now`,
|
||||
* `stale` is always false.
|
||||
*/
|
||||
export function costFor(
|
||||
usage: UsageForCost,
|
||||
model: ModelRef,
|
||||
now?: number,
|
||||
): CostResult {
|
||||
const stale = isStale(now);
|
||||
const pricing = lookupPricing(model);
|
||||
if (!pricing) {
|
||||
return { usd: null, unavailable: true, stale };
|
||||
}
|
||||
const usd =
|
||||
(usage.inputTokens * pricing.inputPer1M +
|
||||
usage.outputTokens * pricing.outputPer1M +
|
||||
usage.cachedTokens * pricing.cacheReadPer1M +
|
||||
usage.cacheWriteTokens * pricing.cacheWritePer1M) /
|
||||
1_000_000;
|
||||
return { usd, unavailable: false, stale };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Database } from "./db.js";
|
||||
import { costFor, type CostResult } from "./model-pricing.js";
|
||||
|
||||
/**
|
||||
* Token-consumption analytics over the `tasks` table, generalizing the fixed
|
||||
@@ -31,6 +32,13 @@ export interface TokenTotals {
|
||||
export interface TokenGroupSummary extends TokenTotals {
|
||||
/** The group key (model id, provider, nodeId, or agentId); null when unset. */
|
||||
key: string | null;
|
||||
/**
|
||||
* Derived USD cost for this group (U3). Each contributing task is priced at
|
||||
* its own model's rates and summed, so the cost is meaningful for any
|
||||
* `groupBy`. `usd` is null when none of the group's tasks had a known price;
|
||||
* `unavailable` is true when at least one task's model was unpriced.
|
||||
*/
|
||||
cost: CostResult;
|
||||
}
|
||||
|
||||
/** Result of {@link aggregateTokenAnalytics}. */
|
||||
@@ -40,6 +48,12 @@ export interface TokenAnalytics {
|
||||
groupBy: TokenGroupBy | null;
|
||||
/** Grand total across all matched tasks. */
|
||||
totals: TokenTotals;
|
||||
/**
|
||||
* Derived USD cost across all matched tasks (U3), each priced at its own
|
||||
* model's rates. `usd` is null when no task had a known price; `unavailable`
|
||||
* is true when at least one task's model had no pricing entry.
|
||||
*/
|
||||
cost: CostResult;
|
||||
/** Per-group totals; empty array when no `groupBy` requested. */
|
||||
groups: TokenGroupSummary[];
|
||||
}
|
||||
@@ -50,6 +64,11 @@ export interface TokenAnalyticsQuery {
|
||||
/** ISO-8601 upper bound (inclusive) on `tokenUsageLastUsedAt`. */
|
||||
to?: string;
|
||||
groupBy?: TokenGroupBy;
|
||||
/**
|
||||
* Epoch ms "now" used only for pricing-staleness (U3). When omitted, derived
|
||||
* cost is never marked stale. Pure: the module never reads the clock itself.
|
||||
*/
|
||||
now?: number;
|
||||
}
|
||||
|
||||
function emptyTotals(): TokenTotals {
|
||||
@@ -88,6 +107,52 @@ function groupKeyFor(row: TaskTokenRow, groupBy: TokenGroupBy): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Running cost tally. Each task is priced at its own model, then summed: `usd`
|
||||
* accumulates priced tasks, `anyUnavailable` records whether any task's model
|
||||
* was unpriced, `anyStale` whether the pricing map was stale, and `anyPriced`
|
||||
* whether at least one task had a known price. {@link finalizeCost} converts
|
||||
* this to a {@link CostResult}.
|
||||
*/
|
||||
interface CostAccumulator {
|
||||
usd: number;
|
||||
anyPriced: boolean;
|
||||
anyUnavailable: boolean;
|
||||
anyStale: boolean;
|
||||
}
|
||||
|
||||
function emptyCostAccumulator(): CostAccumulator {
|
||||
return { usd: 0, anyPriced: false, anyUnavailable: false, anyStale: false };
|
||||
}
|
||||
|
||||
function addRowCost(acc: CostAccumulator, row: TaskTokenRow, now?: number): void {
|
||||
const result = costFor(
|
||||
{
|
||||
inputTokens: row.inputTokens ?? 0,
|
||||
outputTokens: row.outputTokens ?? 0,
|
||||
cachedTokens: row.cachedTokens ?? 0,
|
||||
cacheWriteTokens: row.cacheWriteTokens ?? 0,
|
||||
},
|
||||
{ provider: row.modelProvider, model: row.modelId },
|
||||
now,
|
||||
);
|
||||
if (result.stale) acc.anyStale = true;
|
||||
if (result.unavailable || result.usd === null) {
|
||||
acc.anyUnavailable = true;
|
||||
} else {
|
||||
acc.usd += result.usd;
|
||||
acc.anyPriced = true;
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeCost(acc: CostAccumulator): CostResult {
|
||||
return {
|
||||
usd: acc.anyPriced ? acc.usd : null,
|
||||
unavailable: acc.anyUnavailable,
|
||||
stale: acc.anyStale,
|
||||
};
|
||||
}
|
||||
|
||||
function addRow(totals: TokenTotals, row: TaskTokenRow): void {
|
||||
totals.inputTokens += row.inputTokens ?? 0;
|
||||
totals.outputTokens += row.outputTokens ?? 0;
|
||||
@@ -145,22 +210,33 @@ export function aggregateTokenAnalytics(
|
||||
.all(...params) as TaskTokenRow[];
|
||||
|
||||
const totals = emptyTotals();
|
||||
const totalCost = emptyCostAccumulator();
|
||||
const groupMap = new Map<string | null, TokenGroupSummary>();
|
||||
const groupCostMap = new Map<string | null, CostAccumulator>();
|
||||
const groupBy = query.groupBy;
|
||||
const now = query.now;
|
||||
|
||||
for (const row of rows) {
|
||||
addRow(totals, row);
|
||||
addRowCost(totalCost, row, now);
|
||||
if (groupBy) {
|
||||
const key = groupKeyFor(row, groupBy);
|
||||
let group = groupMap.get(key);
|
||||
if (!group) {
|
||||
group = { key, ...emptyTotals() };
|
||||
group = { key, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } };
|
||||
groupMap.set(key, group);
|
||||
groupCostMap.set(key, emptyCostAccumulator());
|
||||
}
|
||||
addRow(group, row);
|
||||
addRowCost(groupCostMap.get(key)!, row, now);
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize per-group cost from each group's accumulator.
|
||||
for (const [key, group] of groupMap) {
|
||||
group.cost = finalizeCost(groupCostMap.get(key)!);
|
||||
}
|
||||
|
||||
const groups = [...groupMap.values()].sort(
|
||||
(a, b) => b.totalTokens - a.totalTokens,
|
||||
);
|
||||
@@ -170,6 +246,7 @@ export function aggregateTokenAnalytics(
|
||||
to: query.to ?? null,
|
||||
groupBy: groupBy ?? null,
|
||||
totals,
|
||||
cost: finalizeCost(totalCost),
|
||||
groups,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user