diff --git a/packages/core/src/__tests__/model-pricing.test.ts b/packages/core/src/__tests__/model-pricing.test.ts new file mode 100644 index 0000000000..ea116b31fe --- /dev/null +++ b/packages/core/src/__tests__/model-pricing.test.ts @@ -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); + } + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e9698c2c33..cd14f04927 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/model-pricing.ts b/packages/core/src/model-pricing.ts new file mode 100644 index 0000000000..6f81eeafa7 --- /dev/null +++ b/packages/core/src/model-pricing.ts @@ -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 ") 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> = { + // ── 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 }; +} diff --git a/packages/core/src/token-analytics.ts b/packages/core/src/token-analytics.ts index 695cf5eb17..07303a9c9e 100644 --- a/packages/core/src/token-analytics.ts +++ b/packages/core/src/token-analytics.ts @@ -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(); + const groupCostMap = new Map(); 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, }; }