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:
gsxdsm
2026-06-15 19:31:54 -07:00
parent 53bb1d8f37
commit 4519732b20
4 changed files with 592 additions and 1 deletions

View 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);
}
});
});

View File

@@ -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,

View 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 };
}

View File

@@ -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,
};
}