From 969ed1a54076a76bdf28f8fa845647e5f783371e Mon Sep 17 00:00:00 2001 From: Frad LEE Date: Fri, 16 Oct 2026 09:19:50 +0800 Subject: [PATCH 1/2] fix(cost): use models.dev reference prices only --- apps/api/bin/refresh-models-dev.ts | 62 + .../application/cost-usage-event.service.ts | 1 - .../api/src/modules/cost/data/models-dev.json | 2126 +++++++++++++++++ .../src/modules/cost/domain/cost-pricing.ts | 601 ++--- .../modules/cost/domain/models-dev-pricing.ts | 32 + apps/api/tests/cost-pricing.test.ts | 502 ++-- apps/api/tests/cost-usage-event.test.ts | 41 +- .../tests/session-model-call-identity.test.ts | 2 +- docs/prd/cost-dashboard.md | 1 + justfile | 4 + 10 files changed, 2741 insertions(+), 631 deletions(-) create mode 100644 apps/api/bin/refresh-models-dev.ts create mode 100644 apps/api/src/modules/cost/data/models-dev.json create mode 100644 apps/api/src/modules/cost/domain/models-dev-pricing.ts diff --git a/apps/api/bin/refresh-models-dev.ts b/apps/api/bin/refresh-models-dev.ts new file mode 100644 index 00000000..72b755dd --- /dev/null +++ b/apps/api/bin/refresh-models-dev.ts @@ -0,0 +1,62 @@ +import { writeFile } from "node:fs/promises"; + +import { + MODELS_DEV_API_URL, + MODELS_DEV_PROVIDER_IDS, +} from "../src/modules/cost/domain/models-dev-pricing"; +import type { + ModelsDevCost, + ModelsDevPrices, + ModelsDevRates, +} from "../src/modules/cost/domain/models-dev-pricing"; + +interface ModelsDevProvider { + readonly models: Readonly>; +} + +function selectRates(cost: ModelsDevRates): ModelsDevRates { + const { input, output, cache_read, cache_write } = cost; + return { + ...(input === undefined ? {} : { input }), + ...(output === undefined ? {} : { output }), + ...(cache_read === undefined ? {} : { cache_read }), + ...(cache_write === undefined ? {} : { cache_write }), + }; +} + +export function selectModelsDevPrices( + providers: Readonly>, +): ModelsDevPrices { + return Object.fromEntries( + Object.entries(MODELS_DEV_PROVIDER_IDS).map(([providerId, upstreamId]) => { + const provider = providers[upstreamId]; + if (provider === undefined) throw new Error(`models.dev provider is missing: ${upstreamId}`); + const models = Object.entries(provider.models).flatMap(([modelId, { cost }]) => { + if (cost === undefined) return []; + const tiers = cost.tiers + ?.filter(({ tier }) => tier.type === "context") + .map((tier) => Object.assign(selectRates(tier), { tier: tier.tier })); + return [ + [ + modelId, + { + ...selectRates(cost), + ...(tiers === undefined || tiers.length === 0 ? {} : { tiers }), + }, + ], + ]; + }); + return [providerId, Object.fromEntries(models)]; + }), + ); +} + +if (import.meta.main) { + const response = await fetch(MODELS_DEV_API_URL); + if (!response.ok) throw new Error(`models.dev returned HTTP ${response.status}`); + const providers = (await response.json()) as Readonly>; + const prices = selectModelsDevPrices(providers); + const target = new URL("../src/modules/cost/data/models-dev.json", import.meta.url); + await writeFile(target, `${JSON.stringify(prices, null, 2)}\n`); + console.log(`Refreshed prices for ${Object.keys(prices).length} providers. Review the git diff.`); +} diff --git a/apps/api/src/modules/cost/application/cost-usage-event.service.ts b/apps/api/src/modules/cost/application/cost-usage-event.service.ts index 32603508..06c22ebd 100644 --- a/apps/api/src/modules/cost/application/cost-usage-event.service.ts +++ b/apps/api/src/modules/cost/application/cost-usage-event.service.ts @@ -129,7 +129,6 @@ function createRuntimeUsageEventInsert(database: AppDatabase, input: RecordRunti inputTokens: tokens.inputTokens, model, outputTokens: tokens.outputTokens, - pricedAtMs: input.run.createdAtMs, providedCostUsd: toProvidedUsdCost(input.usage), provider, }); diff --git a/apps/api/src/modules/cost/data/models-dev.json b/apps/api/src/modules/cost/data/models-dev.json new file mode 100644 index 00000000..961aed38 --- /dev/null +++ b/apps/api/src/modules/cost/data/models-dev.json @@ -0,0 +1,2126 @@ +{ + "anthropic": { + "claude-haiku-4-5": { + "input": 1, + "output": 5, + "cache_read": 0.1, + "cache_write": 1.25 + }, + "claude-opus-4-5": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "claude-sonnet-4-5": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75 + }, + "claude-opus-5-5": { + "input": 4, + "output": 20, + "cache_read": 0.2, + "cache_write": 5 + }, + "claude-fable-5-1": { + "input": 10, + "output": 50, + "cache_read": 0.25, + "cache_write": 12.5 + }, + "claude-opus-4-5-20251101": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "claude-opus-5": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "claude-fable-5": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5 + }, + "claude-opus-4-8": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "claude-sonnet-4-5-20250929": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75 + }, + "claude-sonnet-5": { + "input": 2, + "output": 10, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "claude-opus-4-6": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "claude-haiku-4-5-20251001": { + "input": 1, + "output": 5, + "cache_read": 0.1, + "cache_write": 1.25 + }, + "claude-haiku-5-5": { + "input": 0.1, + "output": 0.5, + "cache_read": 0.01, + "cache_write": 0.125, + "tiers": [ + { + "input": 0.5, + "output": 2.5, + "cache_read": 0.05, + "cache_write": 0.625, + "tier": { + "type": "context", + "size": 100000 + } + } + ] + }, + "claude-sonnet-4-6": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75 + }, + "claude-sonnet-5-5": { + "input": 2, + "output": 10, + "cache_read": 0.1, + "cache_write": 2.5 + }, + "claude-opus-4-7": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 + } + }, + "deepseek": { + "deepseek-v4-flash-vision-exp": { + "input": 0.15, + "output": 0.6, + "cache_read": 0.003 + }, + "deepseek-flash": { + "input": 0.15, + "output": 0.6, + "cache_read": 0.003 + }, + "deepseek-v4-pro": { + "input": 0.66, + "output": 1.98, + "cache_read": 0.022 + }, + "deepseek-v4-flash": { + "input": 0.15, + "output": 0.6, + "cache_read": 0.003 + } + }, + "gemini": { + "gemini-flash-latest": { + "input": 0.75, + "output": 3.75, + "cache_read": 0.075 + }, + "gemini-omni-flash-preview": { + "input": 1.5, + "output": 17.5 + }, + "gemini-3.1-flash-image-preview": { + "input": 0.5, + "output": 60 + }, + "lyria-3-pro-preview": { + "input": 0, + "output": 0 + }, + "gemini-3.1-flash-tts-preview": { + "input": 1, + "output": 20 + }, + "gemini-2.5-flash-image": { + "input": 0.3, + "output": 30, + "cache_read": 0.075 + }, + "gemini-flash-lite-latest": { + "input": 0.3, + "output": 2.5, + "cache_read": 0.03 + }, + "gemini-embedding-2": { + "input": 0.2, + "output": 0 + }, + "gemini-3.6-flash": { + "input": 0.75, + "output": 3.75, + "cache_read": 0.075 + }, + "gemini-3-pro-image-preview": { + "input": 2, + "output": 120 + }, + "gemini-2.5-pro-preview-tts": { + "input": 1, + "output": 20 + }, + "gemini-3.5-flash-lite": { + "input": 0.3, + "output": 2.5, + "cache_read": 0.03 + }, + "gemini-3.1-flash-image": { + "input": 0.5, + "output": 60 + }, + "lyria-3-clip-preview": { + "input": 0, + "output": 0 + }, + "gemini-3.1-pro-preview": { + "input": 2, + "output": 12, + "cache_read": 0.2, + "tiers": [ + { + "input": 4, + "output": 18, + "cache_read": 0.4, + "tier": { + "type": "context", + "size": 200000 + } + } + ] + }, + "gemini-2.5-computer-use-preview-10-2025": { + "input": 1.25, + "output": 10, + "tiers": [ + { + "input": 2.5, + "output": 15, + "tier": { + "type": "context", + "size": 200000 + } + } + ] + }, + "gemini-3.5-flash": { + "input": 1.5, + "output": 9, + "cache_read": 0.15 + }, + "gemini-2.5-pro": { + "input": 1.25, + "output": 10, + "cache_read": 0.125, + "tiers": [ + { + "input": 2.5, + "output": 15, + "cache_read": 0.25, + "tier": { + "type": "context", + "size": 200000 + } + } + ] + }, + "gemini-3-pro-image": { + "input": 2, + "output": 120 + }, + "gemini-2.5-flash": { + "input": 0.3, + "output": 2.5, + "cache_read": 0.03 + }, + "gemini-2.5-flash-preview-tts": { + "input": 0.5, + "output": 10 + }, + "gemini-3.1-flash-lite-image": { + "input": 0.25, + "output": 30 + }, + "deep-research-preview-04-2026": { + "input": 2, + "output": 12, + "cache_read": 0.2, + "tiers": [ + { + "input": 4, + "output": 18, + "cache_read": 0.4, + "tier": { + "type": "context", + "size": 200000 + } + } + ] + }, + "deep-research-max-preview-04-2026": { + "input": 2, + "output": 12, + "cache_read": 0.2, + "tiers": [ + { + "input": 4, + "output": 18, + "cache_read": 0.4, + "tier": { + "type": "context", + "size": 200000 + } + } + ] + }, + "gemini-3.7-flash": { + "input": 0.75, + "output": 3.75, + "cache_read": 0.075 + }, + "gemini-3.1-pro-preview-customtools": { + "input": 2, + "output": 12, + "cache_read": 0.2, + "tiers": [ + { + "input": 4, + "output": 18, + "cache_read": 0.4, + "tier": { + "type": "context", + "size": 200000 + } + } + ] + }, + "gemini-3-flash-preview": { + "input": 0.5, + "output": 3, + "cache_read": 0.05 + }, + "gemini-3.8-flash": { + "input": 0.75, + "output": 3.75, + "cache_read": 0.075 + }, + "gemini-3.5-live-translate-preview": { + "input": 3.5, + "output": 21 + }, + "gemini-2.5-flash-lite": { + "input": 0.1, + "output": 0.4, + "cache_read": 0.01 + }, + "gemini-embedding-001": { + "input": 0.15, + "output": 0 + }, + "gemini-3.1-flash-lite-preview": { + "input": 0.25, + "output": 1.5, + "cache_read": 0.025 + }, + "gemini-3.1-flash-live-preview": { + "input": 0.75, + "output": 4.5 + }, + "gemini-4-argon": { + "input": 2, + "output": 10, + "cache_read": 0.1 + }, + "gemini-3.1-flash-lite": { + "input": 0.25, + "output": 1.5, + "cache_read": 0.025 + } + }, + "kimi": { + "kimi-k3": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3 + }, + "kimi-k2.7-code-highspeed": { + "input": 1.9, + "output": 8, + "cache_read": 0.38 + }, + "kimi-k2.6": { + "input": 0.95, + "output": 4, + "cache_read": 0.16 + }, + "kimi-k2.7-code": { + "input": 0.95, + "output": 4, + "cache_read": 0.19 + } + }, + "minimax": { + "MiniMax-M3": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06, + "tiers": [ + { + "input": 0.6, + "output": 2.4, + "cache_read": 0.12, + "tier": { + "type": "context", + "size": 512000 + } + } + ] + }, + "MiniMax-M2.1": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.03, + "cache_write": 0.375 + }, + "MiniMax-M2.5": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.03, + "cache_write": 0.375 + }, + "MiniMax-M2.7": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06, + "cache_write": 0.375 + }, + "MiniMax-M2.7-highspeed": { + "input": 0.6, + "output": 2.4, + "cache_read": 0.06, + "cache_write": 0.375 + }, + "MiniMax-M2": { + "input": 0.3, + "output": 1.2 + }, + "MiniMax-M2.5-highspeed": { + "input": 0.6, + "output": 2.4, + "cache_read": 0.03, + "cache_write": 0.375 + } + }, + "openai": { + "gpt-5.4": { + "input": 2.5, + "output": 15, + "cache_read": 0.25, + "tiers": [ + { + "input": 5, + "output": 22.5, + "cache_read": 0.5, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "gpt-5.4-pro": { + "input": 30, + "output": 180, + "tiers": [ + { + "input": 60, + "output": 270, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "gpt-3.5-turbo": { + "input": 0.5, + "output": 1.5, + "cache_read": 0 + }, + "gpt-5.5-pro": { + "input": 30, + "output": 180, + "tiers": [ + { + "input": 60, + "output": 270, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "text-embedding-3-small": { + "input": 0.02, + "output": 0 + }, + "gpt-5.4-nano": { + "input": 0.2, + "output": 1.25, + "cache_read": 0.02 + }, + "gpt-realtime-2.1": { + "input": 4, + "output": 24, + "cache_read": 0.4 + }, + "gpt-4o-2024-05-13": { + "input": 5, + "output": 15 + }, + "gpt-4o": { + "input": 2.5, + "output": 10, + "cache_read": 1.25 + }, + "gpt-5-mini": { + "input": 0.25, + "output": 2, + "cache_read": 0.025 + }, + "gpt-image-2": { + "input": 5, + "output": 30, + "cache_read": 1.25 + }, + "gpt-5.2-pro": { + "input": 21, + "output": 168 + }, + "o4-mini": { + "input": 1.1, + "output": 4.4, + "cache_read": 0.275 + }, + "o3-mini": { + "input": 1.1, + "output": 4.4, + "cache_read": 0.55 + }, + "text-embedding-ada-002": { + "input": 0.1, + "output": 0 + }, + "gpt-4": { + "input": 30, + "output": 60 + }, + "gpt-5.3-codex": { + "input": 1.75, + "output": 14, + "cache_read": 0.175 + }, + "gpt-4.1-nano": { + "input": 0.1, + "output": 0.4, + "cache_read": 0.025 + }, + "gpt-5-nano": { + "input": 0.05, + "output": 0.4, + "cache_read": 0.005 + }, + "gpt-5.6": { + "input": 4, + "output": 20, + "cache_read": 0.4, + "cache_write": 5, + "tiers": [ + { + "input": 8, + "output": 30, + "cache_read": 0.8, + "cache_write": 10, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "gpt-5.2-chat-latest": { + "input": 1.75, + "output": 14, + "cache_read": 0.175 + }, + "o1": { + "input": 15, + "output": 60, + "cache_read": 7.5 + }, + "gpt-5-pro": { + "input": 15, + "output": 120 + }, + "gpt-5.3-codex-spark": { + "input": 1.75, + "output": 14, + "cache_read": 0.175 + }, + "gpt-6.1-sol": { + "input": 2, + "output": 10, + "cache_read": 0.1, + "cache_write": 2.5, + "tiers": [ + { + "input": 4, + "output": 15, + "cache_read": 0.2, + "cache_write": 5, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "text-embedding-3-large": { + "input": 0.13, + "output": 0 + }, + "gpt-4o-2024-08-06": { + "input": 2.5, + "output": 10, + "cache_read": 1.25 + }, + "gpt-6-astra": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5, + "tiers": [ + { + "input": 20, + "output": 75, + "cache_read": 2, + "cache_write": 25, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "gpt-5.1": { + "input": 1.25, + "output": 10, + "cache_read": 0.125 + }, + "gpt-4o-mini": { + "input": 0.15, + "output": 0.6, + "cache_read": 0.075 + }, + "o3-pro": { + "input": 20, + "output": 80 + }, + "gpt-daybreak-blue-latest": { + "input": 4, + "output": 20, + "cache_read": 0.4, + "cache_write": 5, + "tiers": [ + { + "input": 8, + "output": 30, + "cache_read": 0.8, + "cache_write": 10, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "gpt-5.4-mini": { + "input": 0.75, + "output": 4.5, + "cache_read": 0.075 + }, + "gpt-5.6-luna": { + "input": 0.2, + "output": 1.2, + "cache_read": 0.02, + "cache_write": 0.25, + "tiers": [ + { + "input": 0.4, + "output": 1.8, + "cache_read": 0.04, + "cache_write": 0.5, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "gpt-5.2": { + "input": 1.75, + "output": 14, + "cache_read": 0.175 + }, + "gpt-5.5": { + "input": 5, + "output": 30, + "cache_read": 0.5, + "tiers": [ + { + "input": 10, + "output": 45, + "cache_read": 1, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "gpt-4.1": { + "input": 2, + "output": 8, + "cache_read": 0.5 + }, + "gpt-4o-2024-11-20": { + "input": 2.5, + "output": 10, + "cache_read": 1.25 + }, + "gpt-4.1-mini": { + "input": 0.4, + "output": 1.6, + "cache_read": 0.1 + }, + "gpt-6-luna": { + "input": 0.1, + "output": 0.5, + "cache_read": 0.01, + "cache_write": 0.125, + "tiers": [ + { + "input": 0.2, + "output": 0.75, + "cache_read": 0.02, + "cache_write": 0.25, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "gpt-5.3-chat-latest": { + "input": 1.75, + "output": 14, + "cache_read": 0.175 + }, + "gpt-5.6-terra": { + "input": 2, + "output": 12, + "cache_read": 0.2, + "cache_write": 2.5, + "tiers": [ + { + "input": 4, + "output": 18, + "cache_read": 0.4, + "cache_write": 5, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "gpt-4-turbo": { + "input": 10, + "output": 30 + }, + "gpt-daybreak-red-latest": { + "input": 12.5, + "output": 75, + "cache_read": 1.25, + "cache_write": 15.625 + }, + "o3": { + "input": 2, + "output": 8, + "cache_read": 0.5 + }, + "gpt-5": { + "input": 1.25, + "output": 10, + "cache_read": 0.125 + }, + "gpt-5.6-sol": { + "input": 4, + "output": 20, + "cache_read": 0.4, + "cache_write": 5, + "tiers": [ + { + "input": 8, + "output": 30, + "cache_read": 0.8, + "cache_write": 10, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "gpt-6-sol": { + "input": 2, + "output": 10, + "cache_read": 0.2, + "cache_write": 2.5, + "tiers": [ + { + "input": 4, + "output": 15, + "cache_read": 0.4, + "cache_write": 5, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "o1-pro": { + "input": 150, + "output": 600 + } + }, + "opencode": { + "ling-3.0-flash-fin-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "gpt-5.4": { + "input": 2.5, + "output": 15, + "cache_read": 0.25, + "tiers": [ + { + "input": 5, + "output": 22.5, + "cache_read": 0.5, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "fledge-alpha-free": { + "input": 0, + "output": 0 + }, + "qwen3.6-plus-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "claude-haiku-4-5": { + "input": 1, + "output": 5, + "cache_read": 0.1, + "cache_write": 1.25 + }, + "gpt-5.4-pro": { + "input": 30, + "output": 180, + "cache_read": 30 + }, + "mimo-v2-pro-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "muse-spark-1.2-contributor-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "ling-3.1-flash-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "muse-spark-1.3": { + "input": 1.25, + "output": 4.25, + "cache_read": 0.15 + }, + "glm-5-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "trinity-large-preview-free": { + "input": 0, + "output": 0 + }, + "gpt-5.5-pro": { + "input": 30, + "output": 180, + "cache_read": 30 + }, + "grok-4.7": { + "input": 2, + "output": 6, + "cache_read": 0.5, + "tiers": [ + { + "input": 4, + "output": 12, + "cache_read": 1, + "tier": { + "type": "context", + "size": 200000 + } + } + ] + }, + "longcat-2.5-preview-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "gpt-5.4-nano": { + "input": 0.2, + "output": 1.25, + "cache_read": 0.02 + }, + "gpt-5.2-codex": { + "input": 1.75, + "output": 14, + "cache_read": 0.175 + }, + "ling-3.0-tiny-free": { + "input": 0, + "output": 0 + }, + "gpt-5.1-codex": { + "input": 1.07, + "output": 8.5, + "cache_read": 0.107 + }, + "glm-5.3-flash": { + "input": 0.15, + "output": 0.5, + "cache_read": 0.03 + }, + "kimi-k2.5-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "glm-4.7-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "glm-4.6": { + "input": 0.6, + "output": 2.2, + "cache_read": 0.1 + }, + "laguna-s-2.1-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "qwen3.8-max": { + "input": 2, + "output": 6, + "cache_read": 0.25, + "cache_write": 2.5 + }, + "kimi-k3": { + "input": 3, + "output": 15, + "cache_read": 0.3 + }, + "gpt-5-codex": { + "input": 1.07, + "output": 8.5, + "cache_read": 0.107 + }, + "qwen3-coder": { + "input": 0.45, + "output": 1.8 + }, + "mistral-large-4": { + "input": 0.68, + "output": 2.09, + "cache_read": 0.07 + }, + "minimax-m3-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "qwen3.5-plus": { + "input": 0.2, + "output": 1.2, + "cache_read": 0.02, + "cache_write": 0.25 + }, + "claude-opus-4-5": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "glm-5": { + "input": 1, + "output": 3.2, + "cache_read": 0.2 + }, + "deepseek-v4.1-flash": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.006 + }, + "gpt-5.3-codex": { + "input": 1.75, + "output": 14, + "cache_read": 0.175 + }, + "minimax-m2.5": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, + "exo-free": { + "input": 0, + "output": 0 + }, + "gpt-5-nano": { + "input": 0.05, + "output": 0.4, + "cache_read": 0.005 + }, + "deepseek-v4-flash-vision-exp": { + "input": 0.14, + "output": 0.28, + "cache_read": 0.028 + }, + "kimi-k2.6": { + "input": 0.95, + "output": 4, + "cache_read": 0.16 + }, + "claude-sonnet-4-5": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75, + "tiers": [ + { + "input": 6, + "output": 22.5, + "cache_read": 0.6, + "cache_write": 7.5, + "tier": { + "type": "context", + "size": 200000 + } + } + ] + }, + "claude-opus-5-5": { + "input": 4, + "output": 20, + "cache_read": 0.2, + "cache_write": 5 + }, + "claude-fable-5-1": { + "input": 10, + "output": 50, + "cache_read": 0.25, + "cache_write": 12.5 + }, + "gemini-3.6-flash": { + "input": 1.5, + "output": 7.5, + "cache_read": 0.15 + }, + "kimi-k2-thinking": { + "input": 0.4, + "output": 2.5, + "cache_read": 0.4 + }, + "minimax-m2.5-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "cat-jam-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "gpt-5.3-codex-spark": { + "input": 1.75, + "output": 14, + "cache_read": 0.175 + }, + "gpt-6.1-sol": { + "input": 2, + "output": 10, + "cache_read": 0.1, + "cache_write": 2.5, + "tiers": [ + { + "input": 4, + "output": 15, + "cache_read": 0.2, + "cache_write": 5, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "gemini-3.5-flash-lite": { + "input": 0.3, + "output": 2.5, + "cache_read": 0.03 + }, + "claude-opus-4-1": { + "input": 15, + "output": 75, + "cache_read": 1.5, + "cache_write": 18.75 + }, + "gpt-6-astra": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5, + "tiers": [ + { + "input": 20, + "output": 75, + "cache_read": 2, + "cache_write": 25, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "grok-4.5": { + "input": 2, + "output": 6, + "cache_read": 0.3, + "tiers": [ + { + "input": 4, + "output": 12, + "cache_read": 0.6, + "tier": { + "type": "context", + "size": 200000 + } + } + ] + }, + "ring-2.6-1t-free": { + "input": 0, + "output": 0 + }, + "kimi-k2.5": { + "input": 0.6, + "output": 3, + "cache_read": 0.08 + }, + "longcat-2.0-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "gpt-5.1": { + "input": 1.07, + "output": 8.5, + "cache_read": 0.107 + }, + "claude-opus-5": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "claude-3-5-haiku": { + "input": 0.8, + "output": 4, + "cache_read": 0.08, + "cache_write": 1 + }, + "hy3-preview-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "gemini-3-flash": { + "input": 0.5, + "output": 3, + "cache_read": 0.05 + }, + "minimax-m2.7": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, + "gemini-3.5-flash": { + "input": 1.5, + "output": 9, + "cache_read": 0.15 + }, + "space-bunny-free": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "claude-fable-5": { + "input": 10, + "output": 50, + "cache_read": 1, + "cache_write": 12.5 + }, + "nemotron-3-super-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "minimax-m2.1-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "mimo-v2.6-flash-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "gemini-3-pro": { + "input": 2, + "output": 12, + "cache_read": 0.2, + "tiers": [ + { + "input": 4, + "output": 18, + "cache_read": 0.4, + "tier": { + "type": "context", + "size": 200000 + } + } + ] + }, + "nemotron-3-ultra-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "claude-sonnet-4": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75, + "tiers": [ + { + "input": 6, + "output": 22.5, + "cache_read": 0.6, + "cache_write": 7.5, + "tier": { + "type": "context", + "size": 200000 + } + } + ] + }, + "muse-spark-1.2": { + "input": 1.25, + "output": 4.25, + "cache_read": 0.15 + }, + "gpt-5.4-mini": { + "input": 0.75, + "output": 4.5, + "cache_read": 0.075 + }, + "glm-4.7": { + "input": 0.6, + "output": 2.2, + "cache_read": 0.1 + }, + "minimax-m3": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, + "gpt-5.6-luna": { + "input": 0.2, + "output": 1.2, + "cache_read": 0.02, + "cache_write": 0.25, + "tiers": [ + { + "input": 0.4, + "output": 1.8, + "cache_read": 0.04, + "cache_write": 0.5, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "qwen3.8-flash": { + "input": 0.15, + "output": 0.47, + "cache_read": 0.016, + "cache_write": 0.2 + }, + "gpt-5.1-codex-max": { + "input": 1.25, + "output": 10, + "cache_read": 0.125 + }, + "mimo-v2.5-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "grok-code": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "gpt-5.2": { + "input": 1.75, + "output": 14, + "cache_read": 0.175 + }, + "claude-opus-4-8": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "gpt-5.5": { + "input": 5, + "output": 30, + "cache_read": 0.5, + "tiers": [ + { + "input": 10, + "output": 45, + "cache_read": 1, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "ling-2.6-flash-free": { + "input": 0, + "output": 0 + }, + "claude-sonnet-5": { + "input": 2, + "output": 10, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "deepseek-v4-flash-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "hy3-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "nemotron-3.5-lightning-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "claude-opus-4-6": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "gemini-3.7-flash": { + "input": 1.5, + "output": 7.5, + "cache_read": 0.15 + }, + "glm-5.2": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 + }, + "nemotron-3.5-super-vl-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "kimi-k2": { + "input": 0.4, + "output": 2.5, + "cache_read": 0.4 + }, + "glm-5.1": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 + }, + "mimo-v2-omni-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "grok-build-0.1": { + "input": 1, + "output": 2, + "cache_read": 0.2 + }, + "deepseek-v4.1-flash-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "ling-3.0-flash-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "claude-haiku-5-5": { + "input": 0.1, + "output": 0.5, + "cache_read": 0.01, + "cache_write": 0.125, + "tiers": [ + { + "input": 0.5, + "output": 2.5, + "cache_read": 0.05, + "cache_write": 0.625, + "tier": { + "type": "context", + "size": 100000 + } + } + ] + }, + "gemini-3.8-flash": { + "input": 1.5, + "output": 7.5, + "cache_read": 0.15 + }, + "north-mini-code-free": { + "input": 0, + "output": 0 + }, + "gpt-6-luna": { + "input": 0.1, + "output": 0.5, + "cache_read": 0.01, + "cache_write": 0.125, + "tiers": [ + { + "input": 0.2, + "output": 0.75, + "cache_read": 0.02, + "cache_write": 0.25, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "deepseek-v4-pro": { + "input": 1.74, + "output": 3.84, + "cache_read": 0.145 + }, + "claude-sonnet-4-6": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75 + }, + "big-pickle": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "claude-sonnet-5-5": { + "input": 2, + "output": 10, + "cache_read": 0.2, + "cache_write": 2.5 + }, + "qwen3.6-plus": { + "input": 0.5, + "output": 3, + "cache_read": 0.05, + "cache_write": 0.625 + }, + "gpt-5.6-terra": { + "input": 2.5, + "output": 15, + "cache_read": 0.25, + "cache_write": 3.125, + "tiers": [ + { + "input": 5, + "output": 22.5, + "cache_read": 0.5, + "cache_write": 6.25, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "mimo-v2-flash-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "gpt-5.1-codex-mini": { + "input": 0.25, + "output": 2, + "cache_read": 0.025 + }, + "step-5-preview-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "glm-5.3": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.26 + }, + "claude-opus-4-7": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "kimi-k2.7-code": { + "input": 0.95, + "output": 4, + "cache_read": 0.19 + }, + "gpt-5": { + "input": 1.07, + "output": 8.5, + "cache_read": 0.107 + }, + "grok-4.6": { + "input": 2, + "output": 6, + "cache_read": 0.5, + "tiers": [ + { + "input": 4, + "output": 12, + "cache_read": 1, + "tier": { + "type": "context", + "size": 200000 + } + } + ] + }, + "gemini-3.1-pro": { + "input": 2, + "output": 12, + "cache_read": 0.2, + "tiers": [ + { + "input": 4, + "output": 18, + "cache_read": 0.4, + "tier": { + "type": "context", + "size": 200000 + } + } + ] + }, + "minimax-m2.1": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.1 + }, + "gpt-5.6-sol": { + "input": 4, + "output": 20, + "cache_read": 0.4, + "cache_write": 5, + "tiers": [ + { + "input": 8, + "output": 30, + "cache_read": 0.8, + "cache_write": 10, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + }, + "gemini-4-argon": { + "input": 2, + "output": 10, + "cache_read": 0.1 + }, + "x-preview-f-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "muse-spark-1.3-contributor-free": { + "input": 0, + "output": 0, + "cache_read": 0 + }, + "deepseek-v4-flash": { + "input": 0.14, + "output": 0.28, + "cache_read": 0.028 + }, + "gpt-6-sol": { + "input": 2, + "output": 10, + "cache_read": 0.2, + "cache_write": 2.5, + "tiers": [ + { + "input": 4, + "output": 15, + "cache_read": 0.4, + "cache_write": 5, + "tier": { + "type": "context", + "size": 272000 + } + } + ] + } + }, + "qwen": { + "qwen-flash": { + "input": 0.05, + "output": 0.4 + }, + "qwen3.5-flash": { + "input": 0.1, + "output": 0.4, + "cache_read": 0.01, + "cache_write": 0.125 + }, + "qwen3.7-max": { + "input": 2.5, + "output": 7.5, + "cache_read": 0.5, + "cache_write": 3.125 + }, + "qwen2-5-32b-instruct": { + "input": 0.7, + "output": 2.8 + }, + "qwq-plus": { + "input": 0.8, + "output": 2.4 + }, + "qwen2-5-vl-72b-instruct": { + "input": 2.8, + "output": 8.4 + }, + "qwen3-vl-plus": { + "input": 0.2, + "output": 1.6 + }, + "qwen3.5-27b": { + "input": 0.3, + "output": 2.4 + }, + "deepseek-v4-flash-0731": { + "input": 0.2, + "output": 0.4, + "cache_read": 0.04 + }, + "qwen-max": { + "input": 1.6, + "output": 6.4 + }, + "qwen3-next-80b-a3b-thinking": { + "input": 0.5, + "output": 6 + }, + "qwen3.8-max": { + "input": 2, + "output": 6, + "cache_read": 0.25, + "cache_write": 2.5 + }, + "kimi-k3": { + "input": 3, + "output": 15, + "cache_read": 0.3 + }, + "qwen-plus-character-ja": { + "input": 0.5, + "output": 1.4 + }, + "qwen3.5-plus": { + "input": 0.4, + "output": 2.4 + }, + "qwen3-livetranslate-flash-realtime": { + "input": 10, + "output": 10 + }, + "qwen2-5-72b-instruct": { + "input": 1.4, + "output": 5.6 + }, + "qwen3-32b": { + "input": 0.7, + "output": 2.8 + }, + "qwen-plus": { + "input": 0.4, + "output": 1.2 + }, + "qwen3.7-flash": { + "input": 0.03, + "output": 0.13, + "cache_read": 0.003, + "cache_write": 0.0375, + "tiers": [ + { + "input": 0.1, + "output": 0.4, + "cache_read": 0.01, + "cache_write": 0.125, + "tier": { + "type": "context", + "size": 32000 + } + }, + { + "input": 0.2, + "output": 0.8, + "cache_read": 0.02, + "cache_write": 0.25, + "tier": { + "type": "context", + "size": 256000 + } + } + ] + }, + "qwen3-max": { + "input": 1.2, + "output": 6 + }, + "qwen-omni-turbo-realtime": { + "input": 0.27, + "output": 1.07 + }, + "qwen3-coder-480b-a35b-instruct": { + "input": 1.5, + "output": 7.5, + "tiers": [ + { + "input": 2.7, + "output": 13.5, + "tier": { + "type": "context", + "size": 32000 + } + }, + { + "input": 4.5, + "output": 22.5, + "tier": { + "type": "context", + "size": 128000 + } + } + ] + }, + "qwen-turbo": { + "input": 0.05, + "output": 0.2 + }, + "qwen3-coder-30b-a3b-instruct": { + "input": 0.45, + "output": 2.25, + "tiers": [ + { + "input": 0.75, + "output": 3.75, + "tier": { + "type": "context", + "size": 32000 + } + }, + { + "input": 1.2, + "output": 6, + "tier": { + "type": "context", + "size": 128000 + } + } + ] + }, + "qwen3.8-omni-flash": { + "input": 0.15, + "output": 0.47, + "cache_read": 0.016 + }, + "qvq-max": { + "input": 1.2, + "output": 4.8 + }, + "qwen2-5-7b-instruct": { + "input": 0.175, + "output": 0.7 + }, + "qwen3-omni-flash": { + "input": 0.43, + "output": 1.66 + }, + "qwen-vl-max": { + "input": 0.8, + "output": 3.2 + }, + "qwen3-235b-a22b": { + "input": 0.7, + "output": 2.8 + }, + "qwen3-vl-30b-a3b": { + "input": 0.2, + "output": 0.8 + }, + "qwen3.5-122b-a10b": { + "input": 0.4, + "output": 3.2 + }, + "qwen3.6-35b-a3b": { + "input": 0.248, + "output": 1.485 + }, + "qwen3-next-80b-a3b-instruct": { + "input": 0.5, + "output": 2 + }, + "qwen-vl-plus": { + "input": 0.21, + "output": 0.63 + }, + "qwen3.6-flash": { + "input": 0.1875, + "output": 1.125, + "cache_write": 0.234375 + }, + "qwen3-coder-flash": { + "input": 0.3, + "output": 1.5 + }, + "qwen2-5-omni-7b": { + "input": 0.1, + "output": 0.4 + }, + "qwen-mt-plus": { + "input": 2.46, + "output": 7.37 + }, + "qwen3-vl-235b-a22b": { + "input": 0.7, + "output": 2.8 + }, + "qwen3-coder-plus": { + "input": 1, + "output": 5 + }, + "qwen2-5-14b-instruct": { + "input": 0.35, + "output": 1.4 + }, + "qwen3.8-flash": { + "input": 0.15, + "output": 0.47, + "cache_read": 0.016, + "cache_write": 0.2 + }, + "qwen2-5-vl-7b-instruct": { + "input": 0.35, + "output": 1.05 + }, + "qwen3.5-35b-a3b": { + "input": 0.25, + "output": 2 + }, + "qwen3.6-max-preview": { + "input": 1.3, + "output": 7.8, + "cache_read": 0.13, + "cache_write": 1.625 + }, + "qwen-mt-turbo": { + "input": 0.16, + "output": 0.49 + }, + "qwen3.5-397b-a17b": { + "input": 0.6, + "output": 3.6 + }, + "qwen3-8b": { + "input": 0.18, + "output": 0.7 + }, + "glm-5.2": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.28, + "cache_write": 0 + }, + "qwen-omni-turbo": { + "input": 0.07, + "output": 0.27 + }, + "qwen3-omni-flash-realtime": { + "input": 0.52, + "output": 1.99 + }, + "qwen3-asr-flash": { + "input": 0.035, + "output": 0.035 + }, + "qwen3.6-27b": { + "input": 0.6, + "output": 3.6 + }, + "qwen3.6-plus": { + "input": 0.5, + "output": 3, + "cache_read": 0.05, + "cache_write": 0.625, + "tiers": [ + { + "input": 2, + "output": 6, + "cache_read": 0.2, + "cache_write": 2.5, + "tier": { + "type": "context", + "size": 256000 + } + } + ] + }, + "qwen3-14b": { + "input": 0.35, + "output": 1.4 + }, + "qwen3.7-plus": { + "input": 0.4, + "output": 1.6, + "cache_read": 0.04, + "cache_write": 0.5, + "tiers": [ + { + "input": 1.2, + "output": 4.8, + "cache_read": 0.12, + "cache_write": 1.5, + "tier": { + "type": "context", + "size": 256000 + } + } + ] + }, + "qwen-vl-ocr": { + "input": 0.07, + "output": 0.16 + } + }, + "zhipu": { + "glm-4.6v": { + "input": 0.3, + "output": 0.9 + }, + "glm-4.5": { + "input": 0.6, + "output": 2.2, + "cache_read": 0.11, + "cache_write": 0 + }, + "glm-5v-turbo": { + "input": 1.2, + "output": 4, + "cache_read": 0.24, + "cache_write": 0 + }, + "glm-5.3-flash": { + "input": 0.15, + "output": 0.5, + "cache_read": 0.03, + "cache_write": 0 + }, + "glm-4.6": { + "input": 0.6, + "output": 2.2, + "cache_read": 0.11, + "cache_write": 0 + }, + "glm-5": { + "input": 1, + "output": 3.2, + "cache_read": 0.2, + "cache_write": 0 + }, + "glm-4.6v-flash": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "glm-4.5v": { + "input": 0.6, + "output": 1.8 + }, + "glm-5.3-flashx": { + "input": 0.37, + "output": 1.25, + "cache_read": 0.075, + "cache_write": 0 + }, + "glm-4.7-flash": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "glm-4.7": { + "input": 0.6, + "output": 2.2, + "cache_read": 0.11, + "cache_write": 0 + }, + "glm-4.5-flash": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "glm-5.2": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.26, + "cache_write": 0 + }, + "glm-5.1": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.26, + "cache_write": 0 + }, + "glm-4.7-flashx": { + "input": 0.07, + "output": 0.4, + "cache_read": 0.01, + "cache_write": 0 + }, + "glm-5-turbo": { + "input": 1.2, + "output": 4, + "cache_read": 0.24, + "cache_write": 0 + }, + "glm-4.5-air": { + "input": 0.2, + "output": 1.1, + "cache_read": 0.03, + "cache_write": 0 + }, + "glm-5.3": { + "input": 1.4, + "output": 4.4, + "cache_read": 0.26, + "cache_write": 0 + } + } +} diff --git a/apps/api/src/modules/cost/domain/cost-pricing.ts b/apps/api/src/modules/cost/domain/cost-pricing.ts index aefef085..8969a481 100644 --- a/apps/api/src/modules/cost/domain/cost-pricing.ts +++ b/apps/api/src/modules/cost/domain/cost-pricing.ts @@ -1,12 +1,24 @@ +import prices from "../data/models-dev.json" with { type: "json" }; +import { MODELS_DEV_API_URL } from "./models-dev-pricing"; +import type { ModelsDevCost, ModelsDevPrices, ModelsDevRates } from "./models-dev-pricing"; import type { PricingStatus } from "./usage-contract"; -export interface ModelPricing { +interface PricingRates { cacheReadUsdPerMillion: number; cacheWriteUsdPerMillion: number; inputUsdPerMillion: number; - model: string; outputUsdPerMillion: number; +} + +interface ModelPricingContextTier extends PricingRates { + thresholdTokens: number; +} + +export interface ModelPricing extends PricingRates { + contextTiers: readonly ModelPricingContextTier[]; + model: string; provider: string; + source: string; vendor: string; } @@ -17,444 +29,148 @@ export interface CostCalculationResult { } export interface ModelPricingLookup { - atMs?: number; modelId: string; providerId: string; } -interface ModelPricingScheduleEntry extends ModelPricing { - effectiveFromMs?: number; - effectiveUntilMs?: number; -} - -const CLAUDE_SONNET_5_STANDARD_PRICE_START_MS = Date.UTC(2026, 8, 1); -// Published reductions: https://developers.openai.com/api/docs/changelog -// Standard rates: https://developers.openai.com/api/docs/pricing -const OPENAI_LUNA_TERRA_PRICE_REDUCTION_MS = Date.UTC(2026, 6, 30); -const OPENAI_SOL_PRICE_REDUCTION_MS = Date.UTC(2026, 7, 21); -const OPENAI_LONG_CONTEXT_INPUT_THRESHOLD = 272_000; -const OPENAI_LONG_CONTEXT_MODEL_IDS = new Set([ - "gpt-5.4", - "gpt-5.5", - "gpt-5.6-luna", - "gpt-5.6-sol", - "gpt-5.6-terra", -]); - -const MODEL_PRICING: readonly ModelPricingScheduleEntry[] = [ - { - ...anthropicPricing({ - inputUsdPerMillion: 2, - model: "claude-sonnet-5", - outputUsdPerMillion: 10, - }), - effectiveUntilMs: CLAUDE_SONNET_5_STANDARD_PRICE_START_MS, - }, - { - ...anthropicPricing({ - inputUsdPerMillion: 3, - model: "claude-sonnet-5", - outputUsdPerMillion: 15, - }), - effectiveFromMs: CLAUDE_SONNET_5_STANDARD_PRICE_START_MS, - }, - anthropicPricing({ - inputUsdPerMillion: 10, - model: "claude-fable-5", - outputUsdPerMillion: 50, - }), - anthropicPricing({ - inputUsdPerMillion: 15, - model: "claude-opus-4-7", - outputUsdPerMillion: 75, - }), - anthropicPricing({ - inputUsdPerMillion: 15, - model: "claude-opus-4-6", - outputUsdPerMillion: 75, - }), - anthropicPricing({ - inputUsdPerMillion: 15, - model: "claude-opus-4-5", - outputUsdPerMillion: 75, - }), - anthropicPricing({ - inputUsdPerMillion: 3, - model: "claude-sonnet-4-6", - outputUsdPerMillion: 15, - }), - anthropicPricing({ - inputUsdPerMillion: 3, - model: "claude-sonnet-4-5", - outputUsdPerMillion: 15, - }), - anthropicPricing({ - inputUsdPerMillion: 0.8, - model: "claude-haiku-4-5", - outputUsdPerMillion: 4, - }), - { - ...openAiPricing({ - inputUsdPerMillion: 5, - model: "gpt-5.6-sol", - outputUsdPerMillion: 30, - }), - effectiveUntilMs: OPENAI_SOL_PRICE_REDUCTION_MS, - }, - { - ...openAiPricing({ - inputUsdPerMillion: 4, - model: "gpt-5.6-sol", - outputUsdPerMillion: 20, - }), - effectiveFromMs: OPENAI_SOL_PRICE_REDUCTION_MS, - }, - { - ...openAiPricing({ - inputUsdPerMillion: 2.5, - model: "gpt-5.6-terra", - outputUsdPerMillion: 15, - }), - effectiveUntilMs: OPENAI_LUNA_TERRA_PRICE_REDUCTION_MS, - }, - { - ...openAiPricing({ - inputUsdPerMillion: 2, - model: "gpt-5.6-terra", - outputUsdPerMillion: 12, - }), - effectiveFromMs: OPENAI_LUNA_TERRA_PRICE_REDUCTION_MS, - }, - { - ...openAiPricing({ - inputUsdPerMillion: 1, - model: "gpt-5.6-luna", - outputUsdPerMillion: 6, - }), - effectiveUntilMs: OPENAI_LUNA_TERRA_PRICE_REDUCTION_MS, - }, - { - ...openAiPricing({ - inputUsdPerMillion: 0.2, - model: "gpt-5.6-luna", - outputUsdPerMillion: 1.2, - }), - effectiveFromMs: OPENAI_LUNA_TERRA_PRICE_REDUCTION_MS, - }, - openAiPricing({ - inputUsdPerMillion: 5, - model: "gpt-5.5", - outputUsdPerMillion: 30, - }), - openAiPricing({ - inputUsdPerMillion: 2.5, - model: "gpt-5.4", - outputUsdPerMillion: 15, - }), - openAiPricing({ - inputUsdPerMillion: 0.75, - model: "gpt-5.4-mini", - outputUsdPerMillion: 4.5, - }), - openAiPricing({ - inputUsdPerMillion: 1.75, - model: "gpt-5.3", - outputUsdPerMillion: 14, - }), - openAiPricing({ - inputUsdPerMillion: 1.75, - model: "gpt-5.2", - outputUsdPerMillion: 14, - }), - providerPricing({ - cacheReadUsdPerMillion: 0.003625, - inputUsdPerMillion: 0.435, - model: "deepseek-v4-pro", - outputUsdPerMillion: 0.87, - provider: "deepseek", - vendor: "DeepSeek", - }), - providerPricing({ - cacheReadUsdPerMillion: 0.0028, - inputUsdPerMillion: 0.14, - model: "deepseek-v4-flash", - outputUsdPerMillion: 0.28, - provider: "deepseek", - vendor: "DeepSeek", - }), - providerPricing({ - cacheReadUsdPerMillion: 0.15, - inputUsdPerMillion: 1.5, - model: "gemini-3.5-flash", - outputUsdPerMillion: 9, - provider: "gemini", - vendor: "Google", - }), - providerPricing({ - cacheReadUsdPerMillion: 0.0552, - inputUsdPerMillion: 0.276, - model: "qwen3.7-plus", - outputUsdPerMillion: 1.101, - provider: "qwen", - vendor: "Alibaba", - }), - providerPricing({ - cacheReadUsdPerMillion: 0.0552, - inputUsdPerMillion: 0.276, - model: "qwen3.6-plus", - outputUsdPerMillion: 1.651, - provider: "qwen", - vendor: "Alibaba", - }), - providerPricing({ - cacheReadUsdPerMillion: 0.16, - inputUsdPerMillion: 0.95, - model: "kimi-k2.6", - outputUsdPerMillion: 4, - provider: "kimi", - vendor: "Moonshot AI", - }), - providerPricing({ - cacheReadUsdPerMillion: 0.19, - inputUsdPerMillion: 0.95, - model: "kimi-k2.7-code", - outputUsdPerMillion: 4, - provider: "kimi", - vendor: "Moonshot AI", - }), - providerPricing({ - cacheReadUsdPerMillion: 0.11, - inputUsdPerMillion: 0.6, - model: "glm-4.7", - outputUsdPerMillion: 2.2, - provider: "zhipu", - vendor: "Z.ai", - }), - providerPricing({ - cacheReadUsdPerMillion: 0.11, - inputUsdPerMillion: 0.6, - model: "glm-4.6", - outputUsdPerMillion: 2.2, - provider: "zhipu", - vendor: "Z.ai", - }), - providerPricing({ - cacheReadUsdPerMillion: 0.09, - inputUsdPerMillion: 0.45, - model: "MiniMax-M3", - outputUsdPerMillion: 1.8, - provider: "minimax", - vendor: "MiniMax", - }), - providerPricing({ - cacheReadUsdPerMillion: 0.06, - cacheWriteUsdPerMillion: 0.375, - inputUsdPerMillion: 0.3, - model: "MiniMax-M2.7", - outputUsdPerMillion: 1.2, - provider: "minimax", - vendor: "MiniMax", - }), - providerPricing({ - cacheReadUsdPerMillion: 0.145, - inputUsdPerMillion: 1.74, - model: "deepseek-v4-pro", - outputUsdPerMillion: 3.48, - provider: "opencode", - vendor: "OpenCode Zen", - }), - providerPricing({ - cacheReadUsdPerMillion: 0.05, - cacheWriteUsdPerMillion: 0.625, - inputUsdPerMillion: 0.5, - model: "qwen3.6-plus", - outputUsdPerMillion: 3, - provider: "opencode", - vendor: "OpenCode Zen", - }), - providerPricing({ - cacheReadUsdPerMillion: 0.26, - inputUsdPerMillion: 1.4, - model: "glm-5.2", - outputUsdPerMillion: 4.4, - provider: "opencode", - vendor: "OpenCode Zen", - }), - providerPricing({ - cacheReadUsdPerMillion: 0.06, - cacheWriteUsdPerMillion: 0.375, - inputUsdPerMillion: 0.3, - model: "minimax-m2.7", - outputUsdPerMillion: 1.2, - provider: "opencode", - vendor: "OpenCode Zen", - }), - providerPricing({ - cacheReadUsdPerMillion: 0.15, - inputUsdPerMillion: 1.5, - model: "gemini-3.5-flash", - outputUsdPerMillion: 9, - provider: "opencode", - vendor: "OpenCode Zen", - }), -]; - -function anthropicPricing(input: { - inputUsdPerMillion: number; - model: string; - outputUsdPerMillion: number; -}): ModelPricing { - return { - cacheReadUsdPerMillion: scalePrice(input.inputUsdPerMillion, 0.1), - cacheWriteUsdPerMillion: scalePrice(input.inputUsdPerMillion, 1.25), - inputUsdPerMillion: input.inputUsdPerMillion, - model: input.model, - outputUsdPerMillion: input.outputUsdPerMillion, - provider: "anthropic", - vendor: "Anthropic", - }; -} - -function openAiPricing(input: { - inputUsdPerMillion: number; - model: string; - outputUsdPerMillion: number; -}): ModelPricing { - return { - cacheReadUsdPerMillion: scalePrice(input.inputUsdPerMillion, 0.1), - cacheWriteUsdPerMillion: scalePrice(input.inputUsdPerMillion, 1.25), - inputUsdPerMillion: input.inputUsdPerMillion, - model: input.model, - outputUsdPerMillion: input.outputUsdPerMillion, - provider: "openai", - vendor: "OpenAI", - }; -} - -function providerPricing(input: { - cacheReadUsdPerMillion: number; - cacheWriteUsdPerMillion?: number; - inputUsdPerMillion: number; +export interface UsageCostInput { + cacheCreationTokens: number; + cacheReadTokens: number; + inputTokens: number; model: string; - outputUsdPerMillion: number; + outputTokens: number; + providedCostUsd?: number | null; provider: string; - vendor: string; -}): ModelPricing { - return { - cacheReadUsdPerMillion: input.cacheReadUsdPerMillion, - cacheWriteUsdPerMillion: input.cacheWriteUsdPerMillion ?? 0, - inputUsdPerMillion: input.inputUsdPerMillion, - model: input.model, - outputUsdPerMillion: input.outputUsdPerMillion, - provider: input.provider, - vendor: input.vendor, - }; } -function scalePrice(value: number, factor: number): number { - return Number((value * factor).toFixed(6)); +export interface ModelsDevPricingCatalog { + findModelPricing: (input: ModelPricingLookup) => ModelPricing | null; + calculateUsageCost: (input: UsageCostInput) => CostCalculationResult; } -function normalizeProviderId(providerId: string): string { - return providerId.trim().toLowerCase(); -} +const VENDOR_BY_PROVIDER: Readonly> = { + anthropic: "Anthropic", + deepseek: "DeepSeek", + gemini: "Google", + kimi: "Moonshot AI", + minimax: "MiniMax", + openai: "OpenAI", + opencode: "OpenCode Zen", + qwen: "Alibaba", + zhipu: "Z.ai", +}; -function normalizeModelId(modelId: string): string { - return modelId.trim().toLowerCase(); -} - -function stripProviderPrefix(modelId: string): string { - const separatorIndex = modelId.lastIndexOf("/"); - - return separatorIndex === -1 ? modelId : modelId.slice(separatorIndex + 1); -} - -function stripContextWindowSuffix(modelId: string): string { - return modelId.replace(/\[[^\]]+\]$/u, ""); -} +// Retain the reviewed Mosoo rates for the preset absent from models.dev. +const PRICING_OVERRIDES: ModelsDevPrices = { + openai: { + "gpt-5.3": { input: 1.75, output: 14, cache_read: 0.175, cache_write: 2.1875 }, + }, +}; -function stripVersionSuffix(modelId: string): string { - return modelId.replace(/-(?:latest|\d{8}|\d{4}-\d{2}-\d{2})$/u, ""); +function finiteNonNegative(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; } -function normalizeAnthropicSeparator(modelId: string): string { - return modelId.startsWith("claude-") ? modelId.replace(/\./gu, "-") : modelId; -} +function pricingRates(cost: ModelsDevRates): PricingRates | null { + if ( + !finiteNonNegative(cost.input) || + !finiteNonNegative(cost.output) || + (cost.cache_read !== undefined && !finiteNonNegative(cost.cache_read)) || + (cost.cache_write !== undefined && !finiteNonNegative(cost.cache_write)) + ) + return null; -function pricingMapKey(providerId: string, modelId: string): string { - return `${normalizeProviderId(providerId)}:${normalizeModelId(modelId)}`; + return { + cacheReadUsdPerMillion: cost.cache_read ?? cost.input, + cacheWriteUsdPerMillion: cost.cache_write ?? cost.input, + inputUsdPerMillion: cost.input, + outputUsdPerMillion: cost.output, + }; } -function buildPricingMap(): ReadonlyMap { - const pricingMap = new Map(); - - for (const pricing of MODEL_PRICING) { - const key = pricingMapKey(pricing.provider, pricing.model); - const entries = pricingMap.get(key) ?? []; - - entries.push(pricing); - pricingMap.set(key, entries); +function modelPricing( + provider: string, + model: string, + cost: ModelsDevCost, + source: string, +): ModelPricing | null { + const rates = pricingRates(cost); + if (rates === null) return null; + const contextTiers: ModelPricingContextTier[] = []; + const thresholds = new Set(); + for (const { tier, ...costTier } of cost.tiers ?? []) { + const tierRates = pricingRates(costTier); + if ( + tier.type !== "context" || + !Number.isInteger(tier.size) || + tier.size < 0 || + thresholds.has(tier.size) || + tierRates === null + ) + return null; + thresholds.add(tier.size); + contextTiers.push({ ...tierRates, thresholdTokens: tier.size }); } - - return pricingMap; -} - -function isPricingEffective(pricing: ModelPricingScheduleEntry, atMs: number): boolean { - return ( - (pricing.effectiveFromMs === undefined || atMs >= pricing.effectiveFromMs) && - (pricing.effectiveUntilMs === undefined || atMs < pricing.effectiveUntilMs) - ); + return { + ...rates, + contextTiers: contextTiers.toSorted((a, b) => a.thresholdTokens - b.thresholdTokens), + model, + provider, + source, + vendor: VENDOR_BY_PROVIDER[provider] ?? provider, + }; } -const PRICING_BY_PROVIDER_MODEL = buildPricingMap(); - -export function findModelPricing(input: ModelPricingLookup): ModelPricing | null { - const atMs = input.atMs ?? Date.now(); - const providerId = normalizeProviderId(input.providerId); - const modelId = normalizeModelId(input.modelId); - const candidates = new Set([ - modelId, - stripProviderPrefix(modelId), - stripContextWindowSuffix(modelId), - stripVersionSuffix(modelId), - stripVersionSuffix(stripContextWindowSuffix(stripProviderPrefix(modelId))), - normalizeAnthropicSeparator(modelId), - ]); - - for (const candidate of candidates) { - const pricing = PRICING_BY_PROVIDER_MODEL.get(`${providerId}:${candidate}`)?.find((entry) => - isPricingEffective(entry, atMs), - ); - - if (pricing) { - return pricing; +function normalizeModelId(modelId: string): string { + const normalized = modelId.trim().toLowerCase(); + return normalized.startsWith("claude-") ? normalized.replace(/\./gu, "-") : normalized; +} + +function modelCandidates(modelId: string): readonly string[] { + const exact = modelId.trim().toLowerCase(); + const unprefixed = exact.slice(exact.lastIndexOf("/") + 1); + const withoutContext = unprefixed.replace(/\[[^\]]+\]$/u, ""); + const withoutVersion = withoutContext.replace(/-(?:latest|\d{8}|\d{4}-\d{2}-\d{2})$/u, ""); + return [...new Set([exact, unprefixed, withoutContext, withoutVersion].map(normalizeModelId))]; +} + +export function createModelsDevPricingCatalog(snapshot: ModelsDevPrices): ModelsDevPricingCatalog { + const entries = new Map(); + for (const [data, source] of [ + [snapshot, MODELS_DEV_API_URL], + [PRICING_OVERRIDES, "mosoo_override"], + ] as const) { + for (const [provider, models] of Object.entries(data)) { + for (const [model, cost] of Object.entries(models)) { + const pricing = modelPricing(provider, model, cost, source); + if (pricing !== null) entries.set(`${provider}:${normalizeModelId(model)}`, pricing); + } } } - - return null; + const findPricing = ({ modelId, providerId }: ModelPricingLookup): ModelPricing | null => { + const provider = providerId.trim().toLowerCase(); + for (const candidate of modelCandidates(modelId)) { + const pricing = entries.get(`${provider}:${candidate}`); + if (pricing !== undefined) return pricing; + } + return null; + }; + return { + findModelPricing: findPricing, + calculateUsageCost: (input: UsageCostInput) => calculateUsageCostWithLookup(findPricing, input), + }; } -export function calculateUsageCost(input: { - cacheCreationTokens: number; - cacheReadTokens: number; - inputTokens: number; - model: string; - outputTokens: number; - pricedAtMs?: number; - providedCostUsd?: number | null; - provider: string; -}): CostCalculationResult { - const pricing = findModelPricing({ - ...(input.pricedAtMs === undefined ? {} : { atMs: input.pricedAtMs }), - modelId: input.model, - providerId: input.provider, - }); +function calculateUsageCostWithLookup( + findPricing: (input: ModelPricingLookup) => ModelPricing | null, + input: UsageCostInput, +): CostCalculationResult { + const pricing = findPricing({ modelId: input.model, providerId: input.provider }); const tokensUnavailable = input.cacheCreationTokens === 0 && input.cacheReadTokens === 0 && input.inputTokens === 0 && input.outputTokens === 0; - if (tokensUnavailable && input.providedCostUsd !== null && input.providedCostUsd !== undefined) { return { priceSnapshotJson: @@ -471,8 +187,7 @@ export function calculateUsageCost(input: { totalCostUsd: input.providedCostUsd, }; } - - if (!pricing) { + if (pricing === null) { return { priceSnapshotJson: null, pricingStatus: "unknown", @@ -480,41 +195,43 @@ export function calculateUsageCost(input: { }; } - const longContextApplied = - pricing.provider === "openai" && - OPENAI_LONG_CONTEXT_MODEL_IDS.has(pricing.model) && - input.inputTokens + input.cacheCreationTokens > OPENAI_LONG_CONTEXT_INPUT_THRESHOLD; - const inputPriceFactor = longContextApplied ? 2 : 1; - const outputPriceFactor = longContextApplied ? 1.5 : 1; - const effectivePricing: ModelPricing = { - ...pricing, - cacheReadUsdPerMillion: scalePrice(pricing.cacheReadUsdPerMillion, inputPriceFactor), - cacheWriteUsdPerMillion: scalePrice(pricing.cacheWriteUsdPerMillion, inputPriceFactor), - inputUsdPerMillion: scalePrice(pricing.inputUsdPerMillion, inputPriceFactor), - outputUsdPerMillion: scalePrice(pricing.outputUsdPerMillion, outputPriceFactor), - }; + // Normalized input includes cache reads; cache writes are a separate bucket. + const contextTokens = input.inputTokens + input.cacheCreationTokens; + const tier = pricing.contextTiers.findLast( + ({ thresholdTokens }) => contextTokens > thresholdTokens, + ); + const rates = tier ?? pricing; const billableInputTokens = Math.max(0, input.inputTokens - input.cacheReadTokens); - const inputCost = (billableInputTokens * effectivePricing.inputUsdPerMillion) / 1_000_000; - const outputCost = (input.outputTokens * effectivePricing.outputUsdPerMillion) / 1_000_000; - const cacheReadCost = - (input.cacheReadTokens * effectivePricing.cacheReadUsdPerMillion) / 1_000_000; - const cacheWriteCost = - (input.cacheCreationTokens * effectivePricing.cacheWriteUsdPerMillion) / 1_000_000; - const totalCostUsd = inputCost + outputCost + cacheReadCost + cacheWriteCost; - + // Output already includes reasoning. Audio has no separate usage counter. + const totalCostUsd = + (billableInputTokens * rates.inputUsdPerMillion + + input.outputTokens * rates.outputUsdPerMillion + + input.cacheReadTokens * rates.cacheReadUsdPerMillion + + input.cacheCreationTokens * rates.cacheWriteUsdPerMillion) / + 1_000_000; return { priceSnapshotJson: JSON.stringify({ billableInputTokens, - cacheReadUsdPerMillion: effectivePricing.cacheReadUsdPerMillion, - cacheWriteUsdPerMillion: effectivePricing.cacheWriteUsdPerMillion, - inputUsdPerMillion: effectivePricing.inputUsdPerMillion, - longContextApplied, - model: effectivePricing.model, - outputUsdPerMillion: effectivePricing.outputUsdPerMillion, - provider: effectivePricing.provider, - source: "mosoo_seed_2026_07_10", + cacheReadUsdPerMillion: rates.cacheReadUsdPerMillion, + cacheWriteUsdPerMillion: rates.cacheWriteUsdPerMillion, + inputUsdPerMillion: rates.inputUsdPerMillion, + longContextApplied: tier !== undefined, + model: pricing.model, + outputUsdPerMillion: rates.outputUsdPerMillion, + provider: pricing.provider, + source: pricing.source, }), pricingStatus: "priced", totalCostUsd, }; } + +const DEFAULT_PRICING_CATALOG = createModelsDevPricingCatalog(prices); + +export function findModelPricing(input: ModelPricingLookup): ModelPricing | null { + return DEFAULT_PRICING_CATALOG.findModelPricing(input); +} + +export function calculateUsageCost(input: UsageCostInput): CostCalculationResult { + return DEFAULT_PRICING_CATALOG.calculateUsageCost(input); +} diff --git a/apps/api/src/modules/cost/domain/models-dev-pricing.ts b/apps/api/src/modules/cost/domain/models-dev-pricing.ts new file mode 100644 index 00000000..33f467cb --- /dev/null +++ b/apps/api/src/modules/cost/domain/models-dev-pricing.ts @@ -0,0 +1,32 @@ +export const MODELS_DEV_API_URL = "https://models.dev/api.json"; + +/** Mosoo provider identities mapped to their models.dev pricing source. */ +export const MODELS_DEV_PROVIDER_IDS: Readonly> = { + anthropic: "anthropic", + deepseek: "deepseek", + gemini: "google", + kimi: "moonshotai", + minimax: "minimax", + openai: "openai", + opencode: "opencode", + qwen: "alibaba", + zhipu: "zai", +}; + +export interface ModelsDevRates { + readonly input?: number; + readonly output?: number; + readonly cache_read?: number; + readonly cache_write?: number; +} + +export interface ModelsDevContextTier extends ModelsDevRates { + readonly tier: { readonly type: string; readonly size: number }; +} + +export interface ModelsDevCost extends ModelsDevRates { + readonly tiers?: readonly ModelsDevContextTier[]; +} + +/** One prices-only snapshot, keyed by Mosoo provider and upstream model ID. */ +export type ModelsDevPrices = Readonly>>>; diff --git a/apps/api/tests/cost-pricing.test.ts b/apps/api/tests/cost-pricing.test.ts index 1f4c972b..2ab2cad9 100644 --- a/apps/api/tests/cost-pricing.test.ts +++ b/apps/api/tests/cost-pricing.test.ts @@ -2,229 +2,365 @@ import { describe, expect, test } from "bun:test"; import { PRESET_MODEL_CATALOG } from "@mosoo/runtime-catalog"; -import { calculateUsageCost, findModelPricing } from "../src/modules/cost/domain/cost-pricing"; - -const COST_PRICED_PROVIDER_IDS = new Set([ - "anthropic", - "deepseek", - "gemini", - "kimi", - "minimax", - "opencode", - "openai", - "qwen", - "zhipu", -]); - -function requiresCostPricing(model: (typeof PRESET_MODEL_CATALOG)[number]): boolean { - return COST_PRICED_PROVIDER_IDS.has(model.vendorId); +import { selectModelsDevPrices } from "../bin/refresh-models-dev"; +import { + calculateUsageCost, + createModelsDevPricingCatalog, + findModelPricing, +} from "../src/modules/cost/domain/cost-pricing"; +import type { UsageCostInput } from "../src/modules/cost/domain/cost-pricing"; +import { MODELS_DEV_PROVIDER_IDS } from "../src/modules/cost/domain/models-dev-pricing"; +import type { ModelsDevCost } from "../src/modules/cost/domain/models-dev-pricing"; +import { normalizeUsageTokens } from "../src/modules/cost/domain/usage-contract"; + +const USAGE: UsageCostInput = { + cacheCreationTokens: 40, + cacheReadTokens: 100, + inputTokens: 1_000, + model: "test-model", + outputTokens: 200, + provider: "openai", +}; + +function catalogFor(cost: ModelsDevCost) { + return createModelsDevPricingCatalog({ openai: { "test-model": cost } }); +} + +function snapshot(result: { priceSnapshotJson: string | null }) { + return JSON.parse(result.priceSnapshotJson ?? "{}"); } describe("cost pricing", () => { test("has pricing for cost-managed preset models", () => { - for (const model of PRESET_MODEL_CATALOG.filter(requiresCostPricing)) { + for (const model of PRESET_MODEL_CATALOG) { + if (MODELS_DEV_PROVIDER_IDS[model.vendorId] === undefined) continue; expect( - findModelPricing({ - modelId: model.modelId, - providerId: model.vendorId, - }), + findModelPricing({ modelId: model.modelId, providerId: model.vendorId }), `${model.vendorId}:${model.modelId}`, ).not.toBeNull(); } }); - test("does not price a model under a different provider", () => { - expect( - findModelPricing({ - modelId: "claude-sonnet-4-5", - providerId: "openai", - }), - ).toBeNull(); + test.each([ + ["kimi", "kimi/kimi-k2.6", "kimi-k2.6"], + ["kimi", "kimi-k2.7-code-2026-06-25", "kimi-k2.7-code"], + ["zhipu", "zai/glm-4.6", "glm-4.6"], + ["minimax", "minimax/minimax-m3", "MiniMax-M3"], + ["opencode", "opencode/qwen3.6-plus", "qwen3.6-plus"], + ["anthropic", "claude-opus-4.7", "claude-opus-4-7"], + ["anthropic", "anthropic/claude-opus-4.7-20261001[1m]", "claude-opus-4-7"], + ["openai", " OPENAI/GPT-5.3-latest ", "gpt-5.3"], + ])("normalizes %s model %s at the current time", (providerId, modelId, expected) => { + expect(findModelPricing({ modelId, providerId: ` ${providerId.toUpperCase()} ` })?.model).toBe( + expected, + ); }); - test("selects Claude Sonnet 5 pricing from the usage timestamp", () => { - expect( - findModelPricing({ - atMs: Date.UTC(2026, 7, 31, 23, 59, 59), - modelId: "claude-sonnet-5", - providerId: "anthropic", - }), - ).toMatchObject({ - inputUsdPerMillion: 2, - outputUsdPerMillion: 10, + test("keeps provider prices separate and prefers exact dated model IDs", () => { + const catalog = createModelsDevPricingCatalog({ + kimi: { model: { input: 1, output: 2 }, "model-2026-10-01": { input: 3, output: 4 } }, + opencode: { model: { input: 5, output: 6 } }, }); expect( - findModelPricing({ - atMs: Date.UTC(2026, 8, 1), - modelId: "claude-sonnet-5", - providerId: "anthropic", - }), - ).toMatchObject({ - inputUsdPerMillion: 3, - outputUsdPerMillion: 15, + catalog.findModelPricing({ providerId: "kimi", modelId: "model-2026-10-01" }) + ?.inputUsdPerMillion, + ).toBe(3); + expect( + catalog.findModelPricing({ providerId: "opencode", modelId: "model" })?.inputUsdPerMillion, + ).toBe(5); + expect(findModelPricing({ providerId: "openai", modelId: "claude-sonnet-5" })).toBeNull(); + expect(findModelPricing({ providerId: "opencode-go", modelId: "deepseek-v4-pro" })).toBeNull(); + }); + + test.each([ + ["anthropic", "claude-sonnet-5", 2, 10], + ["anthropic", "claude-opus-4-5", 5, 25], + ["anthropic", "claude-opus-4-6", 5, 25], + ["anthropic", "claude-opus-4-7", 5, 25], + ["anthropic", "claude-haiku-4-5", 1, 5], + ["deepseek", "deepseek-v4-pro", 0.66, 1.98], + ["deepseek", "deepseek-v4-flash", 0.15, 0.6], + ["gemini", "gemini-3.5-flash", 1.5, 9], + ["opencode", "gemini-3.5-flash", 1.5, 9], + ] as const)("uses models.dev rates for %s/%s", (providerId, modelId, input, output) => { + expect(findModelPricing({ providerId, modelId })).toMatchObject({ + inputUsdPerMillion: input, + outputUsdPerMillion: output, + source: "https://models.dev/api.json", }); }); - test("uses current GPT-5.4 prices", () => { - expect( - findModelPricing({ - modelId: "gpt-5.4", - providerId: "openai", - }), - ).toMatchObject({ - inputUsdPerMillion: 2.5, - outputUsdPerMillion: 15, + test("layers reviewed overrides on the same normalized lookup", () => { + const catalog = createModelsDevPricingCatalog({ + openai: { "gpt-5.3": { input: 99, output: 99 } }, }); expect( - findModelPricing({ - modelId: "gpt-5.4-mini", - providerId: "openai", - }), + catalog.findModelPricing({ providerId: "openai", modelId: "openai/gpt-5.3-latest" }), ).toMatchObject({ - inputUsdPerMillion: 0.75, - outputUsdPerMillion: 4.5, + inputUsdPerMillion: 1.75, + outputUsdPerMillion: 14, + source: "mosoo_override", + }); + expect(findModelPricing({ providerId: "openai", modelId: "gpt-5.3" })).toMatchObject({ + inputUsdPerMillion: 1.75, }); }); test.each([ - { model: "gpt-5.6-luna", atMs: Date.UTC(2026, 6, 30), before: [1, 6], after: [0.2, 1.2] }, - { model: "gpt-5.6-terra", atMs: Date.UTC(2026, 6, 30), before: [2.5, 15], after: [2, 12] }, - { model: "gpt-5.6-sol", atMs: Date.UTC(2026, 7, 21), before: [5, 30], after: [4, 20] }, - ])("selects $model prices at the published reduction date", ({ model, atMs, before, after }) => { - for (const [pricedAtMs, rates] of [ - [atMs - 1, before], - [atMs, after], - ] as const) { - const result = calculateUsageCost({ - cacheCreationTokens: 50_000, - cacheReadTokens: 100_000, - inputTokens: 200_000, - model, - outputTokens: 10_000, - pricedAtMs, - provider: "openai", - }); - expect(JSON.parse(result.priceSnapshotJson ?? "{}")).toMatchObject({ - inputUsdPerMillion: rates[0], - outputUsdPerMillion: rates[1], - }); - expect(result.totalCostUsd).toBeCloseTo(rates[0]! * 0.1725 + rates[1]! * 0.01, 8); - } + ["openai", "gpt-5.5"], + ["openai", "gpt-5.4"], + ["openai", "gpt-5.4-mini"], + ["openai", "gpt-5.2"], + ["kimi", "kimi-k2.6"], + ["kimi", "kimi-k2.7-code"], + ["minimax", "MiniMax-M3"], + ["opencode", "deepseek-v4-pro"], + ["opencode", "glm-5.2"], + ["opencode", "minimax-m2.7"], + ["deepseek", "deepseek-v4-pro"], + ["deepseek", "deepseek-v4-flash"], + ["gemini", "gemini-3.5-flash"], + ["opencode", "gemini-3.5-flash"], + ])("prices cache creation for %s/%s at the input rate when omitted", (provider, model) => { + const result = calculateUsageCost({ ...USAGE, provider, model }); + const pricing = findModelPricing({ providerId: provider, modelId: model }); + expect(result.pricingStatus).toBe("priced"); + expect(snapshot(result).cacheWriteUsdPerMillion).toBe(pricing?.inputUsdPerMillion); }); - test("normalizes model IDs emitted by OpenCode-compatible providers", () => { - const cases = [ - { - expectedModel: "kimi-k2.6", - modelId: "kimi/kimi-k2.6", - providerId: "kimi", - }, - { - expectedModel: "kimi-k2.7-code", - modelId: "kimi-k2.7-code-2026-06-25", - providerId: "kimi", - }, - { - expectedModel: "glm-4.6", - modelId: "zai/glm-4.6", - providerId: "zhipu", - }, - { - expectedModel: "MiniMax-M3", - modelId: "minimax/minimax-m3", - providerId: "minimax", - }, - { - expectedModel: "qwen3.6-plus", - modelId: "opencode/qwen3.6-plus", - providerId: "opencode", - }, - ]; + test("prices Kimi usage without driver-provided cost", () => { + const result = calculateUsageCost({ ...USAGE, provider: "kimi", model: "kimi/kimi-k2.6" }); + expect(result.totalCostUsd).toBeCloseTo(0.001709, 12); + expect(snapshot(result)).toMatchObject({ + billableInputTokens: 900, + cacheWriteUsdPerMillion: 0.95, + }); + }); - for (const testCase of cases) { - const pricing = findModelPricing({ - modelId: testCase.modelId, - providerId: testCase.providerId, - }); + test.each([false, true])("preserves imported rate precision, tiered: %s", (tiered) => { + const rates = { + input: 0.12345678, + output: 0.23456789, + cache_read: 0.0000001, + cache_write: 0.12345678, + }; + const cost = tiered + ? { input: 1, output: 2, tiers: [{ ...rates, tier: { size: 100, type: "context" } }] } + : rates; + const result = catalogFor(cost).calculateUsageCost({ + ...USAGE, + cacheCreationTokens: 1_000_000, + cacheReadTokens: 1_000_000, + inputTokens: 2_000_000, + outputTokens: 1_000_000, + }); + expect(snapshot(result)).toMatchObject({ + inputUsdPerMillion: rates.input, + outputUsdPerMillion: rates.output, + cacheReadUsdPerMillion: rates.cache_read, + cacheWriteUsdPerMillion: rates.cache_write, + }); + expect(result.totalCostUsd).toBeCloseTo( + rates.input + rates.output + rates.cache_read + rates.cache_write, + 12, + ); + }); + + test.each([false, true])( + "falls back to the effective input rate, preserving explicit zero, tiered: %s", + (tiered) => { + for (const cache of [{}, { cache_read: 0, cache_write: 0 }]) { + const rates = { input: 2, output: 3, ...cache }; + const cost = tiered + ? { + input: 1, + output: 1, + cache_read: 0.1, + cache_write: 0.2, + tiers: [{ ...rates, tier: { size: 100, type: "context" } }], + } + : rates; + const result = catalogFor(cost).calculateUsageCost(USAGE); + const cacheRate = "cache_read" in cache ? 0 : 2; + expect(result.pricingStatus).toBe("priced"); + expect(snapshot(result)).toMatchObject({ + cacheReadUsdPerMillion: cacheRate, + cacheWriteUsdPerMillion: cacheRate, + }); + expect(result.totalCostUsd).toBeCloseTo( + (900 * 2 + 200 * 3 + 140 * cacheRate) / 1_000_000, + 12, + ); + } + }, + ); - expect(pricing?.model, `${testCase.providerId}:${testCase.modelId}`).toBe( - testCase.expectedModel, + test("bills reasoning once at output rate and ignores audio rates at every tier", () => { + const cost = { + input: 1, + output: 2, + reasoning: 99, + input_audio: 98, + output_audio: 97, + tiers: [ + { + input: 3, + output: 4, + reasoning: 96, + input_audio: 95, + output_audio: 94, + tier: { size: 100, type: "context" }, + }, + ], + }; + const catalog = catalogFor(cost); + for (const [inputTokens, inputRate, outputRate] of [ + [100, 1, 2], + [101, 3, 4], + ] as const) { + const result = catalog.calculateUsageCost({ + ...USAGE, + cacheCreationTokens: 0, + cacheReadTokens: 0, + inputTokens, + }); + expect(result.pricingStatus).toBe("priced"); + expect(result.totalCostUsd).toBeCloseTo( + (inputTokens * inputRate + 200 * outputRate) / 1_000_000, + 12, ); } }); - test("prices Kimi usage without driver-provided cost", () => { - const result = calculateUsageCost({ - cacheCreationTokens: 40, - cacheReadTokens: 100, - inputTokens: 1_000, - model: "kimi-k2.6", - outputTokens: 200, - provider: "kimi", - }); + test.each([ + "anthropic_bucketed", + "openai_total_with_cached_breakdown", + "openai_runtime_total_with_cached_breakdown", + ] as const)( + "counts all input buckets once at strict context tier boundaries for %s", + (usageContract) => { + const catalog = catalogFor({ + input: 1, + output: 2, + tiers: [ + { input: 5, output: 6, tier: { size: 200, type: "context" } }, + { input: 3, output: 4, tier: { size: 100, type: "context" } }, + ], + }); + for (const contextTokens of [99, 100, 101, 199, 200, 201]) { + const normalized = normalizeUsageTokens({ + usageContract, + cacheReadTokens: 30, + cacheCreationTokens: 20, + inputTokens: usageContract === "anthropic_bucketed" ? contextTokens - 50 : contextTokens, + outputTokens: 10, + }); + const result = catalog.calculateUsageCost({ ...USAGE, ...normalized }); + const inputRate = contextTokens > 200 ? 5 : contextTokens > 100 ? 3 : 1; + expect(snapshot(result).inputUsdPerMillion).toBe(inputRate); + expect(result.totalCostUsd).toBeCloseTo( + (contextTokens * inputRate + 10 * (inputRate + 1)) / 1_000_000, + 12, + ); + } + }, + ); - expect(result.pricingStatus).toBe("priced"); - expect(result.totalCostUsd).toBeCloseTo(0.001671, 8); - expect(JSON.parse(result.priceSnapshotJson ?? "{}")).toMatchObject({ - billableInputTokens: 900, - cacheReadUsdPerMillion: 0.16, - inputUsdPerMillion: 0.95, - model: "kimi-k2.6", - outputUsdPerMillion: 4, - provider: "kimi", - }); + test("ignores legacy context_over_200k in favor of structured tiers", () => { + const legacy = { input: 1, output: 2, context_over_200k: { input: 99, output: 99 } }; + const result = catalogFor(legacy).calculateUsageCost({ ...USAGE, inputTokens: 300_000 }); + expect(snapshot(result)).toMatchObject({ inputUsdPerMillion: 1, longContextApplied: false }); }); - test("applies GPT long-context prices only above the published threshold", () => { - const thresholdResult = calculateUsageCost({ + test.each([ + { input: -1, output: 2 }, + { input: 1 }, + { input: 1, output: 2, cache_read: -1 }, + { + input: 1, + output: 2, + tiers: [{ input: 3, output: 4, tier: { size: 100.5, type: "context" } }], + }, + { + input: 1, + output: 2, + tiers: [{ input: 3, output: 4, tier: { size: 100, type: "requests" } }], + }, + { + input: 1, + output: 2, + tiers: [ + { input: 3, output: 4, tier: { size: 100, type: "context" } }, + { input: 5, output: 6, tier: { size: 100, type: "context" } }, + ], + }, + ])("keeps invalid required prices or ambiguous tiers unknown: %j", (cost) => { + expect(catalogFor(cost).calculateUsageCost(USAGE).pricingStatus).toBe("unknown"); + }); + + test("preserves runtime-reported USD fallback with and without token counters", () => { + const unknown = calculateUsageCost({ ...USAGE, model: "unlisted", providedCostUsd: 0.12 }); + expect(unknown).toMatchObject({ + pricingStatus: "unknown", + priceSnapshotJson: null, + totalCostUsd: 0.12, + }); + const reported = calculateUsageCost({ + ...USAGE, + model: "gpt-5.4", cacheCreationTokens: 0, cacheReadTokens: 0, - inputTokens: 272_000, - model: "gpt-5.6-terra", - outputTokens: 1_000, - pricedAtMs: Date.UTC(2026, 8, 19), - provider: "openai", - }); - const longContextResult = calculateUsageCost({ - cacheCreationTokens: 10_000, - cacheReadTokens: 100_000, - inputTokens: 272_001, - model: "gpt-5.6-terra", - outputTokens: 1_000, - pricedAtMs: Date.UTC(2026, 8, 19), - provider: "openai", + inputTokens: 0, + outputTokens: 0, + providedCostUsd: 0.12, }); - - expect(JSON.parse(thresholdResult.priceSnapshotJson ?? "{}")).toMatchObject({ - inputUsdPerMillion: 2, - longContextApplied: false, - outputUsdPerMillion: 12, - }); - expect(JSON.parse(longContextResult.priceSnapshotJson ?? "{}")).toMatchObject({ - cacheReadUsdPerMillion: 0.4, - cacheWriteUsdPerMillion: 5, - inputUsdPerMillion: 4, - longContextApplied: true, - outputUsdPerMillion: 18, + expect(reported).toMatchObject({ pricingStatus: "priced", totalCostUsd: 0.12 }); + expect(snapshot(reported)).toMatchObject({ + source: "runtime_reported_usd", + tokenCountersUnavailable: true, }); - expect(longContextResult.totalCostUsd).toBeCloseTo(0.796004, 8); }); - test("includes separately normalized cache writes in the long-context threshold", () => { - for (const cacheCreationTokens of [72_000, 72_001]) { - const result = calculateUsageCost({ - cacheCreationTokens, - cacheReadTokens: 100_000, - inputTokens: 200_000, - model: "gpt-5.6-luna", - outputTokens: 100, - pricedAtMs: Date.UTC(2026, 8, 19), - provider: "openai", - }); - expect(JSON.parse(result.priceSnapshotJson ?? "{}")).toMatchObject({ - longContextApplied: cacheCreationTokens > 72_000, - inputUsdPerMillion: cacheCreationTokens > 72_000 ? 0.4 : 0.2, - }); - } + test("refresh selects only mapped provider prices and context tiers", () => { + const cost = { + input: 1, + output: 2, + cache_read: 0, + reasoning: 99, + input_audio: 98, + context_over_200k: { input: 50, output: 50 }, + tiers: [ + { + input: 3, + output: 4, + cache_write: 0, + reasoning: 97, + tier: { type: "context", size: 100 }, + }, + { input: 5, output: 6, tier: { type: "requests", size: 1 } }, + ], + }; + const providers = Object.fromEntries( + Object.values(MODELS_DEV_PROVIDER_IDS).map((id) => [ + id, + { + models: { priced: { cost, name: "Not imported" }, unpriced: {} }, + name: "Not imported", + }, + ]), + ); + providers["opencode-go"] = { models: { priced: { cost } } }; + const selected = selectModelsDevPrices(providers); + expect(Object.keys(selected)).toEqual(Object.keys(MODELS_DEV_PROVIDER_IDS)); + expect(selected["gemini"]).toEqual({ + priced: { + input: 1, + output: 2, + cache_read: 0, + tiers: [{ input: 3, output: 4, cache_write: 0, tier: { type: "context", size: 100 } }], + }, + }); + expect(selected["opencode-go"]).toBeUndefined(); + expect(() => selectModelsDevPrices({})).toThrow("provider is missing"); }); }); diff --git a/apps/api/tests/cost-usage-event.test.ts b/apps/api/tests/cost-usage-event.test.ts index 567ae608..19456757 100644 --- a/apps/api/tests/cost-usage-event.test.ts +++ b/apps/api/tests/cost-usage-event.test.ts @@ -261,19 +261,19 @@ describe("cost usage event", () => { output_tokens: 200, pricing_status: "priced", provider: "openai", - total_cost_usd_micros: 5_300, + total_cost_usd_micros: 5_275, }); const priceSnapshot = JSON.parse(row?.price_snapshot_json ?? "{}") as Record; expect(priceSnapshot).toMatchObject({ billableInputTokens: 860, cacheReadUsdPerMillion: 0.25, - cacheWriteUsdPerMillion: 3.125, + cacheWriteUsdPerMillion: 2.5, inputUsdPerMillion: 2.5, longContextApplied: false, model: "gpt-5.4", outputUsdPerMillion: 15, provider: "openai", - source: "mosoo_seed_2026_07_10", + source: "https://models.dev/api.json", }); }); @@ -334,7 +334,7 @@ describe("cost usage event", () => { pricing_status: "priced", provider: "kimi", runtime_id: "opencode", - total_cost_usd_micros: 1_633, + total_cost_usd_micros: 1_671, }); const priceSnapshot = JSON.parse(row?.price_snapshot_json ?? "{}") as Record; expect(priceSnapshot).toMatchObject({ @@ -347,6 +347,39 @@ describe("cost usage event", () => { }); }); + test("preserves recorded costs and snapshots while pricing new calls from the current catalog", async () => { + const database = createUsageEventDatabase(); + const input = { + callKey: "recorded-call", + driverInstanceId: DRIVER_INSTANCE_ID, + nativeCallId: null, + run: { ...RUN_CONTEXT, model: "gpt-5.4", provider: "openai" }, + usage: { + inputTokens: 1_000_000, + outputTokens: 0, + usageContract: "openai_total_with_cached_breakdown", + }, + } satisfies RecordRuntimeUsageEventInput; + await recordRuntimeUsageEvent(database, input); + const recordedSnapshot = '{"inputUsdPerMillion":99,"source":"previous_catalog"}'; + await database + .prepare("UPDATE usage_event SET total_cost_usd_micros = 99000000, price_snapshot_json = ?") + .bind(recordedSnapshot) + .run(); + await recordRuntimeUsageEvent(database, { ...input, callKey: "new-call" }); + const { results } = await database + .prepare( + "SELECT total_cost_usd_micros, price_snapshot_json FROM usage_event ORDER BY total_cost_usd_micros", + ) + .all(); + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ total_cost_usd_micros: 5_000_000 }); + expect(results[1]).toEqual({ + total_cost_usd_micros: 99_000_000, + price_snapshot_json: recordedSnapshot, + }); + }); + test("persists and idempotently updates reported USD cost without token counters", async () => { const database = createUsageEventDatabase(); const usage = { diff --git a/apps/api/tests/session-model-call-identity.test.ts b/apps/api/tests/session-model-call-identity.test.ts index 19017df2..ecf4a9f0 100644 --- a/apps/api/tests/session-model-call-identity.test.ts +++ b/apps/api/tests/session-model-call-identity.test.ts @@ -403,7 +403,7 @@ describe("session model call identity", () => { run_purpose: "preview", runtime_id: "openai-runtime", source_event_id: `${DRIVER_INSTANCE_ID}:native-call-1`, - total_cost_usd_micros: 5_300, + total_cost_usd_micros: 5_275, }); expect(JSON.parse(usageEvent?.price_snapshot_json ?? "{}")).toMatchObject({ model: "gpt-5.4", diff --git a/docs/prd/cost-dashboard.md b/docs/prd/cost-dashboard.md index d25b73d6..0dfbbb51 100644 --- a/docs/prd/cost-dashboard.md +++ b/docs/prd/cost-dashboard.md @@ -6,6 +6,7 @@ What the Project Usage view and an Agent's **Cost** tab show, and how far their - Usage is built from the model calls mosoo records. Dollar amounts are estimates from observed tokens and mosoo's reference prices, or the USD amount a runtime reported. - **Overview**, **By Agent** and **By Model** break usage down by run type and time range, and each tab exports to CSV. Usage without an Agent appears as **Direct sessions**. +- Reference prices come from a checked-in, prices-only models.dev subset in the API. Run `just models-refresh` and review the git diff to update it; reviewed overrides cover missing presets. Cache rates default to input rates when absent; explicit zero rates remain free. Reasoning uses the output rate, and audio rates are ignored because audio is not metered separately. Structured context tiers apply strictly above their threshold. - A later, more complete report for the same model call replaces that call's estimate. A price change does not recompute history. ## Limits diff --git a/justfile b/justfile index 72c0e666..6cfedfa8 100644 --- a/justfile +++ b/justfile @@ -39,6 +39,10 @@ fmt-check: docs-check: bun run docs:check +# Refresh reference prices from models.dev; review the resulting git diff. +models-refresh: + bun apps/api/bin/refresh-models-dev.ts + # Check formatting for one file or directory. fmt-check-path path: ./node_modules/.bin/vp fmt --check --ignore-path .gitignore "{{ path }}" From 0e6420589f172880ee2115117f44e1666266ae09 Mon Sep 17 00:00:00 2001 From: Frad LEE Date: Fri, 16 Oct 2026 15:00:29 +0800 Subject: [PATCH 2/2] fix(cost): default gpt-5.3 cache writes to input price --- apps/api/src/modules/cost/domain/cost-pricing.ts | 2 +- apps/api/tests/cost-pricing.test.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/api/src/modules/cost/domain/cost-pricing.ts b/apps/api/src/modules/cost/domain/cost-pricing.ts index 8969a481..30fcda6f 100644 --- a/apps/api/src/modules/cost/domain/cost-pricing.ts +++ b/apps/api/src/modules/cost/domain/cost-pricing.ts @@ -63,7 +63,7 @@ const VENDOR_BY_PROVIDER: Readonly> = { // Retain the reviewed Mosoo rates for the preset absent from models.dev. const PRICING_OVERRIDES: ModelsDevPrices = { openai: { - "gpt-5.3": { input: 1.75, output: 14, cache_read: 0.175, cache_write: 2.1875 }, + "gpt-5.3": { input: 1.75, output: 14, cache_read: 0.175 }, }, }; diff --git a/apps/api/tests/cost-pricing.test.ts b/apps/api/tests/cost-pricing.test.ts index 2ab2cad9..bb454284 100644 --- a/apps/api/tests/cost-pricing.test.ts +++ b/apps/api/tests/cost-pricing.test.ts @@ -97,6 +97,8 @@ describe("cost pricing", () => { expect( catalog.findModelPricing({ providerId: "openai", modelId: "openai/gpt-5.3-latest" }), ).toMatchObject({ + cacheReadUsdPerMillion: 0.175, + cacheWriteUsdPerMillion: 1.75, inputUsdPerMillion: 1.75, outputUsdPerMillion: 14, source: "mosoo_override", @@ -111,6 +113,7 @@ describe("cost pricing", () => { ["openai", "gpt-5.4"], ["openai", "gpt-5.4-mini"], ["openai", "gpt-5.2"], + ["openai", "gpt-5.3"], ["kimi", "kimi-k2.6"], ["kimi", "kimi-k2.7-code"], ["minimax", "MiniMax-M3"],